mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddd5f3cfd | ||
|
|
31f4d4aafa | ||
|
|
e06dc933b8 | ||
|
|
1418be999d | ||
|
|
df06a49949 | ||
|
|
b6dcc74383 | ||
|
|
fead8381bc | ||
|
|
2ab9f8ace6 | ||
|
|
81effcb349 | ||
|
|
3dd1f8151a | ||
|
|
272a7121a9 | ||
|
|
52a7850c1d | ||
|
|
b297ad8564 | ||
|
|
a3810353f7 | ||
|
|
d4dddb1f38 | ||
|
|
21560a1f3a | ||
|
|
bdfb6bb073 | ||
|
|
aac0590017 | ||
|
|
afba33dd83 | ||
|
|
5de5dee52f | ||
|
|
ba3e253569 | ||
|
|
4488af0ab7 | ||
|
|
aae8ad62bd | ||
|
|
9606a33f15 | ||
|
|
f626d70538 | ||
|
|
a91ef44686 | ||
|
|
e3bef4e74b | ||
|
|
358bf5e244 | ||
|
|
fba2d3a59d | ||
|
|
0abba4ece2 | ||
|
|
e9b0694b67 | ||
|
|
49bbf1bc85 | ||
|
|
f5b6a5c605 | ||
|
|
fccc6cedd5 | ||
|
|
a03766448e | ||
|
|
f0c9d318ff | ||
|
|
5c5cc6f28e | ||
|
|
246b96bfb4 | ||
|
|
352e020b54 | ||
|
|
72f5690ea6 | ||
|
|
1ed1199574 | ||
|
|
fd4a43de96 | ||
|
|
e406fd4cc1 | ||
|
|
1e555cc712 | ||
|
|
227a6b2a7b | ||
|
|
5f661d71a6 | ||
|
|
2804543b9d | ||
|
|
380397321c | ||
|
|
d9c8902d34 | ||
|
|
a1b9abd4cb | ||
|
|
5d30844fa8 | ||
|
|
af1b1b4249 | ||
|
|
2cce9ae56c | ||
|
|
4bab7c61a9 | ||
|
|
81e2bc1dba | ||
|
|
df2783d8a1 | ||
|
|
20a6953b54 | ||
|
|
fdd4f69247 | ||
|
|
5ee777b196 | ||
|
|
dd827af3cd | ||
|
|
948205667b | ||
|
|
dbbfa75429 | ||
|
|
78790f73df | ||
|
|
2950d8b3f9 | ||
|
|
4d3a9bc165 | ||
|
|
7d57845502 | ||
|
|
83ef111599 | ||
|
|
0e6848d886 | ||
|
|
1eeeb63a76 | ||
|
|
a4ba302223 | ||
|
|
8c9094d85a | ||
|
|
4529b107dd | ||
|
|
17663bc759 | ||
|
|
278257998b |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 760 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 761 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 605 KiB |
@@ -81,8 +81,7 @@ Currently supported models:
|
||||
|
||||
## Screenshots
|
||||
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/1.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/2.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/3.png" width="200"><img src="https://github.com/d4rken-org/capod/raw/main/.assets/screenshots/4.png" width="200">
|
||||
<img src="https://raw.githubusercontent.com/d4rken-org/capod/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="200">
|
||||
<img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/1_dashboard_light.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/2_dashboard_dark.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/3_case_popup.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/4_widget_configuration.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/5_device_profiles.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/6_add_profile.png" width="100"><img src="https://github.com/d4rken-org/capod/raw/main/fastlane/metadata/android/en-US/images/phoneScreenshots/7_device_settings_reactions.png" width="100">
|
||||
|
||||
## Thanks to
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -21,11 +21,14 @@ import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.main.ui.overview.OverviewScreen
|
||||
import eu.darken.capod.main.ui.overview.OverviewViewModel
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.main.ui.widget.WidgetTheme
|
||||
import eu.darken.capod.main.ui.widget.withLocalizedPreviewEstimates
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerScreen
|
||||
import eu.darken.capod.profiles.ui.DeviceManagerViewModel
|
||||
@@ -52,12 +55,35 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
)
|
||||
}
|
||||
// Plausible estimates for the mock battery levels: runtime under each gauge, charge ETA in
|
||||
// the charging chip of whichever mock pod is charging.
|
||||
val estimates = if (showAap) {
|
||||
mapOf(
|
||||
"preview-dual-aap" to BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE),
|
||||
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||
),
|
||||
"preview-single-aap" to BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(480, 0.05f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 45),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
mapOf(
|
||||
"preview-dual-mixed" to BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(285, 0.17f, BatteryEstimate.Source.LIVE, minutesUntilCharged = 20),
|
||||
right = BatteryEstimate.Pod(160, 0.17f, BatteryEstimate.Source.LIVE),
|
||||
),
|
||||
"preview-single" to BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(1020, 0.05f, BatteryEstimate.Source.LIVE),
|
||||
),
|
||||
)
|
||||
}
|
||||
OverviewScreen(
|
||||
state = OverviewViewModel.State(
|
||||
now = MOCK_NOW,
|
||||
permissions = emptySet(),
|
||||
devices = devices,
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
@@ -65,6 +91,7 @@ internal fun DashboardContent(showAap: Boolean = false) = PreviewWrapper {
|
||||
),
|
||||
upgradeInfo = MockPodDataProvider.gplayInfo(isPro = true),
|
||||
showUnmatchedDevices = false,
|
||||
batteryEstimates = estimates,
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
@@ -229,7 +256,7 @@ internal fun HomescreenWidgetContent() {
|
||||
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
||||
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
),
|
||||
).withLocalizedPreviewEstimates(LocalContext.current),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="foss_upgrade_donate_label">تبرُّع</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">أنا متبرع بالفعل</string>
|
||||
<string name="foss_upgrade_no_money_label">أُنفق كل أموالي على AirPods</string>
|
||||
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيداً، فكر في دعم التطوير للمساعدة في استمرار المشروع.</string>
|
||||
<string name="upgrade_foss_sponsor_action">ادعم التطوير</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود على Google Play.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">عدت بسرعة؟ دعمك يبقي CAPod حياً.</string>
|
||||
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
|
||||
<string name="foss_upgrade_alreadydonated_label">لقد تبرّعتُ بالفعل</string>
|
||||
<string name="foss_upgrade_no_money_label">أنفق كل أموالي على سمّاعات AirPods</string>
|
||||
<string name="upgrade_foss_preamble">برنامج CAPod FOSS مجاني ومفتوح المصدر. إذا وجدتهُ مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
|
||||
<string name="upgrade_foss_sponsor_action">دعم التطوير</string>
|
||||
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبُّع. لا قيود من جوجل بلاي.</string>
|
||||
<string name="upgrade_foss_sponsor_returned_early">هل عدت بالفعل؟ دعمك يُبقي CAPod حيًّا.</string>
|
||||
<string name="upgrade_badge_label">البرمجيات الحرة والمفتوحة المصدر</string>
|
||||
</resources>
|
||||
|
||||
@@ -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 seklōš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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upgrades_gplay_unavailable_error">خدمات Google Play غير متاحة.</string>
|
||||
<string name="upgrades_no_purchases_found_check_account">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||
<string name="upgrades_gplay_billing_error_label">خطأ في Google Play</string>
|
||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في Google Play. يرجى المحاولة مرة أخرى لاحقًا أو إعادة تشغيل هاتفك.\n\nخطأ: %s</string>
|
||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة Google Play</string>
|
||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ عند طلب تفاصيل الشراء من Google Play. امسح ذاكرة التخزين المؤقت لـ Google Play وأعد تشغيل هاتفك.\n\nخطأ %s</string>
|
||||
<string name="upgrade_restore_action">استعادة المُشتَرَى</string>
|
||||
<string name="upgrade_badge_label">احترافي</string>
|
||||
<string name="upgrades_gplay_unavailable_error">خدمات جوجل بلاي غير متوفّرة.</string>
|
||||
<string name="upgrades_no_purchases_found_check_account">لم يُعثَر على أيّ مشتريات. هل تستخدم الحساب الصحيح؟</string>
|
||||
<string name="upgrades_gplay_billing_error_label">خطأ في جوجل بلاي</string>
|
||||
<string name="upgrades_gplay_billing_error_description">حدث خطأ في سوق جوجل بلاي. يُرجى المحاولة لاحقًا أو إعادة تشغيل هاتفك.\n\nالخطأ: %s</string>
|
||||
<string name="upgrades_gplay_billing_result_error_label">خطأ في فوترة جوجل بلاي</string>
|
||||
<string name="upgrades_gplay_billing_result_error_description">حدث خطأ أثناء طلب تفاصيل عملية الشراء من سوق جوجل بلاي. يُرجى مسح ذاكرة التخزين المؤقّت لسوق جوجل بلاي وإعادة تشغيل هاتفك.\n\nالخطأ %s</string>
|
||||
<string name="upgrade_restore_action">استعادة عملية الشراء</string>
|
||||
<string name="upgrade_badge_label">النسخة الاحترافية</string>
|
||||
</resources>
|
||||
|
||||
@@ -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 jāpārsāknē tālrunis.\n\nKļūda %s</string>
|
||||
<string name="upgrade_restore_action">Atjaunot pirkumu</string>
|
||||
<string name="upgrade_badge_label">Pro</string>
|
||||
</resources>
|
||||
|
||||
@@ -15,12 +15,16 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.ui.widget.WidgetManager
|
||||
import eu.darken.capod.main.ui.widget.toWidgetKey
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.displayKey
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -36,6 +40,7 @@ open class App : Application() {
|
||||
@Inject lateinit var deviceMonitor: DeviceMonitor
|
||||
@Inject lateinit var widgetManager: WidgetManager
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||
@Inject @AppScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -62,8 +67,15 @@ open class App : Application() {
|
||||
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
deviceMonitor.devicesWithProfiles()
|
||||
.distinctUntilChangedBy { devices -> devices.map { it.toWidgetKey() } }
|
||||
combine(
|
||||
deviceMonitor.devicesWithProfiles(),
|
||||
batteryEstimator.estimates,
|
||||
) { devices, estimates -> devices to estimates }
|
||||
// Estimate display values can change while the device key is stable (e.g. a charge
|
||||
// ETA gets suppressed after a stall) — refresh on those too, but only on visible ones.
|
||||
.distinctUntilChangedBy { (devices, estimates) ->
|
||||
devices.map { device -> device.toWidgetKey() to estimates.estimateFor(device)?.displayKey(device) }
|
||||
}
|
||||
.throttleLatest(1000)
|
||||
.onEach {
|
||||
log(TAG, VERBOSE) { "Devices changed, refreshing widgets." }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -14,48 +17,103 @@ class MediaControl @Inject constructor(
|
||||
private val audioManager: AudioManager,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
private var capPauseExpiryElapsedRealtime: Long = 0L
|
||||
/**
|
||||
* Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay]
|
||||
* dispatches a resume or when music transitions inactive→active from any source. Read by
|
||||
* [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused.
|
||||
*
|
||||
* "Sticky" — no time-based expiry. The original 15-second window was too short for typical
|
||||
* pod-out conversations and had a known race where music starting again via another source
|
||||
* (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in.
|
||||
*/
|
||||
@Volatile private var capPaused: Boolean = false
|
||||
@Volatile private var lastKnownMusicActive: Boolean = false
|
||||
|
||||
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
|
||||
val nowActive = audioManager.isMusicActive
|
||||
if (!lastKnownMusicActive && nowActive) {
|
||||
// Music started by some source (could be us via sendPlay or someone else).
|
||||
// Either way, our pause memory is stale — drop it so a future pod-in doesn't
|
||||
// re-fire a play key over already-active music.
|
||||
capPaused = false
|
||||
}
|
||||
lastKnownMusicActive = nowActive
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Seed the active flag from current state so we won't miss the next inactive→active
|
||||
// transition if music is already playing when MediaControl is constructed.
|
||||
lastKnownMusicActive = audioManager.isMusicActive
|
||||
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = audioManager.isMusicActive
|
||||
|
||||
val wasRecentlyPausedByCap: Boolean
|
||||
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
|
||||
get() = capPaused
|
||||
|
||||
suspend fun sendPlay() {
|
||||
log(TAG, INFO) { "sendPlay()" }
|
||||
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
|
||||
if (audioManager.isMusicActive && !capPaused) {
|
||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
||||
return
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
||||
clearRecentCapPause()
|
||||
capPaused = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a MEDIA_PAUSE key event if music is currently playing.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched (and the 15-second
|
||||
* [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because
|
||||
* nothing was playing. Callers that need to distinguish "we actually paused" from "there
|
||||
* was nothing to pause" — e.g. the sleep reaction, which gates its notification and
|
||||
* cooldown on a real pause — should branch on the return value rather than checking
|
||||
* [isPlaying] themselves to avoid a check-then-act race with the audio system.
|
||||
* @param rememberForResume When `true`, arms [wasRecentlyPausedByCap] so a subsequent
|
||||
* pod-in transition can auto-resume — set this only from the auto-pause / ear-detection
|
||||
* flow. When `false` (default), the dispatched pause clears any pending auto-resume —
|
||||
* this is the path for stem-press play/pause, sleep detection, and anywhere else CAPod
|
||||
* is relaying an explicit user choice to stop playback. Matches Apple's iOS/macOS
|
||||
* behavior where only ear-removal auto-pauses are eligible for auto-resume.
|
||||
*
|
||||
* Returns `true` when a key event was actually dispatched, `false` when the call was a
|
||||
* no-op because nothing was playing. A no-op leaves the existing flag state untouched
|
||||
* (so a sleep-reaction firing while music is already paused doesn't accidentally cancel
|
||||
* a pending auto-resume from a recent ear-removal pause). Callers that need to
|
||||
* distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep
|
||||
* reaction, which gates its notification and cooldown on a real pause — should branch
|
||||
* on the return value rather than checking [isPlaying] themselves to avoid a
|
||||
* check-then-act race with the audio system.
|
||||
*/
|
||||
suspend fun sendPause(): Boolean {
|
||||
log(TAG, INFO) { "sendPause()" }
|
||||
suspend fun sendPause(rememberForResume: Boolean = false): Boolean {
|
||||
log(TAG, INFO) { "sendPause(rememberForResume=$rememberForResume)" }
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "Music is not playing, not sending pause" }
|
||||
return false
|
||||
}
|
||||
// Set BEFORE the suspending sendKey() call. If we set after, an inactive→active
|
||||
// playback callback that fires during the dispatch (e.g. a fast user resume on the
|
||||
// phone, or another app grabbing audio focus and immediately starting) could clear
|
||||
// capPaused mid-dispatch and we'd then overwrite it back to true on a stale pause.
|
||||
// This single explicit assignment also covers the contract that an explicit user
|
||||
// pause cancels a pending auto-resume.
|
||||
capPaused = rememberForResume
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||
markRecentCapPause()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches MEDIA_STOP and clears any pending auto-resume — Stop is an explicit user
|
||||
* "stay stopped" action, so a later pod-in must not auto-resume from a prior auto-pause.
|
||||
*/
|
||||
suspend fun sendStop() {
|
||||
log(TAG, INFO) { "sendStop()" }
|
||||
capPaused = false
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||
}
|
||||
|
||||
suspend fun sendPlayPause() {
|
||||
log(TAG) { "sendPlayPause()" }
|
||||
if (wasRecentlyPausedByCap) {
|
||||
if (capPaused) {
|
||||
sendPlay()
|
||||
return
|
||||
}
|
||||
@@ -101,16 +159,71 @@ class MediaControl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun markRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
|
||||
/** Current STREAM_MUSIC volume index. Used to detect user-initiated volume changes after a duck. */
|
||||
fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
|
||||
/**
|
||||
* Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns
|
||||
* the prior + the volume actually applied, so the caller can later restore it and detect whether
|
||||
* the user changed the volume in the meantime.
|
||||
*
|
||||
* Returns `null` (no-op) when nothing is playing, the device has fixed volume, or the computed
|
||||
* target wouldn't actually lower the volume. No [AudioManager.FLAG_SHOW_UI] — this fires on a
|
||||
* frequent push event and the volume panel flashing would be noisy. The applied target is read
|
||||
* back from the system because Bluetooth absolute-volume routes can quantize the requested value.
|
||||
*/
|
||||
fun duckMusicVolume(reductionPercent: Int): VolumeDuck? {
|
||||
if (!audioManager.isMusicActive) {
|
||||
log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" }
|
||||
return null
|
||||
}
|
||||
if (audioManager.isVolumeFixed) {
|
||||
log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" }
|
||||
return null
|
||||
}
|
||||
val percent = reductionPercent.coerceIn(0, 100)
|
||||
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val min = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val prior = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
val target = (prior * (100 - percent) / 100).coerceIn(min, max)
|
||||
if (target >= prior) {
|
||||
log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" }
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" }
|
||||
VolumeDuck(priorVolume = prior, appliedVolume = applied)
|
||||
} catch (e: SecurityException) {
|
||||
// setStreamVolume throws under Do-Not-Disturb without notification policy access.
|
||||
log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = 0L
|
||||
/** Restores STREAM_MUSIC to [priorVolume]. No-op on fixed-volume devices; denials are logged. */
|
||||
fun restoreMusicVolume(priorVolume: Int) {
|
||||
if (audioManager.isVolumeFixed) return
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, priorVolume, 0)
|
||||
log(TAG, INFO) { "restoreMusicVolume($priorVolume)" }
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG, WARN) { "restoreMusicVolume: setStreamVolume denied: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */
|
||||
data class VolumeDuck(
|
||||
val priorVolume: Int,
|
||||
val appliedVolume: Int,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ fun BleScanResult.logSummary(): String {
|
||||
.sortedBy { it.key }
|
||||
.joinToString(separator = ",") { (manufacturerId, data) -> "$manufacturerId:${data.size}B" }
|
||||
.ifEmpty { "-" }
|
||||
return "addr=${address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
||||
return "addr=${address.redactedForLogs()}, rssi=$rssi, gen=$generatedAtNanos, payloads=[$payloadSummary]"
|
||||
}
|
||||
|
||||
@JvmName("logBleScanResultCollectionSummary")
|
||||
@@ -34,7 +34,7 @@ fun ScanResult.logSummary(): String {
|
||||
.sorted()
|
||||
.joinToString(separator = ",")
|
||||
.ifEmpty { "-" }
|
||||
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, payloads=[$payloadSummary]"
|
||||
return "addr=${device.address.redactedForLogs()}, rssi=$rssi, gen=$timestampNanos, payloads=[$payloadSummary]"
|
||||
}
|
||||
|
||||
@JvmName("logFrameworkScanResultCollectionSummary")
|
||||
|
||||
@@ -33,7 +33,6 @@ import javax.inject.Singleton
|
||||
class BleScanner @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val fakeBleData: FakeBleData,
|
||||
private val scanResultForwarder: BleScanResultForwarder,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
@@ -206,7 +205,6 @@ class BleScanner @Inject constructor(
|
||||
log(TAG) { "BleScanner stopped" }
|
||||
}
|
||||
}
|
||||
.map { fakeBleData.maybeAddfakeData(it) }
|
||||
|
||||
private val receiverIntent by lazy {
|
||||
Intent(context, BleScanResultReceiver::class.java).apply {
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -23,6 +24,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ContinuityProtocol
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
@@ -390,9 +392,6 @@ class BluetoothManager2 @Inject constructor(
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private var _isNudgeAvailable: Boolean = true
|
||||
val isNudgeAvailable: Boolean get() = _isNudgeAvailable
|
||||
|
||||
/**
|
||||
* Set the Android-local alias for a bonded [device]. Updates what Android's system Bluetooth
|
||||
* settings display without touching the AirPods firmware itself. Uses reflection on the hidden
|
||||
@@ -425,29 +424,42 @@ class BluetoothManager2 @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): Boolean = getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
suspend fun nudgeConnection(device: BluetoothDevice2): NudgeAttemptResult =
|
||||
getBluetoothProfile().map { bluetoothProfile ->
|
||||
try {
|
||||
log(TAG) { "Nudging Android connection to $device" }
|
||||
|
||||
val target = device.internal ?: return@map false
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
val target = device.internal ?: return@map NudgeAttemptResult.Rejected
|
||||
val connectMethod = BluetoothHeadset::class.java.getDeclaredMethod(
|
||||
"connect", BluetoothDevice::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
val isSecurityException = e is SecurityException ||
|
||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||
if (isSecurityException) {
|
||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable: missing MODIFY_PHONE_STATE permission" }
|
||||
_isNudgeAvailable = false
|
||||
val accepted = connectMethod.invoke(bluetoothProfile.proxy, target) as? Boolean ?: false
|
||||
log(TAG) { "Nudged connection to $device — accepted=$accepted" }
|
||||
if (accepted) NudgeAttemptResult.Accepted else NudgeAttemptResult.Rejected
|
||||
} catch (e: Exception) {
|
||||
val isSecurityException = e is SecurityException ||
|
||||
(e is java.lang.reflect.InvocationTargetException && e.cause is SecurityException)
|
||||
if (!isSecurityException) {
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
return@map NudgeAttemptResult.Rejected
|
||||
}
|
||||
// BLUETOOTH_CONNECT is only a runtime permission on Android 12+. On older
|
||||
// versions a SecurityException can't be a missing-permission failure for that
|
||||
// permission — it's hidden-API enforcement (e.g. MODIFY_PHONE_STATE) and we
|
||||
// should persist BROKEN.
|
||||
val missingBtConnect = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
!Permission.BLUETOOTH_CONNECT.isGranted(context)
|
||||
if (missingBtConnect) {
|
||||
log(TAG, WARN) { "nudgeConnection failed because BLUETOOTH_CONNECT is not granted" }
|
||||
NudgeAttemptResult.UnavailableMissingPermission
|
||||
} else {
|
||||
log(TAG, ERROR) { "nudgeConnection is permanently unavailable on this device: $e" }
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
NudgeAttemptResult.UnavailableHiddenApi
|
||||
}
|
||||
}
|
||||
Bugs.report(tag = TAG, "BluetoothHeadset.connect(device) is unavailable", exception = e)
|
||||
false
|
||||
}
|
||||
}.first()
|
||||
}.first()
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "Manager2")
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.fromHex
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@Reusable
|
||||
class FakeBleData @Inject constructor(
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
fun maybeAddfakeData(originals: Collection<BleScanResult>): Collection<BleScanResult> {
|
||||
if (!debugSettings.showFakeData.valueBlocking) return originals
|
||||
return originals + getFakeData()
|
||||
}
|
||||
|
||||
fun getFakeData(): Collection<BleScanResult> {
|
||||
val fakeDevices = mutableListOf<BleScanResult>()
|
||||
val now = timeSource.now()
|
||||
val generatedAt = timeSource.elapsedRealtimeNanos()
|
||||
// AirPods Gen1
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "78:73:AF:B4:85:22",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = generatedAt + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// AirPods Gen2
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "78:73:FF:B4:85:5E",
|
||||
rssi = Random.nextInt(100) * -1,
|
||||
generatedAtNanos = generatedAt + 100,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// AirPods Gen3
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "4E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 200,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
// AirPods Max
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "7E:E5:C7:65:D2:B5",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 300,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
// BeatsFlex
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "5E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// Tws i99999
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "5E:9E:D1:29:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 400,
|
||||
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
// Unknown Device
|
||||
BleScanResult(
|
||||
receivedAt = now,
|
||||
address = "6E:9E:D1:49:D2:6D",
|
||||
rssi = Random.nextInt(15, 75) * -1,
|
||||
generatedAtNanos = generatedAt + 500,
|
||||
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
|
||||
).run {
|
||||
fakeDevices.add(this)
|
||||
}
|
||||
|
||||
return fakeDevices
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class NudgeAvailability {
|
||||
@SerialName("nudge.unknown") UNKNOWN,
|
||||
@SerialName("nudge.available") AVAILABLE,
|
||||
@SerialName("nudge.broken") BROKEN,
|
||||
}
|
||||
|
||||
enum class NudgeAttemptResult {
|
||||
Accepted,
|
||||
Rejected,
|
||||
UnavailableHiddenApi,
|
||||
UnavailableMissingPermission,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package eu.darken.capod.common.bluetooth
|
||||
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NudgeCapabilityStore @Inject constructor(
|
||||
generalSettings: GeneralSettings,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
) {
|
||||
|
||||
private val persistedValue = generalSettings.nudgeAvailability
|
||||
|
||||
// Resolve the persisted value synchronously at construction so consumers that read
|
||||
// `availability.value` (resolver, AutoConnect precondition, UI state) never see the
|
||||
// synthetic UNKNOWN seed before the first DataStore emission arrives. On a known-broken
|
||||
// device this prevents one bonus ALWAYS-mode + nudge-attempt cycle per cold start.
|
||||
val availability: StateFlow<NudgeAvailability> = persistedValue.flow
|
||||
.stateIn(
|
||||
scope = appScope + dispatcherProvider.IO,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = persistedValue.valueBlocking,
|
||||
)
|
||||
|
||||
fun record(result: NudgeAttemptResult) {
|
||||
val verdict = verdictFor(result) ?: return
|
||||
if (availability.value == verdict) return
|
||||
log(TAG, INFO) { "Recording nudge verdict: $verdict (from $result)" }
|
||||
appScope.launch(dispatcherProvider.IO) {
|
||||
persistedValue.value(verdict)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Bluetooth", "NudgeCapabilityStore")
|
||||
|
||||
internal fun verdictFor(result: NudgeAttemptResult): NudgeAvailability? = when (result) {
|
||||
NudgeAttemptResult.Accepted -> NudgeAvailability.AVAILABLE
|
||||
NudgeAttemptResult.UnavailableHiddenApi -> NudgeAvailability.BROKEN
|
||||
NudgeAttemptResult.Rejected,
|
||||
NudgeAttemptResult.UnavailableMissingPermission -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,37 @@ object MockPodDataProvider {
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Dual pods with a fully-populated AAP session: ANC on, both pods in-ear, left pod as the
|
||||
* primary (microphone) pod. Used to preview the card with all status flags AND a battery estimate.
|
||||
*/
|
||||
fun dualPodFullyLoaded(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-full",
|
||||
label = "My AirPods Pro",
|
||||
ble = airPodsProWithKeys(),
|
||||
aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ON,
|
||||
supported = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
),
|
||||
),
|
||||
AapSetting.EarDetection::class to AapSetting.EarDetection(
|
||||
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
|
||||
),
|
||||
AapSetting.PrimaryPod::class to AapSetting.PrimaryPod(
|
||||
pod = AapSetting.PrimaryPod.Pod.LEFT,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** Dual pods with a profile that has reaction toggles set — used for the Reactions screenshot. */
|
||||
fun dualPodMonitoredWithReactions(): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-reactions",
|
||||
@@ -459,7 +490,6 @@ private class MockSingleBlePodSnapshot(
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockSingleAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
@@ -498,7 +528,6 @@ private class MockSingleAppleBlePodSnapshot(
|
||||
override val isBeingWorn: Boolean = _isBeingWorn
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
private class MockDualAppleBlePodSnapshot(
|
||||
private val _model: PodModel,
|
||||
private val _label: String,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -23,9 +24,10 @@ class DataStoreValue<T>(
|
||||
) {
|
||||
val keyName: String get() = key.name
|
||||
|
||||
val flow: Flow<T> = dataStore.data.map { prefs ->
|
||||
reader(prefs[key])
|
||||
}
|
||||
val flow: Flow<T> = dataStore.data
|
||||
.map { prefs -> prefs[key] }
|
||||
.distinctUntilChanged()
|
||||
.map { raw -> reader(raw) }
|
||||
|
||||
data class Updated<T>(val old: T, val new: T)
|
||||
|
||||
|
||||
@@ -8,9 +8,19 @@ import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.asLogSummary
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object Bugs {
|
||||
var reporter: AutomaticBugReporter? = null
|
||||
|
||||
/**
|
||||
* True while the debug log recorder is actively recording. Surfaces extra
|
||||
* diagnostic UI (e.g. raw BLE payload hex on device cards) so users helping
|
||||
* to add support for new headphones can capture useful data without a
|
||||
* permanent debug toggle.
|
||||
*/
|
||||
val isDebug = MutableStateFlow(false)
|
||||
|
||||
fun report(
|
||||
tag: String,
|
||||
message: String,
|
||||
|
||||
@@ -28,10 +28,4 @@ class DebugSettings @Inject constructor(
|
||||
// Reporting is opt-out for gplay, and opt-in for github builds
|
||||
defaultValue = BuildConfigWrap.FLAVOR == BuildConfigWrap.Flavor.GPLAY
|
||||
)
|
||||
val isDebugModeEnabled = dataStore.createValue("debug.mode.enabled", false)
|
||||
|
||||
val showFakeData = dataStore.createValue("debug.fakedata.enabled", false)
|
||||
|
||||
val showUnfiltered = dataStore.createValue("debug.blescanner.unfiltered.enabled", false)
|
||||
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -126,6 +127,10 @@ class RecorderModule @Inject constructor(
|
||||
}
|
||||
}
|
||||
.launchIn(appScope)
|
||||
|
||||
internalState.flow
|
||||
.onEach { Bugs.isDebug.value = it.isRecording }
|
||||
.launchIn(appScope)
|
||||
}
|
||||
|
||||
private fun createSessionDir(): File {
|
||||
|
||||
@@ -36,9 +36,6 @@ object Nav {
|
||||
@Serializable
|
||||
data object General : Settings
|
||||
|
||||
@Serializable
|
||||
data object Debug : Settings
|
||||
|
||||
@Serializable
|
||||
data object Support : Settings
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.BuildConfigWrap
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.datastore.createValue
|
||||
import eu.darken.capod.common.serialization.ByteArrayBase64Serializer
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
@@ -36,8 +37,6 @@ class GeneralSettings @Inject constructor(
|
||||
|
||||
private val dataStore: DataStore<Preferences> get() = context.dataStore
|
||||
|
||||
val monitorMode = dataStore.createValue("core.monitor.mode", MonitorMode.AUTOMATIC, json, onErrorFallbackToDefault = true)
|
||||
|
||||
val useExtraMonitorNotification = dataStore.createValue("core.monitor.notification.connected", false)
|
||||
|
||||
val keepConnectedNotificationAfterDisconnect =
|
||||
@@ -74,10 +73,18 @@ class GeneralSettings @Inject constructor(
|
||||
val isOffloadedBatchingDisabled = dataStore.createValue("core.compat.offloaded.batching.disabled", false)
|
||||
val useIndirectScanResultCallback = dataStore.createValue("core.compat.indirectcallback.enabled", false)
|
||||
|
||||
/** Runtime-detected verdict for whether `BluetoothHeadset.connect()` reflection works on this device. */
|
||||
val nudgeAvailability = dataStore.createValue(
|
||||
"core.bluetooth.nudge.availability", NudgeAvailability.UNKNOWN, json,
|
||||
onErrorFallbackToDefault = true,
|
||||
)
|
||||
|
||||
val isOnboardingDone = dataStore.createValue("core.onboarding.done", false)
|
||||
|
||||
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
|
||||
|
||||
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
|
||||
|
||||
val themeMode = dataStore.createValue(
|
||||
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
|
||||
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
package eu.darken.capod.main.core
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class MonitorMode(
|
||||
@StringRes val labelRes: Int
|
||||
val intensity: Int,
|
||||
) {
|
||||
@SerialName("monitor.mode.manual") MANUAL(
|
||||
R.string.settings_monitor_mode_manual_label
|
||||
),
|
||||
@SerialName("monitor.mode.automatic") AUTOMATIC(
|
||||
R.string.settings_monitor_mode_automatic_label
|
||||
),
|
||||
@SerialName("monitor.mode.always") ALWAYS(
|
||||
R.string.settings_monitor_mode_always_label
|
||||
),
|
||||
}
|
||||
@SerialName("monitor.mode.manual") MANUAL(intensity = 0),
|
||||
@SerialName("monitor.mode.automatic") AUTOMATIC(intensity = 1),
|
||||
@SerialName("monitor.mode.always") ALWAYS(intensity = 2),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.permissions.Permission
|
||||
import eu.darken.capod.common.permissions.isRequired
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -21,7 +22,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class PermissionTool @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
private val permissionCheckTrigger = MutableStateFlow(UUID.randomUUID())
|
||||
@@ -41,13 +42,11 @@ class PermissionTool @Inject constructor(
|
||||
|
||||
val missingPermissions: Flow<Set<Permission>> = combine(
|
||||
permissionCheckTrigger,
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
anyPopupEnabled,
|
||||
) { _, monitorMode, showPopUp ->
|
||||
Permission.entries
|
||||
.filter { it != Permission.IGNORE_BATTERY_OPTIMIZATION || monitorMode == MonitorMode.ALWAYS }
|
||||
.filter { it != Permission.ACCESS_BACKGROUND_LOCATION || monitorMode == MonitorMode.ALWAYS }
|
||||
.filter { it != Permission.SYSTEM_ALERT_WINDOW || showPopUp }
|
||||
.filter { isApplicable(it, monitorMode, showPopUp) }
|
||||
.filter { it.isRequired(context) }
|
||||
.toSet()
|
||||
}
|
||||
@@ -61,5 +60,24 @@ class PermissionTool @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("PermissionTool")
|
||||
|
||||
/**
|
||||
* Whether [permission] is applicable for the user's current configuration.
|
||||
* Three permissions are conditional on monitor mode or popup usage:
|
||||
* - [Permission.IGNORE_BATTERY_OPTIMIZATION] only when always-on scanning is needed
|
||||
* - [Permission.ACCESS_BACKGROUND_LOCATION] same
|
||||
* - [Permission.SYSTEM_ALERT_WINDOW] only when at least one popup reaction is enabled
|
||||
* Everything else is unconditionally applicable.
|
||||
*/
|
||||
internal fun isApplicable(
|
||||
permission: Permission,
|
||||
monitorMode: MonitorMode,
|
||||
anyPopupEnabled: Boolean,
|
||||
): Boolean = when (permission) {
|
||||
Permission.IGNORE_BATTERY_OPTIMIZATION,
|
||||
Permission.ACCESS_BACKGROUND_LOCATION -> monitorMode == MonitorMode.ALWAYS
|
||||
Permission.SYSTEM_ALERT_WINDOW -> anyPopupEnabled
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
|
||||
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
|
||||
@@ -64,6 +65,8 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -148,6 +151,8 @@ fun DeviceSettingsScreenHost(
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onAncModeChange = { vm.setAncMode(it) },
|
||||
onConversationalAwarenessChange = { vm.setConversationalAwareness(it) },
|
||||
onConversationActionChange = { vm.setConversationAction(it) },
|
||||
onConversationVolumeReductionChange = { vm.setConversationVolumeReduction(it) },
|
||||
onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) },
|
||||
onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) },
|
||||
onToneVolumeChange = { vm.setToneVolume(it) },
|
||||
@@ -167,13 +172,18 @@ fun DeviceSettingsScreenHost(
|
||||
onOnePodModeChange = { vm.setOnePodMode(it) },
|
||||
onAutoPlayChange = { vm.setAutoPlay(it) },
|
||||
onAutoPauseChange = { vm.setAutoPause(it) },
|
||||
onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) },
|
||||
onAutoConnectChange = { vm.setAutoConnect(it) },
|
||||
onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) },
|
||||
onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) },
|
||||
onShowPopUpOnConnectionChange = { vm.setShowPopUpOnConnection(it) },
|
||||
onFixMonitorMode = { vm.setMonitorModeAutomatic() },
|
||||
onNotifyWhenChargedChange = { vm.setNotifyWhenCharged(it) },
|
||||
onChargedThresholdChange = { vm.setChargedThreshold(it) },
|
||||
onChargedSlotScopeChange = { vm.setChargedSlotScope(it) },
|
||||
onOpenIssueTracker = { vm.openIssueTracker() },
|
||||
onOpenAapTracker = { vm.openAapCompatibilityTracker() },
|
||||
onBatteryEstimateEnabledChange = { vm.setBatteryEstimateEnabled(it) },
|
||||
onResetBatteryEstimate = { vm.resetBatteryEstimate() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,6 +196,8 @@ fun DeviceSettingsScreen(
|
||||
onNavigateUp: () -> Unit,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {},
|
||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||
onNcWithOneAirPodChange: (Boolean) -> Unit = {},
|
||||
onPersonalizedVolumeChange: (Boolean) -> Unit = {},
|
||||
onToneVolumeChange: (Int) -> Unit = {},
|
||||
@@ -205,13 +217,18 @@ fun DeviceSettingsScreen(
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
||||
onChargedThresholdChange: (Int) -> Unit = {},
|
||||
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
onOpenAapTracker: () -> Unit = {},
|
||||
onBatteryEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||
onResetBatteryEstimate: () -> Unit = {},
|
||||
) {
|
||||
val device = state.device
|
||||
val features = device?.model?.features
|
||||
@@ -279,6 +296,23 @@ fun DeviceSettingsScreen(
|
||||
info = info,
|
||||
labels = rememberDeviceInfoDetailLabels(),
|
||||
formatDate = { instant -> dateFormatter.format(instant) },
|
||||
batteryHealth = when {
|
||||
state.batteryHealth != null -> BatteryHealthTexts(
|
||||
left = state.batteryHealth.left?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
right = state.batteryHealth.right?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
headset = state.batteryHealth.headset?.let {
|
||||
stringResource(R.string.device_settings_info_battery_health_value, it)
|
||||
},
|
||||
)
|
||||
state.batteryHealthPending -> BatteryHealthTexts(
|
||||
pending = stringResource(R.string.device_settings_info_battery_health_pending),
|
||||
)
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
DeviceInfoCard(
|
||||
deviceInfo = device.deviceInfo,
|
||||
@@ -339,22 +373,42 @@ fun DeviceSettingsScreen(
|
||||
device = device,
|
||||
features = features,
|
||||
isPro = isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
onAutoPlayChange = onAutoPlayChange,
|
||||
onAutoPauseChange = onAutoPauseChange,
|
||||
onStartMusicOnWearChange = onStartMusicOnWearChange,
|
||||
onOnePodModeChange = onOnePodModeChange,
|
||||
onConversationalAwarenessChange = onConversationalAwarenessChange,
|
||||
onConversationActionChange = onConversationActionChange,
|
||||
onConversationVolumeReductionChange = onConversationVolumeReductionChange,
|
||||
onSleepDetectionChange = onSleepDetectionChange,
|
||||
onAutoConnectChange = onAutoConnectChange,
|
||||
onAutoConnectConditionChange = onAutoConnectConditionChange,
|
||||
onShowPopUpOnCaseOpenChange = onShowPopUpOnCaseOpenChange,
|
||||
onShowPopUpOnConnectionChange = onShowPopUpOnConnectionChange,
|
||||
onFixMonitorMode = onFixMonitorMode,
|
||||
onOpenIssueTracker = onOpenIssueTracker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Battery (time-remaining estimate for any live device; charge limit needs AAP) ──
|
||||
if (device != null && features != null && device.isLive) {
|
||||
item("battery_section") {
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = features,
|
||||
isPro = isPro,
|
||||
chargeCapControlEnabled = enabled,
|
||||
estimateEnabled = state.batteryEstimateEnabled,
|
||||
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||
onNotifyWhenChargedChange = onNotifyWhenChargedChange,
|
||||
onChargedThresholdChange = onChargedThresholdChange,
|
||||
onChargedSlotScopeChange = onChargedSlotScopeChange,
|
||||
onEstimateEnabledChange = onBatteryEstimateEnabledChange,
|
||||
onResetEstimate = onResetBatteryEstimate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings — only show when AAP is connected
|
||||
if (features != null && device.isAapConnected && device.hasSelectedPairedDevice) {
|
||||
|
||||
@@ -423,18 +477,6 @@ fun DeviceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────
|
||||
if (features.hasDynamicEndOfCharge && device.dynamicEndOfCharge != null) {
|
||||
item("battery_section") {
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = features,
|
||||
enabled = enabled,
|
||||
onDynamicEndOfChargeChange = onDynamicEndOfChargeChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connections ───────────────────────────────
|
||||
val connectedDevices = device.connectedDevices
|
||||
if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) {
|
||||
|
||||
+104
-24
@@ -5,8 +5,10 @@ import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.WebpageTool
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.NudgeAttemptResult
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -16,10 +18,14 @@ import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.BatteryHealth
|
||||
import eu.darken.capod.monitor.core.battery.DrainProfile
|
||||
import eu.darken.capod.monitor.core.resolvedAncCycleMask
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
@@ -29,6 +35,8 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -51,7 +59,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val batteryEstimator: BatteryEstimator,
|
||||
private val drainStore: BatteryDrainStore,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
private val timeSource: TimeSource,
|
||||
private val webpageTool: WebpageTool,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
@@ -114,8 +125,10 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
// The Bluetooth HEADSET profile lookup can be slow to produce its first value.
|
||||
// Seed this branch so the screen can render immediately after navigation.
|
||||
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
profilesRepo.profiles,
|
||||
nudgeCapabilityStore.availability,
|
||||
drainStore.profiles,
|
||||
) { args ->
|
||||
val device = args[1] as PodDevice?
|
||||
val upgrade = args[2] as UpgradeRepo.Info
|
||||
@@ -127,9 +140,13 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val profiles = args[6] as List<eu.darken.capod.profiles.core.DeviceProfile>
|
||||
val stemActions = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||
val nudgeAvailability = args[7] as NudgeAvailability
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val drainProfiles = args[8] as Map<ProfileId, DrainProfile>
|
||||
val appleProfile = profiles.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.id == profileId }
|
||||
?.stemActions
|
||||
val stemActions = appleProfile?.stemActions
|
||||
val connectedAddresses = connectedDevices.map { it.address }.toSet()
|
||||
val systemBtName = device?.address?.let { addr ->
|
||||
try {
|
||||
@@ -142,7 +159,7 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
device = device,
|
||||
now = timeSource.now(),
|
||||
isPro = upgrade.isPro,
|
||||
isNudgeAvailable = bluetoothManager.isNudgeAvailable,
|
||||
isNudgeAvailable = nudgeAvailability != NudgeAvailability.BROKEN,
|
||||
isForceConnecting = forcing,
|
||||
isClassicallyConnected = device?.address?.let { it in connectedAddresses } == true,
|
||||
monitorMode = monitorMode,
|
||||
@@ -151,6 +168,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
(it.leftLong !is StemAction.None && it.leftLong !is StemAction.CycleAnc) ||
|
||||
(it.rightLong !is StemAction.None && it.rightLong !is StemAction.CycleAnc)
|
||||
} == true,
|
||||
batteryEstimateEnabled = appleProfile?.batteryEstimateEnabled ?: true,
|
||||
// Health rides on the same learned data as the estimate — the per-device toggle
|
||||
// governs both.
|
||||
batteryHealth = device
|
||||
?.takeIf { appleProfile?.batteryEstimateEnabled ?: true }
|
||||
?.let { BatteryHealth.estimate(drainProfiles[profileId], it.model) },
|
||||
// A model with a rating WILL eventually produce a health figure — surface the
|
||||
// feature as "still determining" until the listening sessions accumulate. Without
|
||||
// a paired device the listening gate can never open, so no promise is made.
|
||||
batteryHealthPending = device != null &&
|
||||
(appleProfile?.batteryEstimateEnabled ?: true) &&
|
||||
device.model.batterySpec != null &&
|
||||
device.hasSelectedPairedDevice,
|
||||
)
|
||||
}
|
||||
}.asLiveState()
|
||||
@@ -176,6 +206,12 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
val monitorMode: MonitorMode = MonitorMode.AUTOMATIC,
|
||||
val systemBluetoothName: String? = null,
|
||||
val hasCustomLongPressStemAction: Boolean = false,
|
||||
val batteryEstimateEnabled: Boolean = true,
|
||||
/** Derived per-pod battery health (1..100 each), or null when there isn't enough learned data. */
|
||||
val batteryHealth: BatteryHealth.PerPod? = null,
|
||||
/** True when health CAN be derived for this device (rated model, feature on) — shows the
|
||||
* "still determining" placeholder while [batteryHealth] is null. */
|
||||
val batteryHealthPending: Boolean = false,
|
||||
) {
|
||||
val reactions: ReactionConfig get() = device?.reactions ?: ReactionConfig()
|
||||
}
|
||||
@@ -206,18 +242,19 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
return@launch
|
||||
}
|
||||
val accepted = try {
|
||||
val result = try {
|
||||
bluetoothManager.nudgeConnection(bonded)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "nudgeConnection threw: ${e.message}" }
|
||||
false
|
||||
NudgeAttemptResult.Rejected
|
||||
}
|
||||
log(TAG, INFO) { "nudgeConnection($bonded) accepted=$accepted" }
|
||||
if (!accepted) {
|
||||
log(TAG, INFO) { "nudgeConnection($bonded) result=$result" }
|
||||
nudgeCapabilityStore.record(result)
|
||||
if (result != NudgeAttemptResult.Accepted) {
|
||||
events.tryEmit(Event.OpenBluetoothSettings)
|
||||
}
|
||||
} finally {
|
||||
@@ -382,6 +419,15 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
syncEarDetection(autoPause = enabled)
|
||||
}
|
||||
|
||||
fun setStartMusicOnWear(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setStartMusicOnWear($enabled)" }
|
||||
if (enabled && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
updateProfileNow { it.copy(startMusicOnWear = enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the device-side Automatic Ear Detection setting in sync with the
|
||||
* auto-play / auto-pause reaction toggles. When either reaction is active
|
||||
@@ -405,15 +451,6 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
fun setAutoConnect(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setAutoConnect($enabled)" }
|
||||
updateProfileNow { it.copy(autoConnect = enabled) }
|
||||
if (enabled) {
|
||||
// Auto-connect requires ALWAYS mode to react to BLE/case/ear events when
|
||||
// nothing is connected. We intentionally do NOT revert on disable — the user
|
||||
// may have set ALWAYS manually, or another profile may still need it.
|
||||
if (generalSettings.monitorMode.value() != MonitorMode.ALWAYS) {
|
||||
log(TAG, INFO) { "Forcing monitorMode to ALWAYS because autoConnect was enabled" }
|
||||
generalSettings.monitorMode.value(MonitorMode.ALWAYS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoConnectCondition(condition: AutoConnectCondition) = launch {
|
||||
@@ -431,11 +468,54 @@ class DeviceSettingsViewModel @Inject constructor(
|
||||
proGatedReaction(enabled) { it.copy(showPopUpOnConnection = enabled) }
|
||||
}
|
||||
|
||||
fun setMonitorModeAutomatic() = launch {
|
||||
log(TAG, INFO) { "setMonitorModeAutomatic()" }
|
||||
generalSettings.monitorMode.value(MonitorMode.AUTOMATIC)
|
||||
fun setNotifyWhenCharged(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setNotifyWhenCharged($enabled)" }
|
||||
proGatedReaction(enabled) { it.copy(notifyWhenCharged = enabled) }
|
||||
}
|
||||
|
||||
fun setChargedSlotScope(scope: ChargedSlotScope) = launch {
|
||||
log(TAG, INFO) { "setChargedSlotScope($scope)" }
|
||||
updateProfileNow { it.copy(chargedSlotScope = scope) }
|
||||
}
|
||||
|
||||
fun setChargedThreshold(percent: Int) = launch {
|
||||
log(TAG, INFO) { "setChargedThreshold($percent)" }
|
||||
val snapped = (percent.toFloat() / ReactionConfig.CHARGED_THRESHOLD_STEP)
|
||||
.let { Math.round(it) * ReactionConfig.CHARGED_THRESHOLD_STEP }
|
||||
.coerceIn(ReactionConfig.MIN_CHARGED_THRESHOLD, ReactionConfig.MAX_CHARGED_THRESHOLD)
|
||||
updateProfileNow { it.copy(chargedThreshold = snapped) }
|
||||
}
|
||||
|
||||
fun setConversationAction(action: ConversationAction) = launch {
|
||||
log(TAG, INFO) { "setConversationAction($action)" }
|
||||
// The action picker is only shown while Conversation Awareness is already enabled (the pod
|
||||
// emits no speaking frames otherwise), so no need to auto-enable it here.
|
||||
if (action != ConversationAction.NOTHING && !upgradeRepo.isPro()) {
|
||||
navTo(Nav.Main.Upgrade)
|
||||
return@launch
|
||||
}
|
||||
updateProfileNow { it.copy(conversationAction = action) }
|
||||
}
|
||||
|
||||
fun setConversationVolumeReduction(percent: Int) = launch {
|
||||
log(TAG, INFO) { "setConversationVolumeReduction($percent)" }
|
||||
updateProfileNow { it.copy(conversationVolumeReduction = percent) }
|
||||
}
|
||||
|
||||
// ── Battery estimate (per-profile) ───────────────────────────────────────
|
||||
|
||||
fun setBatteryEstimateEnabled(enabled: Boolean) = launch {
|
||||
log(TAG, INFO) { "setBatteryEstimateEnabled($enabled)" }
|
||||
updateProfileNow { it.copy(batteryEstimateEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun resetBatteryEstimate() = launch {
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
log(TAG, INFO) { "resetBatteryEstimate($profileId)" }
|
||||
batteryEstimator.reset(profileId)
|
||||
}
|
||||
|
||||
|
||||
fun navToPressControls() = launch {
|
||||
log(TAG, INFO) { "navToPressControls()" }
|
||||
val profileId = targetProfileId.value ?: return@launch
|
||||
|
||||
@@ -2,41 +2,176 @@ package eu.darken.capod.main.ui.devicesettings.cards
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.BatteryChargingFull
|
||||
import androidx.compose.material.icons.twotone.NotificationsActive
|
||||
import androidx.compose.material.icons.twotone.RestartAlt
|
||||
import androidx.compose.material.icons.twotone.Schedule
|
||||
import androidx.compose.material.icons.twotone.Workspaces
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.ChargedSlotScopeDialog
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||
|
||||
/**
|
||||
* Apple's "Optimized Charge Limit" toggle (AAP setting 0x3B), shown for models that advertise
|
||||
* [PodModel.Features.hasDynamicEndOfCharge]. The wire format follows the Apple-bool convention
|
||||
* used by every other boolean setting.
|
||||
* The device's "Battery" settings, grouping everything charge/battery-related. Time remaining &
|
||||
* battery health lead, then a divider, then the charging-side settings:
|
||||
* - The time-remaining/health estimate: a per-device toggle (disabling pauses/hides without
|
||||
* discarding learned data) and a reset that wipes the learned drain, charge and health data so
|
||||
* it starts over from the model's rated battery life.
|
||||
* - Apple's "Optimized Charge Limit" (AAP setting 0x3B) — only for models that advertise
|
||||
* [PodModel.Features.hasDynamicEndOfCharge] over an active AAP session.
|
||||
* - "Notify when charged" (a per-device reaction) — fires purely off observed charging state, so it
|
||||
* works for any live device (BLE or AAP), not just when the phone is the audio source.
|
||||
*
|
||||
* Each row is gated independently; the whole card hides when nothing applies.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BatteryCard(
|
||||
device: PodDevice,
|
||||
features: PodModel.Features,
|
||||
enabled: Boolean,
|
||||
isPro: Boolean,
|
||||
chargeCapControlEnabled: Boolean,
|
||||
estimateEnabled: Boolean,
|
||||
onDynamicEndOfChargeChange: (Boolean) -> Unit = {},
|
||||
onNotifyWhenChargedChange: (Boolean) -> Unit = {},
|
||||
onChargedThresholdChange: (Int) -> Unit = {},
|
||||
onChargedSlotScopeChange: (ChargedSlotScope) -> Unit = {},
|
||||
onEstimateEnabledChange: (Boolean) -> Unit = {},
|
||||
onResetEstimate: () -> Unit = {},
|
||||
) {
|
||||
if (!features.hasDynamicEndOfCharge) return
|
||||
val cap = device.dynamicEndOfCharge ?: return
|
||||
val chargeCap = device.dynamicEndOfCharge?.takeIf { features.hasDynamicEndOfCharge }
|
||||
// Per-profile controls (charge notification + estimate) apply to any live, profile-matched device.
|
||||
val showLiveControls = device.isLive && device.profileId != null
|
||||
if (chargeCap == null && !showLiveControls) return
|
||||
|
||||
val reactions = device.reactions
|
||||
var showResetConfirm by remember { mutableStateOf(false) }
|
||||
var showChargedScopeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsSection(title = stringResource(R.string.device_settings_category_battery_label)) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||
checked = cap.enabled,
|
||||
onCheckedChange = onDynamicEndOfChargeChange,
|
||||
enabled = enabled,
|
||||
// Time remaining & battery health lead — the headline feature. Charging-side settings
|
||||
// (Apple's charge limit, the charged notification) follow below the divider.
|
||||
if (showLiveControls) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.Schedule,
|
||||
title = stringResource(R.string.device_battery_estimate_toggle_label),
|
||||
subtitle = stringResource(R.string.device_battery_estimate_card_desc),
|
||||
checked = estimateEnabled,
|
||||
onCheckedChange = onEstimateEnabledChange,
|
||||
)
|
||||
SettingsBaseItem(
|
||||
icon = Icons.TwoTone.RestartAlt,
|
||||
title = stringResource(R.string.device_battery_estimate_reset_action),
|
||||
subtitle = stringResource(R.string.device_battery_estimate_reset_desc),
|
||||
onClick = { showResetConfirm = true },
|
||||
)
|
||||
// Same divider style the sibling device-settings cards use (see ReactionsCard).
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
}
|
||||
if (chargeCap != null) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
title = stringResource(R.string.device_settings_charge_cap_label),
|
||||
subtitle = stringResource(R.string.device_settings_charge_cap_description),
|
||||
checked = chargeCap.enabled,
|
||||
onCheckedChange = onDynamicEndOfChargeChange,
|
||||
enabled = chargeCapControlEnabled,
|
||||
)
|
||||
}
|
||||
if (showLiveControls) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.NotificationsActive,
|
||||
title = stringResource(R.string.settings_charged_notification_label),
|
||||
subtitle = stringResource(R.string.settings_charged_notification_description),
|
||||
checked = reactions.notifyWhenCharged,
|
||||
onCheckedChange = onNotifyWhenChargedChange,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (reactions.notifyWhenCharged) {
|
||||
var thresholdValue by remember(reactions.chargedThreshold) {
|
||||
mutableIntStateOf(reactions.chargedThreshold)
|
||||
}
|
||||
SettingsSliderItem(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
title = stringResource(R.string.settings_charged_threshold_label),
|
||||
value = thresholdValue.toFloat(),
|
||||
onValueChange = { thresholdValue = it.toInt() },
|
||||
onValueChangeFinished = { onChargedThresholdChange(thresholdValue) },
|
||||
valueRange = ReactionConfig.MIN_CHARGED_THRESHOLD.toFloat()..
|
||||
ReactionConfig.MAX_CHARGED_THRESHOLD.toFloat(),
|
||||
steps = (ReactionConfig.MAX_CHARGED_THRESHOLD - ReactionConfig.MIN_CHARGED_THRESHOLD) /
|
||||
ReactionConfig.CHARGED_THRESHOLD_STEP - 1,
|
||||
valueLabel = { "${it.toInt()}%" },
|
||||
)
|
||||
if (features.hasCase) {
|
||||
SettingsBaseItem(
|
||||
icon = Icons.TwoTone.Workspaces,
|
||||
title = stringResource(R.string.settings_charged_scope_label),
|
||||
subtitle = stringResource(reactions.chargedSlotScope.labelRes),
|
||||
onClick = { showChargedScopeDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showChargedScopeDialog) {
|
||||
ChargedSlotScopeDialog(
|
||||
current = reactions.chargedSlotScope,
|
||||
onSelect = {
|
||||
onChargedSlotScopeChange(it)
|
||||
showChargedScopeDialog = false
|
||||
},
|
||||
onDismiss = { showChargedScopeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showResetConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetConfirm = false },
|
||||
title = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_title)) },
|
||||
text = { Text(text = stringResource(R.string.device_battery_estimate_reset_confirm_message)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetConfirm = false
|
||||
onResetEstimate()
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(R.string.device_battery_estimate_reset_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetConfirm = false }) {
|
||||
Text(text = stringResource(R.string.general_cancel_action))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -48,8 +183,10 @@ private fun BatteryCardEnabledPreview() = PreviewWrapper {
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = true,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||
isPro = true,
|
||||
chargeCapControlEnabled = true,
|
||||
estimateEnabled = true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +197,9 @@ private fun BatteryCardDisabledPreview() = PreviewWrapper {
|
||||
val device = state.device!!
|
||||
BatteryCard(
|
||||
device = device,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true),
|
||||
enabled = false,
|
||||
features = PodModel.Features(hasDynamicEndOfCharge = true, hasCase = true),
|
||||
isPro = false,
|
||||
chargeCapControlEnabled = false,
|
||||
estimateEnabled = false,
|
||||
)
|
||||
}
|
||||
|
||||
+43
-1
@@ -18,6 +18,9 @@ internal fun rememberDeviceInfoDetailLabels() = DeviceInfoDetailLabels(
|
||||
rightSerial = stringResource(R.string.device_settings_info_right_serial_label),
|
||||
leftBonded = stringResource(R.string.device_settings_info_left_bonded_label),
|
||||
rightBonded = stringResource(R.string.device_settings_info_right_bonded_label),
|
||||
batteryHealth = stringResource(R.string.device_settings_info_battery_health_label),
|
||||
leftBatteryHealth = stringResource(R.string.device_settings_info_battery_health_left_label),
|
||||
rightBatteryHealth = stringResource(R.string.device_settings_info_battery_health_right_label),
|
||||
)
|
||||
|
||||
internal data class DeviceInfoDetailLabels(
|
||||
@@ -31,14 +34,52 @@ internal data class DeviceInfoDetailLabels(
|
||||
val rightSerial: String,
|
||||
val leftBonded: String,
|
||||
val rightBonded: String,
|
||||
val batteryHealth: String,
|
||||
val leftBatteryHealth: String,
|
||||
val rightBatteryHealth: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-formatted per-pod health values ("~85%") for the info sheet; null slots are omitted.
|
||||
* [pending] is a placeholder shown under the health label while NO value exists yet — the feature
|
||||
* stays discoverable while the estimator is still accumulating listening sessions.
|
||||
*/
|
||||
internal data class BatteryHealthTexts(
|
||||
val left: String? = null,
|
||||
val right: String? = null,
|
||||
val headset: String? = null,
|
||||
val pending: String? = null,
|
||||
)
|
||||
|
||||
private fun healthItems(health: BatteryHealthTexts?, labels: DeviceInfoDetailLabels): List<DeviceDetailItem> {
|
||||
if (health == null) return emptyList()
|
||||
return buildList {
|
||||
when {
|
||||
health.left != null && health.right != null -> add(
|
||||
DeviceDetailItem.Paired(
|
||||
start = DeviceDetailItem.Single(labels.leftBatteryHealth, health.left),
|
||||
end = DeviceDetailItem.Single(labels.rightBatteryHealth, health.right),
|
||||
)
|
||||
)
|
||||
health.left != null -> add(DeviceDetailItem.Single(labels.leftBatteryHealth, health.left))
|
||||
health.right != null -> add(DeviceDetailItem.Single(labels.rightBatteryHealth, health.right))
|
||||
}
|
||||
health.headset?.let { add(DeviceDetailItem.Single(labels.batteryHealth, it)) }
|
||||
if (isEmpty() && health.pending != null) {
|
||||
add(DeviceDetailItem.Single(labels.batteryHealth, health.pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildDeviceInfoDetailItems(
|
||||
info: AapDeviceInfo?,
|
||||
labels: DeviceInfoDetailLabels,
|
||||
batteryHealth: BatteryHealthTexts? = null,
|
||||
formatDate: (Instant) -> String,
|
||||
): List<DeviceDetailItem> {
|
||||
if (info == null) return emptyList()
|
||||
// Battery health is derived locally, so it's available (and shows the info button) even for
|
||||
// BLE-only devices that never produce an AAP device-info response.
|
||||
if (info == null) return healthItems(batteryHealth, labels)
|
||||
return buildList {
|
||||
info.manufacturer.takeIf { it.isNotBlank() }?.let {
|
||||
add(DeviceDetailItem.Single(labels.manufacturer, it))
|
||||
@@ -82,5 +123,6 @@ internal fun buildDeviceInfoDetailItems(
|
||||
leftBonded != null -> add(DeviceDetailItem.Single(labels.leftBonded, leftBonded))
|
||||
rightBonded != null -> add(DeviceDetailItem.Single(labels.rightBonded, rightBonded))
|
||||
}
|
||||
addAll(healthItems(batteryHealth, labels))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.os.Build
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.automirrored.twotone.VolumeDown
|
||||
import androidx.compose.material.icons.twotone.BluetoothConnected
|
||||
import androidx.compose.material.icons.twotone.Hearing
|
||||
import androidx.compose.material.icons.twotone.LooksOne
|
||||
import androidx.compose.material.icons.twotone.Nightlight
|
||||
import androidx.compose.material.icons.twotone.PauseCircle
|
||||
import androidx.compose.material.icons.twotone.PlayCircle
|
||||
import androidx.compose.material.icons.twotone.RecordVoiceOver
|
||||
import androidx.compose.material.icons.twotone.Workspaces
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -29,39 +32,45 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.settings.InfoBoxType
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
import eu.darken.capod.common.settings.SettingsCategoryHeader
|
||||
import eu.darken.capod.common.settings.SettingsInfoBox
|
||||
import eu.darken.capod.common.settings.SettingsSection
|
||||
import eu.darken.capod.common.settings.SettingsSliderItem
|
||||
import eu.darken.capod.common.settings.SettingsSwitchItem
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.AutoConnectConditionDialog
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.ConversationActionDialog
|
||||
import eu.darken.capod.main.ui.devicesettings.previewFullState
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
@Composable
|
||||
internal fun ReactionsCard(
|
||||
device: PodDevice,
|
||||
features: PodModel.Features,
|
||||
isPro: Boolean,
|
||||
monitorMode: MonitorMode,
|
||||
onAutoPlayChange: (Boolean) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onStartMusicOnWearChange: (Boolean) -> Unit = {},
|
||||
onOnePodModeChange: (Boolean) -> Unit = {},
|
||||
onConversationalAwarenessChange: (Boolean) -> Unit = {},
|
||||
onConversationActionChange: (ConversationAction) -> Unit = {},
|
||||
onConversationVolumeReductionChange: (Int) -> Unit = {},
|
||||
onSleepDetectionChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectChange: (Boolean) -> Unit = {},
|
||||
onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {},
|
||||
onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {},
|
||||
onShowPopUpOnConnectionChange: (Boolean) -> Unit = {},
|
||||
onFixMonitorMode: () -> Unit = {},
|
||||
onOpenIssueTracker: () -> Unit = {},
|
||||
) {
|
||||
val reactions = device.reactions
|
||||
val enabled = device.isAapReady
|
||||
|
||||
var showAutoConnectConditionDialog by remember { mutableStateOf(false) }
|
||||
var showConversationActionDialog by remember { mutableStateOf(false) }
|
||||
|
||||
SettingsSection(title = stringResource(R.string.settings_reaction_label)) {
|
||||
if (features.hasEarDetection) {
|
||||
@@ -73,6 +82,16 @@ internal fun ReactionsCard(
|
||||
onCheckedChange = onAutoPlayChange,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (reactions.autoPlay) {
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.PlayCircle,
|
||||
title = stringResource(R.string.settings_start_music_on_wear_label),
|
||||
subtitle = stringResource(R.string.settings_start_music_on_wear_description),
|
||||
checked = reactions.startMusicOnWear,
|
||||
onCheckedChange = onStartMusicOnWearChange,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
}
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.PauseCircle,
|
||||
title = stringResource(R.string.settings_autopause_label),
|
||||
@@ -117,20 +136,49 @@ internal fun ReactionsCard(
|
||||
}
|
||||
if (device.isAapConnected) {
|
||||
val convAwareness = device.conversationalAwareness
|
||||
val hasAnyAapReaction =
|
||||
(features.hasConversationAwareness && convAwareness != null) ||
|
||||
features.hasSleepDetection
|
||||
if (features.hasConversationAwareness && convAwareness != null) {
|
||||
val showConversationAwareness = features.hasConversationAwareness && convAwareness != null
|
||||
val hasAnyAapReaction = showConversationAwareness || features.hasSleepDetection
|
||||
if (showConversationAwareness) {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.conversation_awareness_label))
|
||||
SettingsSwitchItem(
|
||||
icon = Icons.TwoTone.Hearing,
|
||||
title = stringResource(R.string.conversation_awareness_label),
|
||||
subtitle = stringResource(R.string.device_settings_conversation_awareness_description),
|
||||
checked = convAwareness.enabled,
|
||||
checked = convAwareness?.enabled == true,
|
||||
onCheckedChange = onConversationalAwarenessChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
// The "when you start speaking" reaction only fires while CA is on (the pod emits no
|
||||
// speaking frames otherwise), so only surface it once CA is actually enabled.
|
||||
if (convAwareness?.enabled == true) {
|
||||
SettingsBaseItem(
|
||||
icon = Icons.TwoTone.RecordVoiceOver,
|
||||
title = stringResource(R.string.settings_conversation_action_label),
|
||||
subtitle = stringResource(reactions.conversationAction.labelRes),
|
||||
onClick = { showConversationActionDialog = true },
|
||||
enabled = enabled,
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
if (reactions.conversationAction == ConversationAction.LOWER_VOLUME) {
|
||||
var sliderValue by remember(reactions.conversationVolumeReduction) {
|
||||
mutableIntStateOf(reactions.conversationVolumeReduction)
|
||||
}
|
||||
SettingsSliderItem(
|
||||
icon = Icons.AutoMirrored.TwoTone.VolumeDown,
|
||||
title = stringResource(R.string.settings_conversation_volume_reduction_label),
|
||||
value = sliderValue.toFloat(),
|
||||
onValueChange = { sliderValue = it.toInt() },
|
||||
onValueChangeFinished = { onConversationVolumeReductionChange(sliderValue) },
|
||||
valueRange = ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION.toFloat()..
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION.toFloat(),
|
||||
enabled = enabled,
|
||||
valueLabel = { "${it.toInt()}%" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (features.hasSleepDetection) {
|
||||
if (showConversationAwareness) ReactionsDivider()
|
||||
val sleepDet = device.sleepDetection
|
||||
?: AapSetting.SleepDetection(enabled = true)
|
||||
SettingsSwitchItem(
|
||||
@@ -198,23 +246,24 @@ internal fun ReactionsCard(
|
||||
requiresUpgrade = !isPro,
|
||||
)
|
||||
val anyPopupEnabled = reactions.showPopUpOnCaseOpen || reactions.showPopUpOnConnection
|
||||
if (anyPopupEnabled && monitorMode == MonitorMode.MANUAL) {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.settings_popup_warning_manual_mode),
|
||||
type = InfoBoxType.WARNING,
|
||||
action = {
|
||||
TextButton(onClick = onFixMonitorMode) {
|
||||
Text(stringResource(R.string.general_fix_it_action))
|
||||
}
|
||||
},
|
||||
)
|
||||
} else if (anyPopupEnabled) {
|
||||
if (anyPopupEnabled) {
|
||||
SettingsInfoBox(
|
||||
text = stringResource(R.string.settings_popup_info_not_in_app),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showConversationActionDialog) {
|
||||
ConversationActionDialog(
|
||||
current = reactions.conversationAction,
|
||||
onSelect = {
|
||||
onConversationActionChange(it)
|
||||
showConversationActionDialog = false
|
||||
},
|
||||
onDismiss = { showConversationActionDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAutoConnectConditionDialog) {
|
||||
AutoConnectConditionDialog(
|
||||
current = reactions.autoConnectCondition,
|
||||
@@ -246,7 +295,6 @@ private fun ReactionsCardPreview() = PreviewWrapper {
|
||||
device = device,
|
||||
features = device.model.features,
|
||||
isPro = state.isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,6 +307,5 @@ private fun ReactionsCardNonProPreview() = PreviewWrapper {
|
||||
device = device,
|
||||
features = device.model.features,
|
||||
isPro = state.isPro,
|
||||
monitorMode = state.monitorMode,
|
||||
)
|
||||
}
|
||||
|
||||
+75
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package eu.darken.capod.main.ui.devicesettings.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
@Composable
|
||||
internal fun ConversationActionDialog(
|
||||
current: ConversationAction,
|
||||
onSelect: (ConversationAction) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = stringResource(R.string.settings_conversation_action_label)) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
ConversationAction.entries.forEach { action ->
|
||||
val isSelected = action == current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { onSelect(action) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = isSelected, onClick = null)
|
||||
Text(
|
||||
text = stringResource(action.labelRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun ConversationActionDialogPreview() = PreviewWrapper {
|
||||
ConversationActionDialog(
|
||||
current = ConversationAction.LOWER_VOLUME,
|
||||
onSelect = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
@@ -61,9 +61,11 @@ import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
|
||||
import eu.darken.capod.main.ui.overview.cards.PermissionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
|
||||
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
|
||||
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import java.time.Instant
|
||||
@@ -172,6 +174,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
},
|
||||
onManageDevices = { vm.goToDeviceManager() },
|
||||
onSettings = { vm.goToSettings() },
|
||||
onTroubleShooter = { vm.goToTroubleShooter() },
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
@@ -194,6 +197,7 @@ fun OverviewScreen(
|
||||
onBluetoothSettings: () -> Unit,
|
||||
onManageDevices: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onTroubleShooter: () -> Unit = {},
|
||||
onUpgrade: () -> Unit,
|
||||
onToggleUnmatched: () -> Unit,
|
||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||
@@ -327,9 +331,10 @@ fun OverviewScreen(
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
showDebug = state.isDebugMode,
|
||||
showDebug = state.isDebug,
|
||||
now = state.now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = state.estimateFor(device),
|
||||
onToggleCollapse = if (isToggleable) {
|
||||
{ onToggleDeviceExpansion(device) }
|
||||
} else null,
|
||||
@@ -351,18 +356,25 @@ fun OverviewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
|
||||
if (state.showTroubleshootSuggestion) {
|
||||
item(key = "troubleshoot_suggestion") {
|
||||
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Monitoring active card
|
||||
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
|
||||
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
|
||||
item(key = "monitoring_active") {
|
||||
MonitoringActiveCard()
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Unmatched devices section
|
||||
if (state.unmatchedDevices.isNotEmpty()) {
|
||||
if (state.shouldShowUnmatchedSection) {
|
||||
item(key = "unmatched_header") {
|
||||
UnmatchedDevicesCard(
|
||||
count = state.unmatchedDevices.size,
|
||||
count = state.visibleUnmatchedDevices.size,
|
||||
isExpanded = state.showUnmatchedDevices,
|
||||
onToggle = onToggleUnmatched,
|
||||
)
|
||||
@@ -370,13 +382,13 @@ fun OverviewScreen(
|
||||
|
||||
if (state.showUnmatchedDevices) {
|
||||
itemsIndexed(
|
||||
items = state.unmatchedDevices,
|
||||
items = state.visibleUnmatchedDevices,
|
||||
key = { index, device -> unmatchedDeviceKey(device, index) },
|
||||
) { _, device ->
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
isPro = state.upgradeInfo.isPro,
|
||||
showDebug = state.isDebugMode,
|
||||
showDebug = state.isDebug,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -397,6 +409,7 @@ private fun PodDeviceCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
@@ -407,6 +420,7 @@ private fun PodDeviceCard(
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -416,6 +430,7 @@ private fun PodDeviceCard(
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(
|
||||
device = device, isPro = isPro, showDebug = showDebug, now = now,
|
||||
isCollapsed = isCollapsed,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onToggleCollapse = onToggleCollapse,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onUpgrade = onUpgrade,
|
||||
@@ -434,11 +449,11 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = listOf(
|
||||
MockPodDataProvider.dualPodMonitoredMixed(),
|
||||
MockPodDataProvider.dualPodFullyLoaded(),
|
||||
MockPodDataProvider.singlePodMonitored(),
|
||||
MockPodDataProvider.unknownMonitored(),
|
||||
),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(
|
||||
MockPodDataProvider.profile("My AirPods Pro", PodModel.AIRPODS_PRO2),
|
||||
@@ -447,6 +462,16 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper {
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
showUnmatchedDevices = false,
|
||||
showReactionsHint = true,
|
||||
userExpandedIds = setOf("preview-single"),
|
||||
batteryEstimates = mapOf(
|
||||
"preview-dual-full" to BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||
),
|
||||
"preview-single" to BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||
),
|
||||
),
|
||||
),
|
||||
onRequestPermission = {},
|
||||
onBluetoothSettings = {},
|
||||
@@ -465,7 +490,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(),
|
||||
@@ -488,7 +513,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = true,
|
||||
profiles = emptyList(),
|
||||
upgradeInfo = MockPodDataProvider.gplayInfo(),
|
||||
@@ -511,7 +536,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper {
|
||||
now = SystemTimeSource.now(),
|
||||
permissions = emptySet(),
|
||||
devices = emptyList(),
|
||||
isDebugMode = false,
|
||||
isDebug = false,
|
||||
isBluetoothEnabled = false,
|
||||
profiles = listOf(MockPodDataProvider.profile("My AirPods", PodModel.AIRPODS_PRO2)),
|
||||
upgradeInfo = MockPodDataProvider.fossInfo(isPro = true),
|
||||
|
||||
@@ -5,8 +5,7 @@ import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.value
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -22,7 +21,11 @@ import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
import eu.darken.capod.monitor.core.tierRank
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
@@ -34,12 +37,16 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.combine as combineFlows
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -50,11 +57,12 @@ class OverviewViewModel @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val generalSettings: GeneralSettings,
|
||||
debugSettings: DebugSettings,
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val monitorModeResolver: MonitorModeResolver,
|
||||
private val batteryEstimator: BatteryEstimator,
|
||||
private val timeSource: TimeSource,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
@@ -77,29 +85,90 @@ class OverviewViewModel @Inject constructor(
|
||||
private val showUnmatchedDevices = MutableStateFlow(false)
|
||||
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
val workerAutolaunch = permissionTool.missingScanPermissions
|
||||
.onEach { permissions ->
|
||||
if (permissions.isNotEmpty()) {
|
||||
log(TAG) { "Missing scan permissions: $permissions" }
|
||||
private data class OverviewUiSettings(
|
||||
val reactionsHintDismissed: Boolean,
|
||||
val hideUnmatchedDevices: Boolean,
|
||||
val showTroubleshootSuggestion: Boolean,
|
||||
val batteryEstimates: Map<String, BatteryEstimate>,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
|
||||
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
|
||||
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
|
||||
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
|
||||
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
|
||||
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
|
||||
*/
|
||||
private val troubleshootSuggestion = combineFlows(
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
|
||||
deviceMonitor.devices,
|
||||
permissionTool.missingScanPermissions,
|
||||
) { profiles, connectedDevices, devices, missingScanPermissions ->
|
||||
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
|
||||
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
|
||||
val anyLivePod = devices.any { it.isLive }
|
||||
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { conditionMet ->
|
||||
if (conditionMet) flow {
|
||||
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
|
||||
emit(true)
|
||||
} else flowOf(false)
|
||||
}
|
||||
.onStart { emit(false) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
private val overviewUiSettings = combineFlows(
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
generalSettings.hideUnmatchedDevices.flow,
|
||||
troubleshootSuggestion,
|
||||
batteryEstimator.estimates,
|
||||
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion, batteryEstimates ->
|
||||
OverviewUiSettings(
|
||||
reactionsHintDismissed = reactionsHintDismissed,
|
||||
hideUnmatchedDevices = hideUnmatched,
|
||||
showTroubleshootSuggestion = showTroubleshootSuggestion,
|
||||
batteryEstimates = batteryEstimates,
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
|
||||
// toggle so the section reappears collapsed if the user later disables the setting.
|
||||
launch {
|
||||
generalSettings.hideUnmatchedDevices.flow
|
||||
.filter { it }
|
||||
.collect { showUnmatchedDevices.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
val workerAutolaunch = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
monitorModeResolver.effectiveMode,
|
||||
// BluetoothManager2.connectedDevices is gated by a slow HEADSET-profile lookup. Without
|
||||
// an onStart seed the combine wouldn't fire until that profile is bound, so ALWAYS mode
|
||||
// would silently delay autolaunch until the first connected-device emission.
|
||||
bluetoothManager.connectedDevices
|
||||
.onStart { emit(emptyList()) }
|
||||
.map { it.isNotEmpty() }
|
||||
.distinctUntilChanged(),
|
||||
) { missing, mode, anyConnected -> Triple(missing, mode, anyConnected) }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (missing, mode, anyConnected) ->
|
||||
if (missing.isNotEmpty()) {
|
||||
log(TAG) { "Missing scan permissions: $missing" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val shouldStart = when (generalSettings.monitorMode.valueBlocking) {
|
||||
val shouldStart = when (mode) {
|
||||
MonitorMode.MANUAL -> false
|
||||
MonitorMode.AUTOMATIC -> {
|
||||
try {
|
||||
val devices = withTimeoutOrNull(5_000) { bluetoothManager.connectedDevices.first() }
|
||||
devices?.isNotEmpty() == true
|
||||
} catch (e: SecurityException) {
|
||||
log(TAG) { "Can't check connected devices without BLUETOOTH_CONNECT: ${e.message}" }
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
MonitorMode.AUTOMATIC -> anyConnected
|
||||
MonitorMode.ALWAYS -> true
|
||||
}
|
||||
if (shouldStart) {
|
||||
log(TAG) { "Starting monitor" }
|
||||
log(TAG) { "Starting monitor (mode=$mode)" }
|
||||
monitorControl.startMonitor()
|
||||
}
|
||||
}
|
||||
@@ -126,15 +195,15 @@ class OverviewViewModel @Inject constructor(
|
||||
updateTicker,
|
||||
permissionTool.missingPermissions,
|
||||
pods,
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
Bugs.isDebug,
|
||||
bluetoothManager.isBluetoothEnabled,
|
||||
profilesRepo.profiles,
|
||||
upgradeRepo.upgradeInfo,
|
||||
showUnmatchedDevices,
|
||||
userExpansionOverrides,
|
||||
generalSettings.reactionsHintDismissed.flow,
|
||||
overviewUiSettings,
|
||||
profilesRepo.hadLegacyReactionData,
|
||||
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
|
||||
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
|
||||
// Prune stale overrides (profiles that no longer exist)
|
||||
val currentProfileIds = profiles.map { it.id }.toSet()
|
||||
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
|
||||
@@ -143,13 +212,16 @@ class OverviewViewModel @Inject constructor(
|
||||
now = timeSource.now(),
|
||||
permissions = permissions,
|
||||
devices = devices,
|
||||
isDebugMode = isDebugMode,
|
||||
isDebug = isDebug,
|
||||
isBluetoothEnabled = isBluetoothEnabled,
|
||||
profiles = profiles,
|
||||
upgradeInfo = upgradeInfo,
|
||||
showUnmatchedDevices = showUnmatched,
|
||||
userExpandedIds = prunedExpandedIds,
|
||||
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
|
||||
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
|
||||
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
|
||||
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
|
||||
batteryEstimates = uiSettings.batteryEstimates,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
@@ -159,16 +231,25 @@ class OverviewViewModel @Inject constructor(
|
||||
val now: Instant,
|
||||
val permissions: Set<Permission>,
|
||||
val devices: List<PodDevice>,
|
||||
val isDebugMode: Boolean,
|
||||
val isDebug: Boolean,
|
||||
val isBluetoothEnabled: Boolean,
|
||||
val profiles: List<DeviceProfile>,
|
||||
val upgradeInfo: UpgradeRepo.Info,
|
||||
val showUnmatchedDevices: Boolean,
|
||||
val userExpandedIds: Set<String> = emptySet(),
|
||||
val showReactionsHint: Boolean = false,
|
||||
val hideUnmatchedDevices: Boolean = false,
|
||||
val showTroubleshootSuggestion: Boolean = false,
|
||||
val batteryEstimates: Map<String, BatteryEstimate> = emptyMap(),
|
||||
) {
|
||||
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
|
||||
|
||||
/**
|
||||
* Time-remaining estimate to show for [device], or null when the device has the estimate
|
||||
* disabled, isn't live (no estimate for cached/offline cards), or no rate is available yet.
|
||||
*/
|
||||
fun estimateFor(device: PodDevice): BatteryEstimate? = batteryEstimates.estimateFor(device)
|
||||
|
||||
/** Profile list order used as tiebreaker within each connection tier. */
|
||||
private val profileOrder: Map<String, Int> by lazy {
|
||||
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||
@@ -186,6 +267,11 @@ class OverviewViewModel @Inject constructor(
|
||||
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
|
||||
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
|
||||
|
||||
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
|
||||
val visibleUnmatchedDevices: List<PodDevice>
|
||||
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
|
||||
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
|
||||
|
||||
val bluetoothIconState: BluetoothIconState
|
||||
get() = when {
|
||||
isScanBlocked -> BluetoothIconState.HIDDEN
|
||||
@@ -224,6 +310,11 @@ class OverviewViewModel @Inject constructor(
|
||||
navTo(Nav.Settings.Index)
|
||||
}
|
||||
|
||||
fun goToTroubleShooter() {
|
||||
log(TAG, INFO) { "goToTroubleShooter()" }
|
||||
navTo(Nav.Main.TroubleShooter)
|
||||
}
|
||||
|
||||
fun goToDeviceManager() {
|
||||
log(TAG, INFO) { "goToDeviceManager()" }
|
||||
navTo(Nav.Main.DeviceManager)
|
||||
@@ -284,6 +375,14 @@ class OverviewViewModel @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val FREE_DEVICE_LIMIT = 1
|
||||
|
||||
/**
|
||||
* How long the "connected but no live data" condition must hold before the troubleshooter
|
||||
* hint appears, so it doesn't flash during the gap between an audio connection and the first
|
||||
* BLE broadcast.
|
||||
*/
|
||||
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
|
||||
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,12 +60,14 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import java.time.Instant
|
||||
|
||||
@@ -76,6 +78,7 @@ fun DualPodsCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onUpgrade: (() -> Unit)? = null,
|
||||
@@ -192,6 +195,7 @@ fun DualPodsCard(
|
||||
device = device,
|
||||
showDebug = showDebug,
|
||||
now = now,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onAncModeChange = onAncModeChange,
|
||||
)
|
||||
}
|
||||
@@ -204,8 +208,10 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
batteryEstimate: BatteryEstimate?,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Circular battery gauges side by side
|
||||
@@ -233,6 +239,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isLeftPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.left?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
untilCharged = batteryEstimate?.left?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||
)
|
||||
|
||||
PodGauge(
|
||||
@@ -245,6 +253,8 @@ private fun ColumnScope.DualPodsCardExpanded(
|
||||
isMicrophone = device.isRightPodMicrophone ?: false,
|
||||
showMicrophone = device.hasDualMicrophone,
|
||||
modifier = Modifier.weight(1f),
|
||||
timeRemaining = batteryEstimate?.right?.let { formatBatteryDurationShort(context, it.minutesRemaining) },
|
||||
untilCharged = batteryEstimate?.right?.minutesUntilCharged?.let { formatBatteryDurationShort(context, it) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +309,8 @@ private fun PodGauge(
|
||||
isMicrophone: Boolean,
|
||||
showMicrophone: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
timeRemaining: String? = null,
|
||||
untilCharged: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clamped = if (batteryPercent >= 0f) batteryPercent.coerceIn(0f, 1f) else -1f
|
||||
@@ -356,16 +368,27 @@ private fun PodGauge(
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Battery percentage
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
// Battery percentage, with the time-remaining estimate inline next to it
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = formatBatteryPercent(context, batteryPercent),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (batteryPercent >= 0f) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
if (timeRemaining != null) {
|
||||
Text(
|
||||
text = "· $timeRemaining",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -380,6 +403,7 @@ private fun PodGauge(
|
||||
chargingOptimizedLabel = stringResource(R.string.pods_charging_optimized_label),
|
||||
inEarLabel = stringResource(R.string.pods_inear_label),
|
||||
microphoneLabel = stringResource(R.string.pods_microphone_label),
|
||||
chargingDetail = untilCharged,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -454,10 +478,49 @@ private fun CaseRow(
|
||||
@Composable
|
||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodMonitoredWithAap(),
|
||||
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
isPro = false,
|
||||
batteryEstimate = BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 135, fractionPerHour = 0.18f, source = BatteryEstimate.Source.LIVE),
|
||||
right = BatteryEstimate.Pod(minutesRemaining = 122, fractionPerHour = 0.20f, source = BatteryEstimate.Source.LIVE),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardEstimateLearnedPreview() = PreviewWrapper {
|
||||
// LEARNED: rate seeded from persisted history on reconnect, before this session has gathered
|
||||
// enough live samples.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
isPro = false,
|
||||
batteryEstimate = BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 92, fractionPerHour = 0.27f, source = BatteryEstimate.Source.LEARNED),
|
||||
right = BatteryEstimate.Pod(minutesRemaining = 100, fractionPerHour = 0.25f, source = BatteryEstimate.Source.LEARNED),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
|
||||
// SPEC: rated estimate shown immediately on connect, before any drain has been measured.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodFullyLoaded(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
isPro = false,
|
||||
batteryEstimate = BatteryEstimate(
|
||||
left = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||
right = BatteryEstimate.Pod(minutesRemaining = 360, fractionPerHour = 0.167f, source = BatteryEstimate.Source.SPEC),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.batteryProgress
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import java.time.Instant
|
||||
@@ -73,6 +75,7 @@ fun SinglePodsCard(
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
isCollapsed: Boolean = false,
|
||||
batteryEstimate: BatteryEstimate? = null,
|
||||
onToggleCollapse: (() -> Unit)? = null,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onUpgrade: (() -> Unit)? = null,
|
||||
@@ -178,6 +181,7 @@ fun SinglePodsCard(
|
||||
device = device,
|
||||
showDebug = showDebug,
|
||||
now = now,
|
||||
batteryEstimate = batteryEstimate,
|
||||
onAncModeChange = onAncModeChange,
|
||||
)
|
||||
}
|
||||
@@ -190,6 +194,7 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
batteryEstimate: BatteryEstimate?,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -251,16 +256,28 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
)
|
||||
}
|
||||
|
||||
// Battery text inside ring
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
// Battery percentage + time remaining, stacked inside the ring
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = formatBatteryPercent(context, percent),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (isKnown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
val headsetEstimate = batteryEstimate?.headset
|
||||
if (headsetEstimate != null) {
|
||||
Text(
|
||||
text = formatBatteryDurationShort(context, headsetEstimate.minutesRemaining),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -277,10 +294,18 @@ private fun ColumnScope.SinglePodsCardExpanded(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> {
|
||||
// Time-until-charged lives in the charging chip; the in-ring estimate
|
||||
// always means runtime, so the two can't be confused.
|
||||
val untilCharged = batteryEstimate?.headset?.minutesUntilCharged
|
||||
?.let { formatBatteryDurationShort(context, it) }
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = untilCharged
|
||||
?.let { "${stringResource(R.string.pods_charging_label)} · $it" }
|
||||
?: stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
if (device.isBeingWorn == true) {
|
||||
@@ -333,6 +358,21 @@ private fun SinglePodsCardFullPreview() = PreviewWrapper {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
|
||||
// Worn (not charging) so the "in ear" chip shows alongside the estimate.
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodMonitored(),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
batteryEstimate = BatteryEstimate(
|
||||
headset = BatteryEstimate.Pod(minutesRemaining = 320, fractionPerHour = 0.09f, source = BatteryEstimate.Source.LEARNED),
|
||||
),
|
||||
onDeviceSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||
|
||||
+71
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-101
@@ -1,44 +1,32 @@
|
||||
package eu.darken.capod.main.ui.settings.general
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.AccountTree
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.Contrast
|
||||
import androidx.compose.material.icons.twotone.DarkMode
|
||||
import androidx.compose.material.icons.twotone.DisabledVisible
|
||||
import androidx.compose.material.icons.twotone.FilterList
|
||||
import androidx.compose.material.icons.automirrored.twotone.Message
|
||||
import androidx.compose.material.icons.twotone.Notifications
|
||||
import androidx.compose.material.icons.twotone.Palette
|
||||
import androidx.compose.material.icons.twotone.VisibilityOff
|
||||
import androidx.compose.material.icons.automirrored.twotone.ViewList
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
@@ -55,7 +43,6 @@ import eu.darken.capod.common.theming.ThemeColor
|
||||
import eu.darken.capod.common.theming.ThemeMode
|
||||
import eu.darken.capod.common.theming.ThemeState
|
||||
import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
|
||||
@Composable
|
||||
fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
@@ -67,13 +54,12 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
GeneralSettingsScreen(
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onMonitorModeSelected = { mode -> vm.setMonitorMode(mode) },
|
||||
onShowConnectedNotificationChanged = { enabled -> vm.setShowConnectedNotification(enabled) },
|
||||
onKeepNotificationAfterDisconnectChanged = { enabled -> vm.setKeepNotificationAfterDisconnect(enabled) },
|
||||
onDebugSettings = { vm.goToDebugSettings() },
|
||||
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
|
||||
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
|
||||
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
|
||||
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
|
||||
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
|
||||
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
|
||||
onThemeColorSelected = { color -> vm.setThemeColor(color) },
|
||||
@@ -86,20 +72,18 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
|
||||
fun GeneralSettingsScreen(
|
||||
state: GeneralSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onMonitorModeSelected: (MonitorMode) -> Unit,
|
||||
onShowConnectedNotificationChanged: (Boolean) -> Unit,
|
||||
onKeepNotificationAfterDisconnectChanged: (Boolean) -> Unit,
|
||||
onDebugSettings: () -> Unit,
|
||||
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
|
||||
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
|
||||
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
|
||||
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
|
||||
onThemeModeSelected: (ThemeMode) -> Unit = {},
|
||||
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
|
||||
onThemeColorSelected: (ThemeColor) -> Unit = {},
|
||||
onUpgrade: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showMonitorModeDialog by remember { mutableStateOf(false) }
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isMaterialYouActive = state.themeState.style == ThemeStyle.MATERIAL_YOU &&
|
||||
@@ -122,17 +106,6 @@ fun GeneralSettingsScreen(
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_monitoring_label))
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_monitor_mode_label),
|
||||
subtitle = stringResource(state.monitorMode.labelRes),
|
||||
icon = Icons.TwoTone.DisabledVisible,
|
||||
onClick = { showMonitorModeDialog = true },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsCategoryHeader(text = stringResource(R.string.settings_category_appearance_label))
|
||||
}
|
||||
@@ -229,10 +202,17 @@ fun GeneralSettingsScreen(
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_debug_label),
|
||||
subtitle = stringResource(R.string.settings_debug_description),
|
||||
icon = Icons.TwoTone.BugReport,
|
||||
onClick = onDebugSettings,
|
||||
title = stringResource(R.string.settings_overview_hide_unmatched_label),
|
||||
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
|
||||
icon = Icons.TwoTone.VisibilityOff,
|
||||
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.hideUnmatchedDevices,
|
||||
onCheckedChange = onHideUnmatchedDevicesChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
@@ -286,20 +266,6 @@ fun GeneralSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonitorModeDialog) {
|
||||
ListPreferenceDialog(
|
||||
title = stringResource(R.string.settings_monitor_mode_label),
|
||||
entries = MonitorMode.entries,
|
||||
selectedEntry = state.monitorMode,
|
||||
onEntrySelected = {
|
||||
onMonitorModeSelected(it)
|
||||
showMonitorModeDialog = false
|
||||
},
|
||||
entryLabel = { stringResource(it.labelRes) },
|
||||
onDismiss = { showMonitorModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showColorDialog) {
|
||||
ThemeColorSelectorDialog(
|
||||
selectedColor = state.themeState.color,
|
||||
@@ -314,12 +280,12 @@ fun GeneralSettingsScreen(
|
||||
|
||||
private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State(
|
||||
isPro = isPro,
|
||||
monitorMode = MonitorMode.AUTOMATIC,
|
||||
showConnectedNotification = true,
|
||||
keepNotificationAfterDisconnect = false,
|
||||
isOffloadedFilteringDisabled = false,
|
||||
isOffloadedBatchingDisabled = false,
|
||||
useIndirectScanResultCallback = false,
|
||||
hideUnmatchedDevices = false,
|
||||
themeState = ThemeState(),
|
||||
)
|
||||
|
||||
@@ -329,13 +295,12 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
|
||||
GeneralSettingsScreen(
|
||||
state = previewGeneralState(isPro = true),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
onOffloadedFilteringDisabledChanged = {},
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
onHideUnmatchedDevicesChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -345,60 +310,11 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
|
||||
GeneralSettingsScreen(
|
||||
state = previewGeneralState(isPro = false),
|
||||
onNavigateUp = {},
|
||||
onMonitorModeSelected = {},
|
||||
onShowConnectedNotificationChanged = {},
|
||||
onKeepNotificationAfterDisconnectChanged = {},
|
||||
onDebugSettings = {},
|
||||
onOffloadedFilteringDisabledChanged = {},
|
||||
onOffloadedBatchingDisabledChanged = {},
|
||||
onUseIndirectScanResultCallbackChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ListPreferenceDialog(
|
||||
title: String,
|
||||
entries: List<T>,
|
||||
selectedEntry: T,
|
||||
onEntrySelected: (T) -> Unit,
|
||||
entryLabel: @Composable (T) -> String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column(Modifier.selectableGroup()) {
|
||||
entries.forEach { entry ->
|
||||
val isSelected = entry == selectedEntry
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { onEntrySelected(entry) },
|
||||
role = Role.RadioButton,
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
Text(
|
||||
text = entryLabel(entry),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
onHideUnmatchedDevicesChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
+13
-19
@@ -13,7 +13,6 @@ import eu.darken.capod.common.theming.ThemeStyle
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.themeState
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -30,12 +29,12 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
|
||||
data class State(
|
||||
val isPro: Boolean,
|
||||
val monitorMode: MonitorMode,
|
||||
val showConnectedNotification: Boolean,
|
||||
val keepNotificationAfterDisconnect: Boolean,
|
||||
val isOffloadedFilteringDisabled: Boolean,
|
||||
val isOffloadedBatchingDisabled: Boolean,
|
||||
val useIndirectScanResultCallback: Boolean,
|
||||
val hideUnmatchedDevices: Boolean,
|
||||
val themeState: ThemeState,
|
||||
)
|
||||
|
||||
@@ -43,12 +42,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
|
||||
val state = combine(
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { monitorMode, showNotif, keepNotif ->
|
||||
) { showNotif, keepNotif ->
|
||||
@Suppress("USELESS_CAST")
|
||||
arrayOf<Any>(monitorMode as Any, showNotif as Any, keepNotif as Any)
|
||||
arrayOf<Any>(showNotif as Any, keepNotif as Any)
|
||||
},
|
||||
combine(
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
@@ -60,24 +58,20 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
},
|
||||
generalSettings.themeState,
|
||||
isPro,
|
||||
) { general, compat, themeState, isPro ->
|
||||
generalSettings.hideUnmatchedDevices.flow,
|
||||
) { general, compat, themeState, isPro, hideUnmatched ->
|
||||
State(
|
||||
isPro = isPro,
|
||||
monitorMode = general[0] as MonitorMode,
|
||||
showConnectedNotification = general[1] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[2] as Boolean,
|
||||
showConnectedNotification = general[0] as Boolean,
|
||||
keepNotificationAfterDisconnect = general[1] as Boolean,
|
||||
isOffloadedFilteringDisabled = compat[0] as Boolean,
|
||||
isOffloadedBatchingDisabled = compat[1] as Boolean,
|
||||
useIndirectScanResultCallback = compat[2] as Boolean,
|
||||
hideUnmatchedDevices = hideUnmatched,
|
||||
themeState = themeState,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
fun setMonitorMode(mode: MonitorMode) {
|
||||
log(TAG, INFO) { "setMonitorMode($mode)" }
|
||||
generalSettings.monitorMode.valueBlocking = mode
|
||||
}
|
||||
|
||||
fun setShowConnectedNotification(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowConnectedNotification($enabled)" }
|
||||
generalSettings.useExtraMonitorNotification.valueBlocking = enabled
|
||||
@@ -103,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setHideUnmatchedDevices(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
|
||||
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) = launch {
|
||||
log(TAG, INFO) { "setThemeMode($mode)" }
|
||||
if (isPro.first()) {
|
||||
@@ -135,11 +134,6 @@ class GeneralSettingsViewModel @Inject constructor(
|
||||
navTo(Nav.Main.Upgrade)
|
||||
}
|
||||
|
||||
fun goToDebugSettings() {
|
||||
log(TAG, INFO) { "goToDebugSettings()" }
|
||||
navTo(Nav.Settings.Debug)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Settings", "General", "VM")
|
||||
}
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import eu.darken.capod.common.navigation.Nav
|
||||
import eu.darken.capod.common.navigation.NavigationEntry
|
||||
import javax.inject.Inject
|
||||
|
||||
class DebugSettingsNavigation @Inject constructor() : NavigationEntry {
|
||||
override fun EntryProviderScope<NavKey>.setup() {
|
||||
entry<Nav.Settings.Debug> { DebugSettingsScreenHost() }
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class Mod {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bind(entry: DebugSettingsNavigation): NavigationEntry
|
||||
}
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
|
||||
import androidx.compose.material.icons.twotone.BugReport
|
||||
import androidx.compose.material.icons.twotone.DataArray
|
||||
import androidx.compose.material.icons.twotone.DevicesOther
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import eu.darken.capod.common.error.ErrorEventHandler
|
||||
import eu.darken.capod.common.navigation.NavigationEventHandler
|
||||
import eu.darken.capod.common.settings.SettingsBaseItem
|
||||
|
||||
@Composable
|
||||
fun DebugSettingsScreenHost(vm: DebugSettingsViewModel = hiltViewModel()) {
|
||||
ErrorEventHandler(vm)
|
||||
NavigationEventHandler(vm)
|
||||
|
||||
val state by vm.state.collectAsStateWithLifecycle(initialValue = null)
|
||||
state?.let {
|
||||
DebugSettingsScreen(
|
||||
state = it,
|
||||
onNavigateUp = { vm.navUp() },
|
||||
onDebugModeChanged = { enabled -> vm.setDebugModeEnabled(enabled) },
|
||||
onShowFakeDataChanged = { enabled -> vm.setShowFakeData(enabled) },
|
||||
onShowUnfilteredChanged = { enabled -> vm.setShowUnfiltered(enabled) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DebugSettingsScreen(
|
||||
state: DebugSettingsViewModel.State,
|
||||
onNavigateUp: () -> Unit,
|
||||
onDebugModeChanged: (Boolean) -> Unit,
|
||||
onShowFakeDataChanged: (Boolean) -> Unit,
|
||||
onShowUnfilteredChanged: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.settings_debug_label)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateUp) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.TwoTone.ArrowBack,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_debug_mode_label),
|
||||
subtitle = stringResource(R.string.settings_debug_mode_description),
|
||||
icon = Icons.TwoTone.BugReport,
|
||||
onClick = { onDebugModeChanged(!state.isDebugModeEnabled) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.isDebugModeEnabled,
|
||||
onCheckedChange = onDebugModeChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_fake_data_label),
|
||||
subtitle = stringResource(R.string.settings_fake_data_description),
|
||||
icon = Icons.TwoTone.DataArray,
|
||||
onClick = { onShowFakeDataChanged(!state.showFakeData) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.showFakeData,
|
||||
onCheckedChange = onShowFakeDataChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
SettingsBaseItem(
|
||||
title = stringResource(R.string.settings_blescanner_unfiltered_label),
|
||||
subtitle = stringResource(R.string.settings_blescanner_unfiltered_description),
|
||||
icon = Icons.TwoTone.DevicesOther,
|
||||
onClick = { onShowUnfilteredChanged(!state.showUnfiltered) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.showUnfiltered,
|
||||
onCheckedChange = onShowUnfilteredChanged,
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DebugSettingsScreenPreview() = PreviewWrapper {
|
||||
DebugSettingsScreen(
|
||||
state = DebugSettingsViewModel.State(
|
||||
isDebugModeEnabled = true,
|
||||
showFakeData = false,
|
||||
showUnfiltered = false,
|
||||
),
|
||||
onNavigateUp = {},
|
||||
onDebugModeChanged = {},
|
||||
onShowFakeDataChanged = {},
|
||||
onShowUnfilteredChanged = {},
|
||||
)
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package eu.darken.capod.main.ui.settings.general.debug
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import javax.inject.Inject
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
|
||||
@HiltViewModel
|
||||
class DebugSettingsViewModel @Inject constructor(
|
||||
dispatcherProvider: DispatcherProvider,
|
||||
private val debugSettings: DebugSettings,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
data class State(
|
||||
val isDebugModeEnabled: Boolean,
|
||||
val showFakeData: Boolean,
|
||||
val showUnfiltered: Boolean,
|
||||
)
|
||||
|
||||
val state = combine(
|
||||
debugSettings.isDebugModeEnabled.flow,
|
||||
debugSettings.showFakeData.flow,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
) { debugMode, fakeData, unfiltered ->
|
||||
State(
|
||||
isDebugModeEnabled = debugMode,
|
||||
showFakeData = fakeData,
|
||||
showUnfiltered = unfiltered,
|
||||
)
|
||||
}.asLiveState()
|
||||
|
||||
fun setDebugModeEnabled(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setDebugModeEnabled($enabled)" }
|
||||
debugSettings.isDebugModeEnabled.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setShowFakeData(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowFakeData($enabled)" }
|
||||
debugSettings.showFakeData.valueBlocking = enabled
|
||||
}
|
||||
|
||||
fun setShowUnfiltered(enabled: Boolean) {
|
||||
log(TAG, INFO) { "setShowUnfiltered($enabled)" }
|
||||
debugSettings.showUnfiltered.valueBlocking = enabled
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Settings", "Debug", "VM")
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,13 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.takeFor
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
@@ -40,6 +45,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
fun upgradeRepo(): UpgradeRepo
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||
fun batteryEstimator(): BatteryEstimator
|
||||
}
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
@@ -99,6 +105,13 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } }
|
||||
?.collectAsState(initial = initialDevice)
|
||||
?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) }
|
||||
// Empty while the monitor service isn't running — estimates simply stay hidden.
|
||||
val rawEstimate by config.profileId
|
||||
?.let { pid ->
|
||||
remember(pid) { ep.batteryEstimator().estimates.map { it[pid] }.distinctUntilChanged() }
|
||||
}
|
||||
?.collectAsState(initial = null)
|
||||
?: remember { androidx.compose.runtime.mutableStateOf<BatteryEstimate?>(null) }
|
||||
val profileLabel = config.profileId?.let { pid ->
|
||||
runCatching {
|
||||
runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label }
|
||||
@@ -117,6 +130,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
hasConfiguredProfile = config.profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
layout = layout,
|
||||
estimate = device?.let { d -> rawEstimate?.takeFor(d) },
|
||||
)
|
||||
} else {
|
||||
WidgetRenderState.Message(
|
||||
@@ -138,7 +152,7 @@ class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
)
|
||||
).withLocalizedPreviewEstimates(context)
|
||||
|
||||
provideContent {
|
||||
GlanceWidgetContent(state = previewState, context = context)
|
||||
|
||||
@@ -96,9 +96,9 @@ private fun DualPodPreview(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint, iconSize = 40, modifier = Modifier.padding(end = 12.dp))
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
@@ -111,8 +111,8 @@ private fun DualPodPreview(
|
||||
|
||||
BatteryLayout.NARROW -> {
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint)
|
||||
PodItemRow(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textColor, iconTint, estimate = state.leftEstimate)
|
||||
PodItemRow(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textColor, iconTint, estimate = state.rightEstimate)
|
||||
PodItemRow(state.caseIcon, state.casePercent, state.caseCharging, false, textColor, iconTint)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
@@ -160,9 +160,10 @@ private fun SinglePodPreview(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent),
|
||||
text = formatPercent(state.percent) + estimateSuffix(state.estimate),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
@@ -297,39 +298,54 @@ private fun PodItemRow(
|
||||
textColor: Color,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
estimate: String? = null,
|
||||
estimateBelow: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
if (estimateBelow && estimate != null) {
|
||||
Text(
|
||||
text = estimate,
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -385,6 +401,8 @@ private fun DeviceLabel(
|
||||
private fun formatPercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualTiny11() = PreviewWrapper {
|
||||
|
||||
@@ -65,9 +65,9 @@ private fun GlanceDualPod(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, estimate = state.leftEstimate, estimateBelow = true, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40, estimate = state.rightEstimate, estimateBelow = true)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
@@ -76,8 +76,8 @@ private fun GlanceDualPod(
|
||||
BatteryLayout.NARROW -> {
|
||||
val textStyle = TextStyle(color = fixedColor(state.resolvedTextColor), fontSize = 12.sp)
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, estimate = state.leftEstimate)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, estimate = state.rightEstimate)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
@@ -122,8 +122,9 @@ private fun GlanceSinglePod(
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent),
|
||||
text = formatGlancePercent(state.percent) + estimateSuffix(state.estimate),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
@@ -257,37 +258,49 @@ private fun GlancePodItem(
|
||||
textStyle: TextStyle,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
estimate: String? = null,
|
||||
estimateBelow: Boolean = false,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
modifier = GlanceModifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent) + if (estimateBelow) "" else estimateSuffix(estimate),
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
if (estimateBelow && estimate != null) {
|
||||
Text(
|
||||
text = estimate,
|
||||
style = textStyle,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -343,3 +356,5 @@ private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%" else "—"
|
||||
|
||||
private fun estimateSuffix(estimate: String?): String = estimate?.let { " · $it" } ?: ""
|
||||
|
||||
@@ -507,7 +507,7 @@ private fun WidgetConfigPreview(
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
||||
).copy(deviceLabel = deviceLabel)
|
||||
).withLocalizedPreviewEstimates(context).copy(deviceLabel = deviceLabel)
|
||||
}
|
||||
ComposeWidgetPreview(state = batteryState, modifier = innerModifier)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ internal data class WidgetDeviceKey(
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val isAapConnected: Boolean,
|
||||
val isAapReady: Boolean,
|
||||
val hasBleAdvertisement: Boolean,
|
||||
@@ -48,6 +49,7 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey(
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
isAapConnected = isAapConnected,
|
||||
isAapReady = isAapReady,
|
||||
hasBleAdvertisement = ble != null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
|
||||
enum class BatteryLayout {
|
||||
TINY_COLUMN,
|
||||
@@ -36,10 +38,12 @@ sealed class WidgetRenderState {
|
||||
val leftPercent: Float,
|
||||
val leftCharging: Boolean,
|
||||
val leftInEar: Boolean,
|
||||
val leftEstimate: String?,
|
||||
@DrawableRes val rightIcon: Int,
|
||||
val rightPercent: Float,
|
||||
val rightCharging: Boolean,
|
||||
val rightInEar: Boolean,
|
||||
val rightEstimate: String?,
|
||||
@DrawableRes val caseIcon: Int,
|
||||
val casePercent: Float,
|
||||
val caseCharging: Boolean,
|
||||
@@ -57,6 +61,7 @@ sealed class WidgetRenderState {
|
||||
@DrawableRes val batteryIcon: Int,
|
||||
val charging: Boolean,
|
||||
val worn: Boolean,
|
||||
val estimate: String?,
|
||||
) : WidgetRenderState()
|
||||
|
||||
data class Message(
|
||||
@@ -95,10 +100,12 @@ sealed class WidgetRenderState {
|
||||
leftPercent = 0.85f,
|
||||
leftCharging = false,
|
||||
leftInEar = true,
|
||||
leftEstimate = "4h 55m",
|
||||
rightIcon = R.drawable.device_airpods_pro2_right,
|
||||
rightPercent = 0.92f,
|
||||
rightCharging = true,
|
||||
rightInEar = false,
|
||||
rightEstimate = "25m",
|
||||
caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
casePercent = 1.0f,
|
||||
caseCharging = false,
|
||||
@@ -122,6 +129,16 @@ sealed class WidgetRenderState {
|
||||
batteryIcon = R.drawable.ic_baseline_battery_3_bar_24,
|
||||
charging = false,
|
||||
worn = true,
|
||||
estimate = "14h 30m",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the preview factory's hardcoded sample estimates with properly localized ones for
|
||||
* user-facing preview surfaces (widget picker, configuration screen, store screenshots).
|
||||
*/
|
||||
fun WidgetRenderState.DualPod.withLocalizedPreviewEstimates(context: Context): WidgetRenderState.DualPod = copy(
|
||||
leftEstimate = formatBatteryDurationShort(context, 295),
|
||||
rightEstimate = formatBatteryDurationShort(context, 25),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,10 @@ import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
@@ -18,6 +21,7 @@ object WidgetRenderStateMapper {
|
||||
hasConfiguredProfile: Boolean,
|
||||
profileLabel: String?,
|
||||
layout: BatteryLayout = BatteryLayout.NARROW,
|
||||
estimate: BatteryEstimate? = null,
|
||||
): WidgetRenderState {
|
||||
val bgColor = resolvedBgColor(context, theme)
|
||||
val textColor = resolvedTextColor(context, theme)
|
||||
@@ -45,10 +49,12 @@ object WidgetRenderStateMapper {
|
||||
leftPercent = device.batteryLeft,
|
||||
leftCharging = device.isLeftPodCharging == true,
|
||||
leftInEar = device.isLeftInEar == true,
|
||||
leftEstimate = estimate?.left.estimateText(context, device.isLeftPodCharging == true),
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRight,
|
||||
rightCharging = device.isRightPodCharging == true,
|
||||
rightInEar = device.isRightInEar == true,
|
||||
rightEstimate = estimate?.right.estimateText(context, device.isRightPodCharging == true),
|
||||
caseIcon = device.caseIcon,
|
||||
casePercent = device.batteryCase,
|
||||
caseCharging = device.isCaseCharging == true,
|
||||
@@ -66,6 +72,7 @@ object WidgetRenderStateMapper {
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadset),
|
||||
charging = device.isHeadsetBeingCharged == true,
|
||||
worn = device.isBeingWorn == true,
|
||||
estimate = estimate?.headset.estimateText(context, device.isHeadsetBeingCharged == true),
|
||||
)
|
||||
|
||||
device != null -> WidgetRenderState.Message(
|
||||
@@ -95,6 +102,9 @@ object WidgetRenderStateMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private fun BatteryEstimate.Pod?.estimateText(context: Context, charging: Boolean): String? =
|
||||
this?.displayMinutes(charging)?.let { formatBatteryDurationShort(context, it) }
|
||||
|
||||
@ColorInt
|
||||
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
|
||||
val bgColor = theme.backgroundColor
|
||||
|
||||
@@ -79,6 +79,7 @@ class DeviceMonitor @Inject constructor(
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||
isSystemConnected = profile?.address in connectedAddresses,
|
||||
)
|
||||
}
|
||||
@@ -115,6 +116,7 @@ class DeviceMonitor @Inject constructor(
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||
isSystemConnected = profile.address in state.connectedAddresses,
|
||||
)
|
||||
}
|
||||
@@ -181,6 +183,7 @@ class DeviceMonitor @Inject constructor(
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||
isSystemConnected = profile.address in connectedAddresses,
|
||||
)
|
||||
}
|
||||
@@ -212,6 +215,21 @@ class DeviceMonitor @Inject constructor(
|
||||
dedupedLiveDevices + nonLiveDevices
|
||||
}.replayingShare(appScope)
|
||||
|
||||
/**
|
||||
* Connection-aware "primary" device for display surfaces (ongoing notification, case popup):
|
||||
* ranks by [PodDevice.tierRank] (system-connected > live > offline) with the user's
|
||||
* profile-list order as the tiebreaker. Unlike [primaryDevice], this does NOT blindly pick the
|
||||
* first profile, so a worn/connected pod is surfaced even when a different profile sits higher
|
||||
* in the list. Shared so multiple display collectors don't each re-run the combine.
|
||||
*/
|
||||
val primaryDeviceByTier: Flow<PodDevice?> = combine(
|
||||
devices,
|
||||
profilesRepo.profiles,
|
||||
) { devices, profiles ->
|
||||
val profileOrder = profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
|
||||
devices.primaryByTier(profileOrder)
|
||||
}.replayingShare(appScope)
|
||||
|
||||
private val reportedPersistFailures = mutableSetOf<String>()
|
||||
|
||||
private suspend fun persistLiveDevices(devices: List<PodDevice>) {
|
||||
@@ -273,6 +291,7 @@ class DeviceMonitor @Inject constructor(
|
||||
profileLearnedAllowOffEnabled = (profile as? AppleDeviceProfile)?.learnedAllowOffEnabled,
|
||||
profileLastRequestedListeningModeCycleMask = (profile as? AppleDeviceProfile)?.lastRequestedListeningModeCycleMask,
|
||||
reactions = profile.toReactionConfig(),
|
||||
batteryEstimateEnabled = (profile as? AppleDeviceProfile)?.batteryEstimateEnabled ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.toReactionConfig
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MonitorModeResolver @Inject constructor(
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
) {
|
||||
|
||||
val effectiveMode: Flow<MonitorMode> = combine(
|
||||
profilesRepo.profiles,
|
||||
nudgeCapabilityStore.availability,
|
||||
) { profiles, nudge ->
|
||||
// The mode is driven by the first *addressed* profile, not just the first profile. An
|
||||
// address-less profile can neither host an AAP session (AapAutoConnect requires an address)
|
||||
// nor auto-connect, so it can only ever resolve to MANUAL. Letting such a profile sit at the
|
||||
// top of the list and force MANUAL would silently stop the foreground service that keeps the
|
||||
// process — and thus background AAP/stem controls — alive for an addressed profile below it.
|
||||
// No addressed profile at all => genuinely nothing to monitor => MANUAL.
|
||||
profiles.firstOrNull { !it.address.isNullOrBlank() }?.requiredMode(nudge)
|
||||
?: MonitorMode.MANUAL
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { log(TAG, VERBOSE) { "effectiveMode = $it" } }
|
||||
|
||||
private fun DeviceProfile.requiredMode(nudge: NudgeAvailability): MonitorMode = when {
|
||||
// Match AutoConnect.kt's isNullOrEmpty check — a blank/legacy "" address is no address.
|
||||
address.isNullOrBlank() -> MonitorMode.MANUAL
|
||||
toReactionConfig().autoConnect && nudge != NudgeAvailability.BROKEN -> MonitorMode.ALWAYS
|
||||
else -> MonitorMode.AUTOMATIC
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "ModeResolver")
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ data class PodDevice(
|
||||
internal val profileLastRequestedListeningModeCycleMask: Int? = null,
|
||||
/** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */
|
||||
val reactions: ReactionConfig = ReactionConfig(),
|
||||
/** Whether the dashboard battery time-remaining estimate is enabled for this device (per-profile). */
|
||||
val batteryEstimateEnabled: Boolean = true,
|
||||
/** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */
|
||||
val isSystemConnected: Boolean = false,
|
||||
) {
|
||||
@@ -181,18 +183,23 @@ data class PodDevice(
|
||||
).minOrNull()
|
||||
}
|
||||
|
||||
// Charging — AAP preferred, BLE fallback, then cached
|
||||
// Charging — AAP preferred, BLE fallback, then cached. The cached fallback only applies while
|
||||
// the device is live (connected): charging is a real-time claim ("power is flowing now") that
|
||||
// we can't stand behind once the device is out of range, so we suppress it after full
|
||||
// disconnect rather than showing a frozen charging state on the dimmed "cached N ago" card.
|
||||
// The raw charging bits are still persisted in `cached` for callers that want the last-known
|
||||
// value directly (device.cached?.is…Charging).
|
||||
val isLeftPodCharging: Boolean?
|
||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging
|
||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging ?: cached?.isLeftCharging?.takeIf { isLive }
|
||||
|
||||
val isRightPodCharging: Boolean?
|
||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging
|
||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging ?: cached?.isRightCharging?.takeIf { isLive }
|
||||
|
||||
val isCaseCharging: Boolean?
|
||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging
|
||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging ?: cached?.isCaseCharging?.takeIf { isLive }
|
||||
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged ?: cached?.isHeadsetCharging?.takeIf { isLive }
|
||||
|
||||
// Full per-slot charging state — AAP only (BLE + cache don't carry CHARGING_OPTIMIZED).
|
||||
// Null means "no live AAP reading", which lets callers avoid showing a stale Optimized chip
|
||||
@@ -267,6 +274,14 @@ data class PodDevice(
|
||||
val isEitherPodInEar: Boolean?
|
||||
get() = aap?.isEitherPodInEar ?: (ble as? HasEarDetectionDual)?.isEitherPodInEar
|
||||
|
||||
/**
|
||||
* True when ear detection comes from a live AAP reading. The BLE ear-detection bits are
|
||||
* unreliable for pods resting in the case (they report phantom "in ear" and disagree
|
||||
* between the two pods' advertisements), so consumers that must not act on a false "worn"
|
||||
* gate on this.
|
||||
*/
|
||||
val hasAapEarDetection: Boolean get() = aap?.aapEarDetection != null
|
||||
|
||||
val caseLidState: DualApplePods.LidState?
|
||||
get() = (ble as? DualApplePods)?.caseLidState
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ package eu.darken.capod.monitor.core
|
||||
* lower rank = higher priority. System-connected devices come first, then
|
||||
* any live device (BLE or AAP), then profiled-but-offline.
|
||||
*
|
||||
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
|
||||
* non-tier-ranked for reaction flows that want "any profiled device".
|
||||
* Backs [DeviceMonitor.primaryDeviceByTier], used by display surfaces (ongoing notification,
|
||||
* case popup) that should follow the worn/connected pod.
|
||||
*
|
||||
* Distinct from [DeviceMonitor.primaryDevice], which is intentionally non-tier-ranked: address-
|
||||
* and eligibility-gated reaction flows (auto-connect targets a not-yet-connected device; sleep and
|
||||
* conversation match by the event's source address) must keep "first profiled device" semantics,
|
||||
* which tier ranking would break.
|
||||
*/
|
||||
fun PodDevice.tierRank(): Int = when {
|
||||
isSystemConnected -> 0
|
||||
|
||||
@@ -9,20 +9,22 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapDisconnectEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -37,6 +39,21 @@ class AapAutoConnect @Inject constructor(
|
||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
/**
|
||||
* Per-address count of consecutive connection attempts that never reached READY (failed socket
|
||||
* connects and handshake-timeout disconnects). Drives the escalating reconnect cooldown so a
|
||||
* device that keeps stalling its handshake in a crowded RF environment doesn't churn a tight
|
||||
* reconnect loop. Reset to 0 once a session works (a was-ready disconnect) or the device leaves.
|
||||
*/
|
||||
private val preReadyFailures = ConcurrentHashMap<String, Int>()
|
||||
|
||||
/** Cooldown (ms) to wait before the next attempt, given how many consecutive pre-READY failures we've seen. */
|
||||
private fun cooldownFor(address: String): Long {
|
||||
val failures = preReadyFailures[address] ?: 0
|
||||
if (failures <= 0) return 0L
|
||||
return HANDSHAKE_BACKOFF[minOf(failures - 1, HANDSHAKE_BACKOFF.lastIndex)]
|
||||
}
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
initialConnect(),
|
||||
reconnectOnDisconnect(),
|
||||
@@ -74,104 +91,150 @@ class AapAutoConnect @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
// Escalating cooldown carried over from prior failed attempts (socket failures or handshake
|
||||
// stalls). 0 on the first attempt so a healthy device connects without delay.
|
||||
val cooldown = cooldownFor(address)
|
||||
if (cooldown > 0) {
|
||||
log(TAG) { "AAP initial connect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
|
||||
delay(cooldown)
|
||||
}
|
||||
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
try {
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP initial connect failed for $address: ${e.message}" }
|
||||
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Bail out if classic BT disconnected
|
||||
// Bail out if classic BT disconnected — device left, clear the penalty
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP initial retry: $address no longer classically connected, stopping" }
|
||||
break
|
||||
preReadyFailures.remove(address)
|
||||
return
|
||||
}
|
||||
|
||||
val retryState = aapManager.allStates.value[address]
|
||||
if (retryState != null && retryState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP initial retry: $address already reconnected, stopping" }
|
||||
break
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP initial retry ${attempt + 1} for $address after ${delayMs}ms" }
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP connected to $address on retry ${attempt + 1}" }
|
||||
break
|
||||
return
|
||||
} catch (retryException: Exception) {
|
||||
log(TAG, WARN) { "AAP initial retry ${attempt + 1} failed for $address: ${retryException.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Every attempt this episode failed at the socket level — escalate the next round's cooldown.
|
||||
preReadyFailures.merge(address, 1, Int::plus)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
|
||||
.onEach { address ->
|
||||
if (!activeReconnects.add(address)) {
|
||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
try {
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Check if still profiled
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still bonded
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still classically connected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still visible in BLE
|
||||
val bleDevices = blePodMonitor.devices.first()
|
||||
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if already reconnected (e.g., by initialConnect)
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeReconnects.remove(address)
|
||||
}
|
||||
// Process each disconnect on its own child coroutine so a long per-address backoff never blocks
|
||||
// another device's reconnect (the collector would otherwise serialise events). channelFlow gives
|
||||
// us a scope to launch into; it intentionally emits nothing — it stays subscribed for its lifetime.
|
||||
private fun reconnectOnDisconnect(): Flow<Unit> = channelFlow<Unit> {
|
||||
aapManager.disconnectEvents.collect { event ->
|
||||
launch { handleReconnect(event) }
|
||||
}
|
||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||
}.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||
|
||||
private suspend fun handleReconnect(event: AapDisconnectEvent) {
|
||||
val address = event.address
|
||||
|
||||
// A session that actually worked resets the penalty (prompt reconnect). One that never reached
|
||||
// READY is a failed handshake/short session — escalate so a persistent stall backs off.
|
||||
if (event.wasEverReady) {
|
||||
preReadyFailures.remove(address)
|
||||
} else {
|
||||
preReadyFailures.merge(address, 1, Int::plus)
|
||||
}
|
||||
|
||||
if (!activeReconnects.add(address)) {
|
||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Escalating cooldown before the normal retry cadence when handshakes keep stalling.
|
||||
val cooldown = cooldownFor(address)
|
||||
if (cooldown > 0) {
|
||||
log(TAG) { "AAP reconnect cooldown ${cooldown}ms for $address (failures=${preReadyFailures[address]})" }
|
||||
delay(cooldown)
|
||||
}
|
||||
|
||||
for ((attempt, delayMs) in RETRY_DELAYS.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Check if still profiled
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||
preReadyFailures.remove(address)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if still bonded
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||
preReadyFailures.remove(address)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if still classically connected
|
||||
val currentConnected = bluetoothManager.connectedDevices.first().map { it.address }.toSet()
|
||||
if (address !in currentConnected) {
|
||||
log(TAG) { "AAP reconnect: $address no longer classically connected, stopping" }
|
||||
preReadyFailures.remove(address)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if still visible in BLE
|
||||
val bleDevices = blePodMonitor.devices.first()
|
||||
if (bleDevices.none { it.meta?.profile?.address == address }) {
|
||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||
preReadyFailures.remove(address)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already reconnected (e.g., by initialConnect)
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
aapManager.connect(address, bonded.internal!!, profile.model)
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Every attempt threw at the socket level (the device is still present but won't accept a
|
||||
// socket) — escalate so we don't hammer it. Mirrors connectWithRetries. Handshake stalls
|
||||
// don't reach here: their reconnect socket succeeds and we return above; they escalate via
|
||||
// the never-ready disconnect event instead.
|
||||
preReadyFailures.merge(address, 1, Int::plus)
|
||||
} finally {
|
||||
activeReconnects.remove(address)
|
||||
}
|
||||
}
|
||||
|
||||
private fun correctModelOnDeviceInfo(): Flow<Unit> = aapManager.allStates
|
||||
.map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } }
|
||||
@@ -224,5 +287,12 @@ class AapAutoConnect @Inject constructor(
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "AapAutoConnect")
|
||||
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||
|
||||
/**
|
||||
* Escalating cooldown (ms) applied before a reconnect/connect attempt, indexed by
|
||||
* consecutive pre-READY failures minus one. Caps at 60s so a device that perpetually stalls
|
||||
* its handshake in crowded RF settles into a slow ~minute cadence instead of a tight loop.
|
||||
*/
|
||||
internal val HANDSHAKE_BACKOFF = longArrayOf(5_000, 15_000, 30_000, 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class StemPressReaction @Inject constructor(
|
||||
is StemAction.PreviousTrack -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS)
|
||||
is StemAction.VolumeUp -> mediaControl.adjustVolumeUp()
|
||||
is StemAction.VolumeDown -> mediaControl.adjustVolumeDown()
|
||||
is StemAction.Stop -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_STOP)
|
||||
is StemAction.Stop -> mediaControl.sendStop()
|
||||
is StemAction.FastForward -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)
|
||||
is StemAction.Rewind -> mediaControl.sendKey(KeyEvent.KEYCODE_MEDIA_REWIND)
|
||||
is StemAction.MuteToggle -> mediaControl.toggleMuteMusic()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists learned battery drain rates per profile so the time-remaining estimate survives app
|
||||
* restarts and is available immediately on reconnect. Mirrors [eu.darken.capod.monitor.core.cache.DeviceStateCache]:
|
||||
* one small JSON file per profile, guarded by a [Mutex], read/written on IO, broadcast via a
|
||||
* [StateFlow]. Per-profile files mean one corrupt write can only lose a single device's history.
|
||||
*/
|
||||
@Singleton
|
||||
class BatteryDrainStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
private val storeDir by lazy {
|
||||
File(context.filesDir, "battery_drain_rates").apply { mkdirs() }
|
||||
}
|
||||
private val lock = Mutex()
|
||||
|
||||
private val _profiles = MutableStateFlow<Map<ProfileId, DrainProfile>>(emptyMap())
|
||||
val profiles: StateFlow<Map<ProfileId, DrainProfile>> = _profiles
|
||||
|
||||
init {
|
||||
appScope.launch { loadAll() }
|
||||
}
|
||||
|
||||
private suspend fun loadAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
val dir = storeDir
|
||||
if (!dir.exists()) return@withLock
|
||||
|
||||
val loaded = mutableMapOf<ProfileId, DrainProfile>()
|
||||
dir.listFiles()
|
||||
?.filter { it.name.startsWith(PREFIX) && it.name.endsWith(SUFFIX) }
|
||||
?.forEach { file ->
|
||||
val profileId = file.name.removePrefix(PREFIX).removeSuffix(SUFFIX)
|
||||
try {
|
||||
loaded[profileId] = json.decodeFromString<DrainProfile>(file.readText())
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to load ${file.name}: ${e.asLog()}, deleting" }
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
log(TAG, Logging.Priority.VERBOSE) { "loadAll(): loaded ${loaded.size} entries" }
|
||||
_profiles.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProfileId.toFile(): File = File(storeDir, "$PREFIX$this$SUFFIX")
|
||||
|
||||
suspend fun save(id: ProfileId, profile: DrainProfile) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "save(id=$id, rates=${profile.rates.keys})" }
|
||||
val file = id.toFile()
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
try {
|
||||
tmp.writeText(json.encodeToString(DrainProfile.serializer(), profile))
|
||||
if (!tmp.renameTo(file)) {
|
||||
// renameTo can fail across some filesystems; fall back to a direct write.
|
||||
file.writeText(tmp.readText())
|
||||
tmp.delete()
|
||||
}
|
||||
_profiles.value += (id to profile)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Failed to save $id: ${e.asLog()}" }
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(id: ProfileId) = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "delete(id=$id)" }
|
||||
val file = id.toFile()
|
||||
if (file.exists() && !file.delete()) {
|
||||
// In-memory is cleared regardless; warn so a failed delete (which would resurrect the
|
||||
// rate on the next loadAll()) is at least visible rather than silently undoing a reset.
|
||||
log(TAG, Logging.Priority.ERROR) { "delete($id): failed to remove $file" }
|
||||
}
|
||||
_profiles.value -= id
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll() = withContext(dispatcherProvider.IO) {
|
||||
lock.withLock {
|
||||
log(TAG, Logging.Priority.VERBOSE) { "deleteAll()" }
|
||||
storeDir.listFiles()?.forEach { it.delete() }
|
||||
_profiles.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "BatteryDrainStore")
|
||||
private const val PREFIX = "drainrate_"
|
||||
private const val SUFFIX = ".json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
/**
|
||||
* Per-pod time-remaining estimates for one device. Each earbud is measured and shown independently
|
||||
* (the mic pod, for example, drains faster). [headset] is used for single-headset devices; [left] /
|
||||
* [right] for dual-pod devices. Slots with no usable estimate are null.
|
||||
*/
|
||||
data class BatteryEstimate(
|
||||
val left: Pod? = null,
|
||||
val right: Pod? = null,
|
||||
val headset: Pod? = null,
|
||||
) {
|
||||
/**
|
||||
* @property minutesRemaining smoothed estimate of minutes until this pod empties
|
||||
* @property fractionPerHour the drain rate it was derived from (fraction/hour)
|
||||
* @property source how the drain was determined (see [Source]) — reflects measurement provenance,
|
||||
* NOT whether the model rating capped the shown value; a LIVE/LEARNED estimate can still be
|
||||
* bounded by the model's rated life
|
||||
* @property minutesUntilCharged minutes until this pod is full — non-null only while it is
|
||||
* actively charging with a usable charge rate (not during an Optimized Battery Charging hold
|
||||
* or the final trickle phase). Shown inside the charging chip; [minutesRemaining] stays on
|
||||
* the gauge line as the runtime projection.
|
||||
*/
|
||||
data class Pod(
|
||||
val minutesRemaining: Int,
|
||||
val fractionPerHour: Float,
|
||||
val source: Source,
|
||||
val minutesUntilCharged: Int? = null,
|
||||
) {
|
||||
/** True while the estimate rests on the model's rated spec, before any drain has been measured. */
|
||||
val isProvisional: Boolean get() = source == Source.SPEC
|
||||
}
|
||||
|
||||
/** Where a pod's drain rate was derived from, in order of increasing confidence. */
|
||||
enum class Source {
|
||||
/** Apple's published rating — shown immediately on connect, before any drain is observed. */
|
||||
SPEC,
|
||||
|
||||
/** Persisted rate from earlier sessions with this device. */
|
||||
LEARNED,
|
||||
|
||||
/** Measured from the current session's observed drain. */
|
||||
LIVE,
|
||||
}
|
||||
|
||||
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
|
||||
/**
|
||||
* Returns this estimate only if [device] should display it: the per-device toggle is on, the
|
||||
* device is live (no estimates for cached/offline cards), and it maps to a profile.
|
||||
*/
|
||||
fun BatteryEstimate.takeFor(device: PodDevice): BatteryEstimate? = takeIf {
|
||||
device.batteryEstimateEnabled && device.isLive && device.profileId != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up and gates the estimate to show for [device]. Shared by all surfaces (dashboard,
|
||||
* widget, notification) so the display rules can't drift apart.
|
||||
*/
|
||||
fun Map<String, BatteryEstimate>.estimateFor(device: PodDevice): BatteryEstimate? =
|
||||
device.profileId?.let { this[it] }?.takeFor(device)
|
||||
|
||||
/**
|
||||
* Minutes to display for one pod: the time-until-full while [charging] (null when no usable
|
||||
* charge ETA exists, e.g. during an Optimized Battery Charging hold), otherwise the runtime
|
||||
* projection. [charging] should be the same flag that drives the visible charging indicator,
|
||||
* so the shown duration never contradicts the bolt icon next to it.
|
||||
*/
|
||||
fun BatteryEstimate.Pod.displayMinutes(charging: Boolean): Int? = when {
|
||||
charging -> minutesUntilCharged
|
||||
else -> minutesRemaining
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-slot minutes a surface would actually render for [device], or null when no slot shows
|
||||
* anything. Everything else on the estimate (rates, source) churns without visible effect —
|
||||
* dedupe render triggers on this key so invisible changes don't refresh widgets/notifications.
|
||||
*/
|
||||
fun BatteryEstimate.displayKey(device: PodDevice): List<Int?>? = listOf(
|
||||
left?.displayMinutes(device.isLeftPodCharging == true),
|
||||
right?.displayMinutes(device.isRightPodCharging == true),
|
||||
headset?.displayMinutes(device.isHeadsetBeingCharged == true),
|
||||
).takeUnless { key -> key.all { it == null } }
|
||||
@@ -0,0 +1,722 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import android.media.AudioManager
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Learns each device's battery drain rate from observed levels over time and turns it into a
|
||||
* time-remaining estimate for the earbuds. Rates are learned per ANC mode (drain differs sharply
|
||||
* between OFF / ON / Transparency / Adaptive) and persisted via [BatteryDrainStore] so an estimate
|
||||
* is available immediately on reconnect.
|
||||
*
|
||||
* Lifecycle: [monitor] is launched by the foreground monitor service (see
|
||||
* `MonitorService.doMonitor`), NOT eagerly in `init`. `DeviceMonitor.devices` starts BLE scanning
|
||||
* while it has a subscriber, so a permanent subscription would keep scanning alive forever; gating
|
||||
* on the service keeps sampling tied to active monitoring. All mutable state below is touched only
|
||||
* from the single `monitor()` collector; [estimates] is the read-only output other components observe.
|
||||
*/
|
||||
@Singleton
|
||||
class BatteryEstimator @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val drainStore: BatteryDrainStore,
|
||||
private val timeSource: TimeSource,
|
||||
private val audioManager: AudioManager,
|
||||
) {
|
||||
|
||||
private val _estimates = MutableStateFlow<Map<ProfileId, BatteryEstimate>>(emptyMap())
|
||||
val estimates: StateFlow<Map<ProfileId, BatteryEstimate>> = _estimates
|
||||
|
||||
private enum class Slot { LEFT, RIGHT, HEADSET }
|
||||
|
||||
/** Which transport a battery reading came from — AAP is 1% granularity, BLE 10%. */
|
||||
private enum class DataSource { AAP, BLE }
|
||||
|
||||
private class SlotHistory {
|
||||
enum class Direction { DRAIN, CHARGE }
|
||||
|
||||
var direction: Direction = Direction.DRAIN
|
||||
private set
|
||||
private var source: DataSource? = null
|
||||
private val samples = ArrayDeque<DrainSample>()
|
||||
|
||||
val lastFraction: Float? get() = samples.lastOrNull()?.fraction
|
||||
val size: Int get() = samples.size
|
||||
|
||||
/**
|
||||
* Keeps the window only while it still describes the same thing: a direction flip
|
||||
* (drain <-> charge) obviously invalidates it, and so does an AAP <-> BLE source change —
|
||||
* the granularity jump (1% vs 10%) between transports would read as a fake level step.
|
||||
*/
|
||||
fun matches(direction: Direction, source: DataSource): Boolean =
|
||||
this.direction == direction && this.source == source
|
||||
|
||||
fun realign(direction: Direction, source: DataSource) {
|
||||
if (!matches(direction, source)) samples.clear()
|
||||
this.direction = direction
|
||||
this.source = source
|
||||
}
|
||||
|
||||
fun record(sample: DrainSample) {
|
||||
samples.addLast(sample)
|
||||
while (samples.size > RING_SIZE) samples.removeFirst()
|
||||
}
|
||||
|
||||
fun clear() = samples.clear()
|
||||
|
||||
fun toList(): List<DrainSample> = samples.toList()
|
||||
}
|
||||
|
||||
private class DeviceTracker {
|
||||
var modeBucket: String = MODE_UNKNOWN
|
||||
val slots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||
|
||||
/**
|
||||
* Parallel drain windows fed ONLY while the pod is worn, audio is playing, and this device
|
||||
* is the system's audio sink — pure listening segments, the basis for battery health.
|
||||
*/
|
||||
val listeningSlots: Map<Slot, SlotHistory> = Slot.entries.associateWith { SlotHistory() }
|
||||
|
||||
/** Fit + sample count of a just-closed listening segment, persisted on the next pass. */
|
||||
val pendingListeningFits: MutableMap<Slot, Pair<Float, Int>> = mutableMapOf()
|
||||
|
||||
/** Smoothed displayed minutes, per pod. */
|
||||
val lastMinutes: MutableMap<Slot, Int> = mutableMapOf()
|
||||
var lastUpdateMs: Long? = null
|
||||
|
||||
/** When each slot's level last visibly ROSE while charging — drives stall suppression. */
|
||||
val lastRiseMs: MutableMap<Slot, Long> = mutableMapOf()
|
||||
|
||||
// Keyed by "<bucket>/<slot>" for drain rates, "CHARGE/<slot>" for charge rates.
|
||||
val lastPersistAtMs: MutableMap<String, Long> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* Pre-session stored rate captured once per (bucket, slot), so repeated periodic persists
|
||||
* during a single session blend against a fixed baseline instead of runaway-converging.
|
||||
* [sessionBaselineCounts] captures the matching pre-session updateCount, so repeated persists
|
||||
* within one session count as ONE update, not many.
|
||||
*/
|
||||
val sessionBaseline: MutableMap<String, Float?> = mutableMapOf()
|
||||
val sessionBaselineCounts: MutableMap<String, Int> = mutableMapOf()
|
||||
|
||||
fun clearSlots() = slots.values.forEach { it.clear() }
|
||||
|
||||
fun resetWindow() {
|
||||
clearSlots()
|
||||
listeningSlots.values.forEach { it.clear() }
|
||||
pendingListeningFits.clear()
|
||||
lastMinutes.clear()
|
||||
lastRiseMs.clear()
|
||||
sessionBaseline.clear()
|
||||
sessionBaselineCounts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private val trackers = mutableMapOf<ProfileId, DeviceTracker>()
|
||||
|
||||
// Serialises process() against reset() so an in-flight persist can't resurrect a just-wiped rate.
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Wipes ALL learned state for [profileId] — the in-memory tracker, the current estimate, and the
|
||||
* persisted rates — under the same lock as [process], so it's atomic w.r.t. sampling/persisting.
|
||||
* The next live emission re-seeds the device from its model rating. Safe to call when the monitor
|
||||
* isn't running (trackers/estimates are already empty; only the store delete has effect).
|
||||
*/
|
||||
suspend fun reset(profileId: ProfileId) = withContext(NonCancellable) {
|
||||
// NonCancellable so a cancelled caller (e.g. the settings screen closing) can't leave the
|
||||
// wipe half-applied — memory cleared but persisted rates surviving to resurrect later.
|
||||
mutex.withLock {
|
||||
log(TAG) { "reset($profileId)" }
|
||||
trackers.remove(profileId)
|
||||
_estimates.value = _estimates.value - profileId
|
||||
drainStore.delete(profileId)
|
||||
}
|
||||
}
|
||||
|
||||
fun monitor(): Flow<Unit> = deviceMonitor.devices
|
||||
.onEach { devices -> process(devices) }
|
||||
.onCompletion {
|
||||
// The collector stops with the monitor service; drop session state so the UI can't
|
||||
// keep showing a stale estimate and the next session re-seeds from persistence.
|
||||
trackers.clear()
|
||||
_estimates.value = emptyMap()
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
|
||||
|
||||
private suspend fun process(devices: List<PodDevice>) = mutex.withLock {
|
||||
// Only profiles with a single, unambiguous live candidate that has the estimate enabled.
|
||||
// DeviceMonitor keeps multiple same-profile devices when there's no IRK-verified match, and
|
||||
// blending two physical devices' levels would be garbage — skip those. A device with the
|
||||
// feature disabled is skipped entirely (not sampled, not persisted).
|
||||
val unambiguous = devices
|
||||
.filter { it.profileId != null && it.isLive && it.batteryEstimateEnabled }
|
||||
.groupBy { it.profileId!! }
|
||||
.filter { (_, group) -> group.size == 1 }
|
||||
.mapValues { (_, group) -> group.single() }
|
||||
|
||||
val next = _estimates.value.toMutableMap()
|
||||
// Drop estimates for profiles no longer live/unambiguous/enabled this emission (offline gating).
|
||||
next.keys.retainAll(unambiguous.keys)
|
||||
|
||||
// Sampled once per emission — the gate for health-grade "listening" segments.
|
||||
val musicActive = audioManager.isMusicActive
|
||||
|
||||
for ((profileId, device) in unambiguous) {
|
||||
val estimate = updateTracker(profileId, device, musicActive)
|
||||
if (estimate != null) next[profileId] = estimate else next.remove(profileId)
|
||||
}
|
||||
|
||||
_estimates.value = next
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(profileId: ProfileId, device: PodDevice, musicActive: Boolean): BatteryEstimate? {
|
||||
val tracker = trackers.getOrPut(profileId) { DeviceTracker() }
|
||||
val nowMs = timeSource.elapsedRealtime()
|
||||
val bucket = device.modeBucket()
|
||||
|
||||
// A long gap since the last update means the device was out of range / reconnected — the
|
||||
// prior window is stale and must not be extended across the absence.
|
||||
val gap = tracker.lastUpdateMs?.let { nowMs - it }
|
||||
if (gap != null && gap > STALE_GAP_MS) tracker.resetWindow()
|
||||
tracker.lastUpdateMs = nowMs
|
||||
|
||||
// A mode change invalidates the current window — flush what we learned to the OLD bucket,
|
||||
// then start fresh so OFF-rate samples never blend into ON-rate.
|
||||
if (tracker.modeBucket != bucket) {
|
||||
persistFromWindow(profileId, tracker, device, tracker.modeBucket, nowMs, force = true)
|
||||
tracker.resetWindow()
|
||||
tracker.modeBucket = bucket
|
||||
}
|
||||
|
||||
for (slot in Slot.entries) {
|
||||
val history = tracker.slots.getValue(slot)
|
||||
val charging = device.liveCharging(slot)
|
||||
val reading = device.liveReading(slot)
|
||||
|
||||
when {
|
||||
reading == null -> { // unavailable reading → just drop this slot's window
|
||||
history.clear()
|
||||
tracker.lastRiseMs.remove(slot)
|
||||
// A charging jump breaks discharge continuity even when the level is unreadable.
|
||||
if (charging == true) tracker.lastMinutes.remove(slot)
|
||||
}
|
||||
charging == true -> { // battery rising → sample the CHARGE, never a drain
|
||||
val (fraction, source) = reading
|
||||
tracker.lastMinutes.remove(slot) // jump breaks continuity → drop discharge smoothing
|
||||
if (device.liveChargingOptimized(slot)) {
|
||||
// Optimized Battery Charging parks the level below full for hours while
|
||||
// still flagged charging — fitting that plateau would learn garbage.
|
||||
history.clear()
|
||||
tracker.lastRiseMs.remove(slot)
|
||||
} else {
|
||||
history.realign(SlotHistory.Direction.CHARGE, source)
|
||||
val last = history.lastFraction
|
||||
when {
|
||||
last == null -> { // charge session starts (or resumes) for this slot
|
||||
history.record(DrainSample(nowMs, fraction))
|
||||
tracker.lastRiseMs[slot] = nowMs
|
||||
}
|
||||
fraction > last + EPSILON -> {
|
||||
history.record(DrainSample(nowMs, fraction))
|
||||
tracker.lastRiseMs[slot] = nowMs
|
||||
}
|
||||
fraction < last - EPSILON -> { // level DROPPED while charging → reseat/swap
|
||||
history.clear()
|
||||
history.record(DrainSample(nowMs, fraction))
|
||||
tracker.lastRiseMs[slot] = nowMs
|
||||
}
|
||||
else -> Unit // ~unchanged; stall detection judges the silence
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { // draining (or unknown charging state — treated as draining, as before)
|
||||
val (fraction, source) = reading
|
||||
tracker.lastRiseMs.remove(slot)
|
||||
history.realign(SlotHistory.Direction.DRAIN, source)
|
||||
val last = history.lastFraction
|
||||
when {
|
||||
last == null -> history.record(DrainSample(nowMs, fraction))
|
||||
fraction > last + EPSILON -> { // level went UP (reseat/swap) → reset
|
||||
history.clear()
|
||||
history.record(DrainSample(nowMs, fraction))
|
||||
tracker.lastMinutes.remove(slot)
|
||||
}
|
||||
fraction < last - EPSILON -> history.record(DrainSample(nowMs, fraction)) // a drop
|
||||
else -> Unit // ~unchanged, no new information
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health-grade listening window: only pure segments count — the pod worn, audio playing,
|
||||
// and this device the audio sink (isMusicActive alone would count phone-speaker
|
||||
// playback). The moment the gate breaks, the finished segment's fit is captured for
|
||||
// persistence and the window cleared; mixed idle/listening samples would flatten the
|
||||
// slope and re-dilute health.
|
||||
val listening = charging != true && reading != null &&
|
||||
musicActive && device.isSystemConnected && device.wornForSlot(slot)
|
||||
val listeningHistory = tracker.listeningSlots.getValue(slot)
|
||||
if (listening) {
|
||||
val (fraction, source) = reading!!
|
||||
// An AAP<->BLE flip mid-listening still ends a PURE segment — flush it rather
|
||||
// than letting realign silently discard it.
|
||||
if (!listeningHistory.matches(SlotHistory.Direction.DRAIN, source)) {
|
||||
captureListeningSegment(tracker, slot)
|
||||
}
|
||||
listeningHistory.realign(SlotHistory.Direction.DRAIN, source)
|
||||
val last = listeningHistory.lastFraction
|
||||
when {
|
||||
last == null -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
fraction > last + EPSILON -> { // reseat mid-listening → fresh segment
|
||||
listeningHistory.clear()
|
||||
listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
}
|
||||
fraction < last - EPSILON -> listeningHistory.record(DrainSample(nowMs, fraction))
|
||||
else -> Unit
|
||||
}
|
||||
} else {
|
||||
captureListeningSegment(tracker, slot)
|
||||
}
|
||||
}
|
||||
|
||||
persistFromWindow(profileId, tracker, device, bucket, nowMs, force = false)
|
||||
return computeEstimate(profileId, tracker, device, bucket, nowMs)
|
||||
}
|
||||
|
||||
/** Computes an independent estimate for each pod (left / right / headset). */
|
||||
private fun computeEstimate(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
bucket: String,
|
||||
nowMs: Long,
|
||||
): BatteryEstimate? {
|
||||
val estimate = BatteryEstimate(
|
||||
left = slotEstimate(profileId, tracker, device, bucket, Slot.LEFT, nowMs),
|
||||
right = slotEstimate(profileId, tracker, device, bucket, Slot.RIGHT, nowMs),
|
||||
headset = slotEstimate(profileId, tracker, device, bucket, Slot.HEADSET, nowMs),
|
||||
)
|
||||
return estimate.takeIf { it.hasAny }
|
||||
}
|
||||
|
||||
private fun slotEstimate(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
bucket: String,
|
||||
slot: Slot,
|
||||
nowMs: Long,
|
||||
): BatteryEstimate.Pod? {
|
||||
val fraction = device.liveFraction(slot) ?: return null
|
||||
|
||||
val spec = device.specRate(bucket)
|
||||
// While charging the battery is rising, so there's no live drain to fit — project the runtime
|
||||
// "if used now" from the learned rate or the model rating instead. Live sampling is skipped
|
||||
// here (and updateTracker already clears the window while charging), so a rising level is
|
||||
// never learned as a drain — we only surface a projection so the estimate stays visible in
|
||||
// the case, climbing as the pod charges.
|
||||
val charging = device.liveCharging(slot) == true
|
||||
val live = if (charging) {
|
||||
null
|
||||
} else {
|
||||
DrainModel.slopeFractionPerHour(tracker.slots.getValue(slot).toList())
|
||||
?.takeIf { plausibleForModel(it, spec) }
|
||||
}
|
||||
val learned = learnedRate(profileId, device, bucket, slot)
|
||||
val displayRate = live ?: learned ?: spec ?: return null
|
||||
|
||||
// Apple's rating is a hard ceiling on remaining life (a floor on the drain rate) for every
|
||||
// source: a degraded battery only ever drains FASTER than new-condition spec, so a measured
|
||||
// rate normally wins — spec only bites when a source implausibly implies MORE life than Apple.
|
||||
val effectiveRate = spec?.let { maxOf(displayRate, it) } ?: displayRate
|
||||
|
||||
val minutes = DrainModel.minutesRemaining(fraction, effectiveRate) ?: return null
|
||||
// Smooth against the last displayed value — but while charging neither read nor write that
|
||||
// state: the in-case projection is shown raw and must not pollute the discharge history, else
|
||||
// the first estimate after undocking would blend against a stale charging projection.
|
||||
var smoothed = DrainModel.blendMinutes(if (charging) null else tracker.lastMinutes[slot], minutes)
|
||||
// Smoothing lags, so just after a drop it can sit above the ceiling — clamp the shown value to
|
||||
// the spec cap too, so we never DISPLAY more life than Apple rates even mid-transition.
|
||||
spec?.let { DrainModel.minutesRemaining(fraction, it) }?.let { smoothed = minOf(smoothed, it) }
|
||||
if (!charging) tracker.lastMinutes[slot] = smoothed
|
||||
|
||||
val source = when {
|
||||
live != null -> BatteryEstimate.Source.LIVE
|
||||
learned != null -> BatteryEstimate.Source.LEARNED
|
||||
else -> BatteryEstimate.Source.SPEC
|
||||
}
|
||||
return BatteryEstimate.Pod(
|
||||
minutesRemaining = smoothed,
|
||||
fractionPerHour = effectiveRate,
|
||||
source = source,
|
||||
minutesUntilCharged = if (charging) chargeEstimate(profileId, tracker, device, slot, fraction, nowMs) else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [slot] is full, or null when no usable charge rate exists, the pod is in an
|
||||
* Optimized Battery Charging hold, or the level has sat still longer than one visible step
|
||||
* should take (stall — trickle phase or an unreported hold; a linear ETA would just freeze).
|
||||
*/
|
||||
private fun chargeEstimate(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
slot: Slot,
|
||||
fraction: Float,
|
||||
nowMs: Long,
|
||||
): Int? {
|
||||
if (device.liveChargingOptimized(slot)) return null // held below full — an ETA would mislead
|
||||
val history = tracker.slots.getValue(slot)
|
||||
val ring = if (history.direction == SlotHistory.Direction.CHARGE) history.toList() else emptyList()
|
||||
val liveScalar = if (ring.isNotEmpty()) DrainModel.chargeSlopeFractionPerHour(ring) else null
|
||||
val learnedScalar = learnedChargeRate(profileId, device, slot)
|
||||
val specRate = device.model.batterySpec?.chargeFractionPerHour
|
||||
|
||||
// Per band: this session's in-band fit, then the learned band rate, then the scalar
|
||||
// fallbacks, then Apple's quick-charge claim with the band's taper haircut (the claim
|
||||
// measures the bulk phase; scalars already average what was actually observed).
|
||||
fun rateFor(band: DrainModel.ChargeBand): Float? =
|
||||
(if (ring.isNotEmpty()) DrainModel.chargeBandSlopeFractionPerHour(ring, band) else null)
|
||||
?: learnedChargeBand(profileId, device, slot, band)
|
||||
?: liveScalar
|
||||
?: learnedScalar
|
||||
?: specRate?.let { it * band.specMultiplier }
|
||||
|
||||
val currentBand = DrainModel.ChargeBand.entries.firstOrNull { fraction < it.to }
|
||||
?: DrainModel.ChargeBand.TRICKLE
|
||||
val stallRate = rateFor(currentBand) ?: return null
|
||||
val lastRise = tracker.lastRiseMs[slot] ?: return null
|
||||
val step = if (device.liveReading(slot)?.second == DataSource.AAP) STEP_AAP else STEP_BLE
|
||||
if (nowMs - lastRise > DrainModel.chargeStallThresholdMs(stallRate, step)) return null
|
||||
|
||||
return DrainModel.minutesUntilFull(fraction, ::rateFor)
|
||||
}
|
||||
|
||||
private fun learnedChargeBand(
|
||||
profileId: ProfileId,
|
||||
device: PodDevice,
|
||||
slot: Slot,
|
||||
band: DrainModel.ChargeBand,
|
||||
): Float? = storedProfileFor(profileId, device)?.chargeBands[slot.name]?.get(band.name)?.fractionPerHour
|
||||
|
||||
/**
|
||||
* Closes [slot]'s current listening segment: a valid fit is queued for persistence (the next
|
||||
* persist pass writes it, bypassing cadence) and the window cleared either way.
|
||||
*/
|
||||
private fun captureListeningSegment(tracker: DeviceTracker, slot: Slot) {
|
||||
val history = tracker.listeningSlots.getValue(slot)
|
||||
if (history.size == 0) return
|
||||
DrainModel.slopeFractionPerHour(history.toList())?.let {
|
||||
tracker.pendingListeningFits[slot] = it to history.size
|
||||
}
|
||||
history.clear()
|
||||
}
|
||||
|
||||
/** Whether the pod in [slot] is being worn — per-pod for buds, whole-device for headsets. */
|
||||
private fun PodDevice.wornForSlot(slot: Slot): Boolean = when (slot) {
|
||||
Slot.LEFT -> isLeftInEar
|
||||
Slot.RIGHT -> isRightInEar
|
||||
Slot.HEADSET -> isBeingWorn
|
||||
} == true
|
||||
|
||||
/**
|
||||
* Persists each pod's live drain or charge rate (whichever direction its window currently
|
||||
* tracks), at most once per [PERSIST_INTERVAL_MS] (mirrors the cache's periodic-save cadence)
|
||||
* unless [force]d (mode change). Drain rates are keyed per (bucket, slot); charge rates per slot
|
||||
* only — the ANC mode doesn't apply inside the case.
|
||||
*/
|
||||
private suspend fun persistFromWindow(
|
||||
profileId: ProfileId,
|
||||
tracker: DeviceTracker,
|
||||
device: PodDevice,
|
||||
bucket: String,
|
||||
nowMs: Long,
|
||||
force: Boolean,
|
||||
) {
|
||||
// A profile tagged with DIFFERENT hardware means the user re-pointed it — its rates don't
|
||||
// describe this device, so learning starts over instead of blending into foreign history.
|
||||
val existing = drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
|
||||
?: DrainProfile()
|
||||
val spec = device.specRate(bucket)
|
||||
var rates = existing.rates
|
||||
var chargeRates = existing.chargeRates
|
||||
var changed = false
|
||||
|
||||
var chargeBands = existing.chargeBands
|
||||
var listeningRates = existing.listeningRates
|
||||
|
||||
// Blend against the rate stored when this session began, captured once per key, so a single
|
||||
// long session's repeated writes can't dominate prior history by re-blending their own
|
||||
// output. The captured updateCount keeps a whole session counting as ONE accumulated update.
|
||||
fun blended(cadenceKey: String, stored: DrainProfile.LearnedRate?, fit: Float, samples: Int): DrainProfile.LearnedRate {
|
||||
if (!tracker.sessionBaseline.containsKey(cadenceKey)) {
|
||||
tracker.sessionBaseline[cadenceKey] = stored?.fractionPerHour
|
||||
tracker.sessionBaselineCounts[cadenceKey] = stored?.updateCount ?: 0
|
||||
}
|
||||
return DrainProfile.LearnedRate(
|
||||
fractionPerHour = DrainModel.blendRate(tracker.sessionBaseline[cadenceKey], fit),
|
||||
sampleCount = samples,
|
||||
updateCount = (tracker.sessionBaselineCounts[cadenceKey] ?: 0) + 1,
|
||||
updatedAt = timeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
// True at most once per PERSIST_INTERVAL_MS per key (bypassed on [force] or [always]).
|
||||
fun cadenceOk(cadenceKey: String, always: Boolean = false): Boolean {
|
||||
val last = tracker.lastPersistAtMs[cadenceKey]
|
||||
if (!always && !force && last != null && nowMs - last < PERSIST_INTERVAL_MS) return false
|
||||
tracker.lastPersistAtMs[cadenceKey] = nowMs
|
||||
return true
|
||||
}
|
||||
|
||||
for (slot in Slot.entries) {
|
||||
val history = tracker.slots.getValue(slot)
|
||||
|
||||
if (history.direction == SlotHistory.Direction.CHARGE) {
|
||||
// Whole-session scalar — the fallback basis and the stall-threshold reference.
|
||||
DrainModel.chargeSlopeFractionPerHour(history.toList())?.let { fit ->
|
||||
val key = chargeRateKey(slot)
|
||||
if (cadenceOk(key)) {
|
||||
chargeRates = chargeRates + (slot.name to blended(key, chargeRates[slot.name], fit, history.size))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting charge rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
// Per-band rates — charging is CC/CV, each regime learns its own speed.
|
||||
for (band in DrainModel.ChargeBand.entries) {
|
||||
val fit = DrainModel.chargeBandSlopeFractionPerHour(history.toList(), band) ?: continue
|
||||
val key = "${chargeRateKey(slot)}/${band.name}"
|
||||
if (!cadenceOk(key)) continue
|
||||
val stored = chargeBands[slot.name]?.get(band.name)
|
||||
val slotBands = chargeBands[slot.name].orEmpty() + (band.name to blended(key, stored, fit, history.size))
|
||||
chargeBands = chargeBands + (slot.name to slotBands)
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting charge band for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
} else {
|
||||
// Same model-aware plausibility gate as display, so an implausibly fast fit isn't learned.
|
||||
DrainModel.slopeFractionPerHour(history.toList())
|
||||
?.takeIf { plausibleForModel(it, spec) }
|
||||
?.let { fit ->
|
||||
val key = rateKey(bucket, slot)
|
||||
if (cadenceOk(key)) {
|
||||
rates = rates + (key to blended(key, rates[key], fit, history.size))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting learned rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listening rates (health basis): a just-closed segment persists immediately — it would
|
||||
// be lost otherwise, the window is already cleared. An ongoing window follows cadence.
|
||||
val pending = tracker.pendingListeningFits.remove(slot)
|
||||
val (fit, samples) = when {
|
||||
pending != null -> pending
|
||||
else -> DrainModel.slopeFractionPerHour(tracker.listeningSlots.getValue(slot).toList())
|
||||
?.let { it to tracker.listeningSlots.getValue(slot).size } ?: (null to 0)
|
||||
}
|
||||
if (fit != null && plausibleForModel(fit, spec)) {
|
||||
val key = rateKey(bucket, slot)
|
||||
val cadenceKey = "LISTEN/$key"
|
||||
if (cadenceOk(cadenceKey, always = pending != null)) {
|
||||
listeningRates = listeningRates + (key to blended(cadenceKey, listeningRates[key], fit, samples))
|
||||
changed = true
|
||||
log(TAG, VERBOSE) { "Persisting listening rate for $profileId [$key]: ${"%.3f".format(fit)}/hr" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
drainStore.save(
|
||||
profileId,
|
||||
existing.copy(
|
||||
model = device.model.name,
|
||||
rates = rates,
|
||||
chargeRates = chargeRates,
|
||||
chargeBands = chargeBands,
|
||||
listeningRates = listeningRates,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun learnedRate(profileId: ProfileId, device: PodDevice, bucket: String, slot: Slot): Float? {
|
||||
val profile = storedProfileFor(profileId, device) ?: return null
|
||||
// Exact per-mode learning always wins — a real measurement for THIS mode.
|
||||
profile.rates[rateKey(bucket, slot)]?.validFractionPerHour()?.let { return it }
|
||||
// Empty bucket: fill it conservatively with the less-optimistic (faster-draining) of the
|
||||
// mode-agnostic UNKNOWN reading and the spec-scaled sibling reading, so toggling ANC into an
|
||||
// unlearned mode never inflates the estimate past a real sibling measurement.
|
||||
val unknown = profile.rates[rateKey(MODE_UNKNOWN, slot)]?.validFractionPerHour()
|
||||
val sibling = siblingScaledRate(profile, device, bucket, slot)
|
||||
return listOfNotNull(unknown, sibling).maxOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills an empty ANC bucket by borrowing another mode's learned rate, scaled to this mode by the
|
||||
* ratio of the two modes' rated drain (`predicted = sibling × specRate(current)/specRate(sibling)`).
|
||||
* Keeps the estimate continuous across an ANC toggle instead of jumping to the optimistic spec.
|
||||
*
|
||||
* Only fires when:
|
||||
* - the current bucket is a real ANC mode (an UNKNOWN / mode-not-known reading keeps its
|
||||
* conservative spec-min behaviour), and
|
||||
* - the model publishes ratings for both modes (without a rating there's no [effectiveRate] ceiling
|
||||
* or display clamp, so a borrowed rate could over-promise unbounded), and
|
||||
* - the sibling mode is one the device reports as supported (ignore stale keys for modes this
|
||||
* hardware can't use), falling back to all modes only when the supported list is unavailable.
|
||||
*
|
||||
* Among the candidates the best-evidenced one wins, tie-broken by closest rated drain (best
|
||||
* physical predictor) then recency. Returns null when nothing trustworthy is available; the caller
|
||||
* merges the result conservatively with the UNKNOWN reading.
|
||||
*/
|
||||
private fun siblingScaledRate(profile: DrainProfile, device: PodDevice, bucket: String, slot: Slot): Float? {
|
||||
if (bucket == MODE_UNKNOWN) return null
|
||||
val targetSpec = device.specRate(bucket) ?: return null
|
||||
val supported = device.ancMode?.supported?.map { it.name }?.takeIf { it.isNotEmpty() }
|
||||
val best = AapSetting.AncMode.Value.entries
|
||||
.map { it.name }
|
||||
.filter { it != bucket && (supported == null || it in supported) }
|
||||
.mapNotNull { sib ->
|
||||
val siblingSpec = device.specRate(sib) ?: return@mapNotNull null
|
||||
val learned = profile.rates[rateKey(sib, slot)]
|
||||
?.takeIf { it.fractionPerHour.isFinite() && it.fractionPerHour > 0f }
|
||||
?: return@mapNotNull null
|
||||
learned to siblingSpec
|
||||
}
|
||||
.maxWithOrNull(
|
||||
compareBy<Pair<DrainProfile.LearnedRate, Float>> { it.first.updateCount }
|
||||
.thenBy { -abs(targetSpec - it.second) }
|
||||
.thenBy { it.first.updatedAt },
|
||||
) ?: return null
|
||||
return (best.first.fractionPerHour * (targetSpec / best.second)).takeIf { it.isFinite() && it > 0f }
|
||||
}
|
||||
|
||||
private fun DrainProfile.LearnedRate.validFractionPerHour(): Float? =
|
||||
fractionPerHour.takeIf { it.isFinite() && it > 0f }
|
||||
|
||||
private fun learnedChargeRate(profileId: ProfileId, device: PodDevice, slot: Slot): Float? =
|
||||
storedProfileFor(profileId, device)?.chargeRates[slot.name]?.fractionPerHour
|
||||
|
||||
/** The stored profile, ignored entirely when its rates were learned on different hardware. */
|
||||
private fun storedProfileFor(profileId: ProfileId, device: PodDevice): DrainProfile? =
|
||||
drainStore.profiles.value[profileId]?.takeIf { it.matchesModel(device.model) }
|
||||
|
||||
private fun rateKey(bucket: String, slot: Slot): String = "$bucket/${slot.name}"
|
||||
|
||||
/** Session-state key for charge windows — namespaced so it can't collide with an ANC bucket. */
|
||||
private fun chargeRateKey(slot: Slot): String = "CHARGE/${slot.name}"
|
||||
|
||||
private fun PodDevice.modeBucket(): String = ancMode?.current?.name ?: MODE_UNKNOWN
|
||||
|
||||
/**
|
||||
* Apple's rated drain (fraction/hour) for this model in the given ANC bucket, or null when the
|
||||
* model has no published rating (Beats / unknown). Used to seed an estimate before any drain is
|
||||
* observed and as the upper bound on remaining life. When the mode isn't known yet (BLE-only, or
|
||||
* a just-connected AAP session) the shorter of the two ratings is used, so an unknown mode can't
|
||||
* over-promise.
|
||||
*/
|
||||
private fun PodDevice.specRate(bucket: String): Float? {
|
||||
val spec = model.batterySpec ?: return null
|
||||
val on = spec.listeningHoursAncOn
|
||||
val off = spec.listeningHoursAncOff
|
||||
val hours = when (bucket) {
|
||||
AapSetting.AncMode.Value.OFF.name -> off ?: on
|
||||
MODE_UNKNOWN -> listOfNotNull(on, off).minOrNull()
|
||||
else -> on ?: off // ON / TRANSPARENCY / ADAPTIVE
|
||||
} ?: return null
|
||||
return if (hours.isFinite() && hours > 0f) 1f / hours else null
|
||||
}
|
||||
|
||||
/**
|
||||
* A measured rate is trusted only when it isn't absurdly faster than the model's rated drain. The
|
||||
* slow side is intentionally NOT bounded: a genuinely gentle drain is real data worth learning,
|
||||
* and the spec ceiling already stops a slow rate from over-reporting on screen.
|
||||
*/
|
||||
private fun plausibleForModel(rate: Float, spec: Float?): Boolean =
|
||||
spec == null || rate <= spec * SPEC_BAND_MAX
|
||||
|
||||
// RAW LIVE extraction — must NOT use device.batteryLeft/isLeftPodCharging (which fall back to
|
||||
// cache); learning from a re-stamped stale reading would poison the rate.
|
||||
private fun PodDevice.liveFraction(slot: Slot): Float? = liveReading(slot)?.first
|
||||
|
||||
/**
|
||||
* The slot's live battery fraction plus which transport reported it. The source matters because
|
||||
* the two granularities (AAP 1%, BLE 10%) can't share a fit window — see [SlotHistory.realign].
|
||||
*/
|
||||
private fun PodDevice.liveReading(slot: Slot): Pair<Float, DataSource>? {
|
||||
val aapValue = when (slot) {
|
||||
Slot.LEFT -> aap?.batteryLeft
|
||||
Slot.RIGHT -> aap?.batteryRight
|
||||
Slot.HEADSET -> aap?.batteryHeadset
|
||||
}
|
||||
val (value, source) = when {
|
||||
aapValue != null -> aapValue to DataSource.AAP
|
||||
else -> when (slot) {
|
||||
Slot.LEFT -> (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
Slot.RIGHT -> (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
Slot.HEADSET -> (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
}?.let { it to DataSource.BLE } ?: return null
|
||||
}
|
||||
// coerce defends against a malformed >1 reading, which would otherwise beat the full-charge spec.
|
||||
return value.takeIf { isKnownBattery(it) }?.coerceIn(0f, 1f)?.let { it to source }
|
||||
}
|
||||
|
||||
private fun PodDevice.liveCharging(slot: Slot): Boolean? = when (slot) {
|
||||
Slot.LEFT -> aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
|
||||
Slot.RIGHT -> aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
|
||||
Slot.HEADSET -> aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
|
||||
}
|
||||
|
||||
/** Only AAP reports the Optimized Battery Charging hold; BLE can't distinguish it. */
|
||||
private fun PodDevice.liveChargingOptimized(slot: Slot): Boolean = when (slot) {
|
||||
Slot.LEFT -> aap?.leftChargingState
|
||||
Slot.RIGHT -> aap?.rightChargingState
|
||||
Slot.HEADSET -> aap?.headsetChargingState
|
||||
} == AapPodState.ChargingState.CHARGING_OPTIMIZED
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Monitor", "BatteryEstimator")
|
||||
private const val RING_SIZE = 32
|
||||
private const val EPSILON = 0.001f
|
||||
private const val MODE_UNKNOWN = DrainProfile.BUCKET_UNKNOWN
|
||||
private const val PERSIST_INTERVAL_MS = 5 * 60_000L
|
||||
|
||||
/** Visible battery step per transport — feeds the granularity-aware stall threshold. */
|
||||
private const val STEP_AAP = 0.01f
|
||||
private const val STEP_BLE = 0.10f
|
||||
|
||||
/** A measured rate above this multiple of the model's rated drain is rejected as implausible. */
|
||||
private const val SPEC_BAND_MAX = 4f
|
||||
|
||||
/** A gap longer than this between updates means the device was away — reset its window. */
|
||||
private const val STALE_GAP_MS = 15 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Derives a rough per-pod battery-health percentage from learned drain rates vs the model's rated
|
||||
* battery life. Nothing on the wire exposes Apple's real health/cycle data, so this is a usage-based
|
||||
* proxy: a pod that only lasts 4.5h of a rated 6h reads as ~75%.
|
||||
*
|
||||
* Health is computed PER POD — single-pod listening habits or a replaced earbud make the two sides
|
||||
* genuinely diverge, and a combined figure would mask a failing pod. Only [DrainProfile.listeningRates]
|
||||
* feed it (segments where the pod was worn AND audio was playing on this device), because Apple's
|
||||
* ratings are listening figures — general rates include idle wear and would flatter health. Within a
|
||||
* pod, the MEDIAN of its qualifying rates is used rather than the best or worst, damping remaining
|
||||
* confounds (volume, calls, cold) in either direction. It remains an estimate — label it as such in
|
||||
* the UI.
|
||||
*/
|
||||
object BatteryHealth {
|
||||
|
||||
/** A learned rate must have accumulated this many separate sessions before it counts. */
|
||||
const val MIN_UPDATE_COUNT = 3
|
||||
|
||||
data class PerPod(
|
||||
val left: Int? = null,
|
||||
val right: Int? = null,
|
||||
val headset: Int? = null,
|
||||
) {
|
||||
val hasAny: Boolean get() = left != null || right != null || headset != null
|
||||
}
|
||||
|
||||
fun estimate(profile: DrainProfile?, model: PodModel): PerPod? {
|
||||
if (profile == null) return null
|
||||
val spec = model.batterySpec ?: return null
|
||||
if (!profile.matchesModel(model)) return null
|
||||
|
||||
return PerPod(
|
||||
left = slotPercent(profile, spec, "LEFT"),
|
||||
right = slotPercent(profile, spec, "RIGHT"),
|
||||
headset = slotPercent(profile, spec, "HEADSET"),
|
||||
).takeIf { it.hasAny }
|
||||
}
|
||||
|
||||
private fun slotPercent(profile: DrainProfile, spec: PodModel.BatterySpec, slot: String): Int? {
|
||||
val ratios = profile.listeningRates.mapNotNull { (key, rate) ->
|
||||
// Keys must be exactly "<bucket>/<slot>" — anything else is corrupted or
|
||||
// future-format data and must not feed a health figure.
|
||||
val parts = key.split('/')
|
||||
if (parts.size != 2 || parts[1] != slot) return@mapNotNull null
|
||||
val specHours = specHoursFor(spec, parts[0]) ?: return@mapNotNull null
|
||||
if (rate.updateCount < MIN_UPDATE_COUNT) return@mapNotNull null
|
||||
if (!rate.fractionPerHour.isFinite() || rate.fractionPerHour <= 0f) return@mapNotNull null
|
||||
(1f / specHours) / rate.fractionPerHour
|
||||
}
|
||||
if (ratios.isEmpty()) return null
|
||||
|
||||
val sorted = ratios.sorted()
|
||||
val median = if (sorted.size % 2 == 1) {
|
||||
sorted[sorted.size / 2]
|
||||
} else {
|
||||
(sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2f
|
||||
}
|
||||
return (median * 100f).roundToInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rated hours a rate learned in [bucket] should be judged against. UNKNOWN-bucket usage
|
||||
* can't be matched to a specific mode, so it's compared to the middle of the two ratings —
|
||||
* the shorter one would systematically flatter health, the longer one would slander it.
|
||||
* Malformed or unrecognized bucket keys yield null (entry is skipped).
|
||||
*/
|
||||
private fun specHoursFor(spec: PodModel.BatterySpec, bucket: String): Float? {
|
||||
val on = spec.listeningHoursAncOn
|
||||
val off = spec.listeningHoursAncOff
|
||||
return when (bucket) {
|
||||
AapSetting.AncMode.Value.OFF.name -> off ?: on
|
||||
AapSetting.AncMode.Value.ON.name,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY.name,
|
||||
AapSetting.AncMode.Value.ADAPTIVE.name,
|
||||
-> on ?: off
|
||||
DrainProfile.BUCKET_UNKNOWN -> listOfNotNull(on, off).takeIf { it.isNotEmpty() }?.average()?.toFloat()
|
||||
else -> null
|
||||
}?.takeIf { it.isFinite() && it > 0f }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* A single battery observation for one slot (left / right / headset).
|
||||
*
|
||||
* [atElapsedMs] is a monotonic timestamp ([eu.darken.capod.common.TimeSource.elapsedRealtime]) so
|
||||
* wall-clock jumps can't corrupt the regression. [fraction] is a battery level in `0.0..1.0`
|
||||
* (the same unit used everywhere else in the app), NOT a 0..100 percentage.
|
||||
*/
|
||||
data class DrainSample(
|
||||
val atElapsedMs: Long,
|
||||
val fraction: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure, side-effect-free drain-rate math. Kept separate from [BatteryEstimator] so the numerics can
|
||||
* be unit-tested without Android, coroutines, or persistence.
|
||||
*
|
||||
* All rates are **fraction per hour** (e.g. `0.169` = 16.9 %/hr) to match the `0.0..1.0` battery unit.
|
||||
* Mixing this up with a 0..100 percentage would produce a 100× error, so the unit is in the name.
|
||||
*/
|
||||
object DrainModel {
|
||||
|
||||
/** Minimum samples before a live regression is trusted. */
|
||||
const val MIN_SAMPLES = 4
|
||||
|
||||
/** Minimum span between oldest and newest sample. Rejects bursts of rapid 1% ticks. */
|
||||
const val MIN_SPAN_MS = 3 * 60_000L
|
||||
|
||||
/** Minimum total drop across the window. Rejects noise that isn't a real discharge. */
|
||||
const val MIN_TOTAL_DROP = 0.03f
|
||||
|
||||
/**
|
||||
* Only samples within this window of the newest one feed the regression. Drops stale points
|
||||
* from before a long gap (out of range / overnight) so a reconnect can't fit a line across
|
||||
* hours of absence.
|
||||
*/
|
||||
const val MAX_SAMPLE_AGE_MS = 2 * 60 * 60_000L
|
||||
|
||||
/** Plausible drain-rate band (fraction/hour). Outside this, the estimate is rejected. */
|
||||
const val RATE_MIN = 0.02f
|
||||
const val RATE_MAX = 0.80f
|
||||
|
||||
/**
|
||||
* Charge fits get by with fewer samples than drain fits: a charge session is short (well under
|
||||
* an hour) and BLE's 10% steps would otherwise need most of the session before a fit exists.
|
||||
*/
|
||||
const val MIN_SAMPLES_CHARGE = 3
|
||||
|
||||
/** Minimum total rise across the charge window — rejects jitter that isn't a real charge. */
|
||||
const val MIN_TOTAL_RISE = 0.05f
|
||||
|
||||
/** Plausible charge-rate band (fraction/hour): a full charge in 15 minutes .. 4 hours. */
|
||||
const val CHARGE_RATE_MIN = 0.25f
|
||||
const val CHARGE_RATE_MAX = 4.0f
|
||||
|
||||
/**
|
||||
* Above this level the "until charged" estimate is suppressed. The trickle band models the slow
|
||||
* tail, so suppression only covers the last sliver where the firmware is about to flip the
|
||||
* charging flag off at 100% anyway.
|
||||
*/
|
||||
const val NEAR_FULL_SUPPRESS = 0.99f
|
||||
|
||||
/** Narrow bands (10% wide) accept a two-point fit — a full BLE band is exactly two ticks. */
|
||||
const val MIN_SAMPLES_CHARGE_NARROW = 2
|
||||
|
||||
/** Minimum rise WITHIN a band before its fit is trusted (half a narrow band). */
|
||||
const val MIN_BAND_RISE = 0.05f
|
||||
|
||||
/** [chargeStallThresholdMs] never goes below this, however fast the rate claims to be. */
|
||||
const val CHARGE_STALL_FLOOR_MS = 10 * 60_000L
|
||||
|
||||
/** Estimates above this are implausible and suppressed. */
|
||||
const val MAX_MINUTES = 24 * 60
|
||||
|
||||
/**
|
||||
* Smoothing for the displayed minutes. Asymmetric on purpose: react quickly when the estimate
|
||||
* DROPS (less time left — e.g. a degraded battery measured draining faster than its rating, or a
|
||||
* first live fit undercutting the spec seed) so we don't keep showing more life than the latest
|
||||
* reading supports, but ease UP slowly to swallow upward noise and stay conservative.
|
||||
*/
|
||||
const val MINUTES_ALPHA_DOWN = 0.6f
|
||||
const val MINUTES_ALPHA_UP = 0.25f
|
||||
|
||||
/** Smoothing for the persisted per-mode learned rate. */
|
||||
const val LEARN_ALPHA = 0.3f
|
||||
|
||||
/**
|
||||
* Least-squares slope of [samples] (fraction vs. hours) as a positive drain rate in
|
||||
* fraction/hour, or null if there aren't enough samples, the window is too short/small, the
|
||||
* battery isn't actually draining, or the result is outside [RATE_MIN]..[RATE_MAX].
|
||||
*/
|
||||
fun slopeFractionPerHour(samples: List<DrainSample>): Float? {
|
||||
val recent = recentWindow(samples, MIN_SAMPLES) ?: return null
|
||||
if (recent.first().fraction - recent.last().fraction < MIN_TOTAL_DROP) return null
|
||||
|
||||
// Negative slope == draining; flip to a positive drain rate.
|
||||
val rate = regressionSlopePerHour(recent)?.let { -it } ?: return null
|
||||
if (!rate.isFinite() || rate < RATE_MIN || rate > RATE_MAX) return null
|
||||
return rate
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares slope of RISING [samples] (a charging pod) as a positive charge rate in
|
||||
* fraction/hour, with the same window guards as the drain fit but charge-tuned thresholds.
|
||||
*/
|
||||
fun chargeSlopeFractionPerHour(samples: List<DrainSample>): Float? {
|
||||
val recent = recentWindow(samples, MIN_SAMPLES_CHARGE) ?: return null
|
||||
if (recent.last().fraction - recent.first().fraction < MIN_TOTAL_RISE) return null
|
||||
|
||||
val rate = regressionSlopePerHour(recent) ?: return null
|
||||
if (!rate.isFinite() || rate < CHARGE_RATE_MIN || rate > CHARGE_RATE_MAX) return null
|
||||
return rate
|
||||
}
|
||||
|
||||
/**
|
||||
* The regimes of a lithium CC/CV charge. Constant-current bulk is fast and roughly linear;
|
||||
* once the cell hits its voltage ceiling the charger switches to constant-voltage and the
|
||||
* current decays, ending in a slow trickle. One linear rate over-promises badly above ~80%,
|
||||
* so each band learns its own rate.
|
||||
*
|
||||
* Band boundaries and seeds are grounded rather than guessed:
|
||||
* - The 80% bulk boundary is Apple's own documented behavior for their devices ("uses fast
|
||||
* charging to quickly reach 80% ... then switches to slower trickle charging",
|
||||
* apple.com/batteries/why-lithium-ion, mirrored in the iPhone user guide).
|
||||
* - Battery-charging literature places the CC->CV transition at ~75-85% SoC (lower at high
|
||||
* charge currents) and describes the CV current as decaying until cutoff, with the
|
||||
* saturation stage taking a large share of total charge time (Battery University BU-409,
|
||||
* "Charging Lithium-ion").
|
||||
* - Two CV bands, not more: the CV current decays continuously, so finer bands would model
|
||||
* noise while multiplying the state to learn. With the 0.5/0.3 seeds below, the last 20%
|
||||
* takes ~40% of the seeded total charge time — inside BU-409's reported envelope.
|
||||
*
|
||||
* [specMultiplier] scales the spec-derived seed for the band: Apple's quick-charge claims
|
||||
* ("5 minutes = ~1 hour of listening") measure the bulk phase, so seeding the taper/trickle
|
||||
* bands from them needs the CV haircut. Applied ONLY to the spec seed — measured or learned
|
||||
* rates already reflect where they were observed.
|
||||
*/
|
||||
enum class ChargeBand(val from: Float, val to: Float, val specMultiplier: Float) {
|
||||
BULK(0.0f, 0.8f, 1.0f),
|
||||
TAPER(0.8f, 0.9f, 0.5f),
|
||||
TRICKLE(0.9f, 1.0f, 0.3f),
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares charge rate fitted ONLY to the recent samples inside [band], or null when the
|
||||
* band lacks coverage. Narrow bands accept two points (a full BLE band is exactly two ticks);
|
||||
* the wide bulk band keeps the regular sample requirement.
|
||||
*/
|
||||
fun chargeBandSlopeFractionPerHour(samples: List<DrainSample>, band: ChargeBand): Float? {
|
||||
val newestMs = samples.lastOrNull()?.atElapsedMs ?: return null
|
||||
val recent = samples.filter {
|
||||
newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS && it.fraction in band.from..band.to
|
||||
}
|
||||
val minSamples = if (band == ChargeBand.BULK) MIN_SAMPLES_CHARGE else MIN_SAMPLES_CHARGE_NARROW
|
||||
if (recent.size < minSamples) return null
|
||||
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
|
||||
if (recent.last().fraction - recent.first().fraction < MIN_BAND_RISE) return null
|
||||
|
||||
val rate = regressionSlopePerHour(recent) ?: return null
|
||||
// The taper/trickle bands are legitimately slower than any plausible bulk rate.
|
||||
val floor = CHARGE_RATE_MIN * band.specMultiplier
|
||||
return rate.takeIf { it.isFinite() && it >= floor && it <= CHARGE_RATE_MAX }
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [levelFraction] reaches full, walking the remaining [ChargeBand]s at
|
||||
* [rateForBand]'s per-band rates (partial current band + all bands above it). Null when the
|
||||
* level is already in the suppression sliver ([NEAR_FULL_SUPPRESS]), any needed band has no
|
||||
* usable rate, or the result is implausible.
|
||||
*/
|
||||
fun minutesUntilFull(levelFraction: Float, rateForBand: (ChargeBand) -> Float?): Int? {
|
||||
if (!levelFraction.isFinite() || levelFraction < 0f) return null
|
||||
if (levelFraction >= NEAR_FULL_SUPPRESS) return null
|
||||
|
||||
var totalMinutes = 0.0
|
||||
for (band in ChargeBand.entries) {
|
||||
val start = maxOf(levelFraction, band.from)
|
||||
val missing = band.to - start
|
||||
if (missing <= 0f) continue
|
||||
val rate = rateForBand(band) ?: return null
|
||||
if (rate <= 0f) return null
|
||||
totalMinutes += missing / rate * 60.0
|
||||
}
|
||||
return totalMinutes.roundToInt().takeIf { it in 1..MAX_MINUTES }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the level may sit unchanged while charging before the "until charged" estimate is
|
||||
* considered stalled (Optimized Battery Charging hold, trickle phase, or a dead reading) and
|
||||
* suppressed. Granularity-aware: at [chargeFractionPerHour] a single visible step of
|
||||
* [stepFraction] (1% on AAP, 10% on BLE) takes `step/rate` hours — a slow BLE charge legitimately
|
||||
* shows no change for ~20 minutes, so a fixed timeout would falsely suppress it.
|
||||
*/
|
||||
fun chargeStallThresholdMs(chargeFractionPerHour: Float, stepFraction: Float): Long {
|
||||
if (chargeFractionPerHour <= 0f) return CHARGE_STALL_FLOOR_MS
|
||||
val stepMs = (stepFraction.toDouble() / chargeFractionPerHour * 3_600_000).roundToLong()
|
||||
return maxOf(CHARGE_STALL_FLOOR_MS, stepMs * 3 / 2)
|
||||
}
|
||||
|
||||
/** Samples within [MAX_SAMPLE_AGE_MS] of the newest, or null when count/span guards fail. */
|
||||
private fun recentWindow(samples: List<DrainSample>, minSamples: Int): List<DrainSample>? {
|
||||
if (samples.size < minSamples) return null
|
||||
|
||||
// Restrict to the recent window so a gap before the newest sample can't span the fit.
|
||||
val newestMs = samples.last().atElapsedMs
|
||||
val recent = samples.filter { newestMs - it.atElapsedMs <= MAX_SAMPLE_AGE_MS }
|
||||
if (recent.size < minSamples) return null
|
||||
if (recent.last().atElapsedMs - recent.first().atElapsedMs < MIN_SPAN_MS) return null
|
||||
return recent
|
||||
}
|
||||
|
||||
/** Signed least-squares slope (fraction per hour) over [recent]; negative when draining. */
|
||||
private fun regressionSlopePerHour(recent: List<DrainSample>): Float? {
|
||||
val first = recent.first()
|
||||
val n = recent.size.toDouble()
|
||||
var sumX = 0.0
|
||||
var sumY = 0.0
|
||||
var sumXY = 0.0
|
||||
var sumXX = 0.0
|
||||
for (s in recent) {
|
||||
val x = (s.atElapsedMs - first.atElapsedMs) / 3_600_000.0 // hours since first
|
||||
val y = s.fraction.toDouble()
|
||||
sumX += x
|
||||
sumY += y
|
||||
sumXY += x * y
|
||||
sumXX += x * x
|
||||
}
|
||||
val denominator = n * sumXX - sumX * sumX
|
||||
if (abs(denominator) < 1e-9) return null
|
||||
return ((n * sumXY - sumX * sumY) / denominator).toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes until [levelFraction] reaches empty at [fractionPerHour], or null if the rate is
|
||||
* non-positive or the result is implausible (<=0 or above [MAX_MINUTES]).
|
||||
*/
|
||||
fun minutesRemaining(levelFraction: Float, fractionPerHour: Float): Int? {
|
||||
if (fractionPerHour <= 0f || !levelFraction.isFinite() || levelFraction <= 0f) return null
|
||||
val minutes = (levelFraction / fractionPerHour * 60.0).roundToInt()
|
||||
return minutes.takeIf { it in 1..MAX_MINUTES }
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential moving average over the displayed minutes, to avoid a jumpy number. Uses the
|
||||
* asymmetric [MINUTES_ALPHA_DOWN]/[MINUTES_ALPHA_UP] factors by default; pass an explicit [alpha]
|
||||
* to force a symmetric factor (used by tests).
|
||||
*/
|
||||
fun blendMinutes(previous: Int?, next: Int, alpha: Float? = null): Int {
|
||||
if (previous == null) return next
|
||||
val a = alpha ?: if (next < previous) MINUTES_ALPHA_DOWN else MINUTES_ALPHA_UP
|
||||
return (next * a + previous * (1f - a)).roundToInt()
|
||||
}
|
||||
|
||||
/** Exponential moving average over the persisted learned rate across sessions. */
|
||||
fun blendRate(previous: Float?, next: Float, alpha: Float = LEARN_ALPHA): Float =
|
||||
if (previous == null) next else next * alpha + previous * (1f - alpha)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.common.serialization.InstantEpochMillisSerializer
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Persisted drain-rate knowledge for a single device profile, keyed per ANC-mode bucket AND per pod
|
||||
* — map key is `"<bucket>/<slot>"`, e.g. `"ON/LEFT"`, `"UNKNOWN/HEADSET"`. Drain differs sharply by
|
||||
* both mode and pod (the mic pod drains faster), so each is learned independently. Seeds the
|
||||
* time-remaining estimate immediately on reconnect, before the live session has enough samples.
|
||||
*
|
||||
* [chargeRates] is the charging counterpart, keyed per slot only (`"LEFT"` / `"RIGHT"` /
|
||||
* `"HEADSET"`) — the ANC mode is irrelevant while a pod sits in the case.
|
||||
*
|
||||
* [model] tags which [PodModel] the rates were learned on, so a profile re-assigned to different
|
||||
* hardware doesn't inherit the old device's rates (see [matchesModel]).
|
||||
*/
|
||||
@Serializable
|
||||
data class DrainProfile(
|
||||
@SerialName("model") val model: String? = null,
|
||||
@SerialName("rates") val rates: Map<String, LearnedRate> = emptyMap(),
|
||||
@SerialName("chargeRates") val chargeRates: Map<String, LearnedRate> = emptyMap(),
|
||||
/**
|
||||
* Per-band charge rates, `"<slot>" -> "<band>" -> rate` with band names from
|
||||
* [DrainModel.ChargeBand]. Charging is nonlinear (fast bulk, slow taper/trickle), so each
|
||||
* regime learns its own rate; [chargeRates] stays as the whole-session scalar fallback.
|
||||
*/
|
||||
@SerialName("chargeBands") val chargeBands: Map<String, Map<String, LearnedRate>> = emptyMap(),
|
||||
/**
|
||||
* Drain rates learned ONLY while the pod was worn and audio was actually playing on this
|
||||
* device — same `"<bucket>/<slot>"` keys as [rates]. Apple's battery ratings are listening
|
||||
* figures, so the battery-health estimate compares against these; the general [rates]
|
||||
* (which include idle wear) keep powering the time-remaining estimate.
|
||||
*/
|
||||
@SerialName("listeningRates") val listeningRates: Map<String, LearnedRate> = emptyMap(),
|
||||
) {
|
||||
@Serializable
|
||||
data class LearnedRate(
|
||||
/** Drain (or charge) rate in fraction/hour (e.g. 0.169 = 16.9 %/hr). */
|
||||
@SerialName("fractionPerHour") val fractionPerHour: Float,
|
||||
@SerialName("sampleCount") val sampleCount: Int,
|
||||
/**
|
||||
* How many distinct sessions have blended into this rate. [sampleCount] is only the window
|
||||
* size at the LAST save, so this is the actual accumulated-evidence signal (used e.g. to
|
||||
* gate the derived battery-health figure).
|
||||
*/
|
||||
@SerialName("updateCount") val updateCount: Int = 1,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("updatedAt") val updatedAt: Instant,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether these learned rates apply to [model]. An untagged profile or an UNKNOWN model on
|
||||
* either side is treated as matching — only a definite known-A vs known-B mismatch (the user
|
||||
* re-pointed the profile at different hardware) disqualifies the data.
|
||||
*/
|
||||
fun matchesModel(model: PodModel): Boolean =
|
||||
this.model == null ||
|
||||
model == PodModel.UNKNOWN ||
|
||||
this.model == PodModel.UNKNOWN.name ||
|
||||
this.model == model.name
|
||||
|
||||
companion object {
|
||||
/** Bucket key for rates learned while the ANC mode wasn't known (BLE-only sessions). */
|
||||
const val BUCKET_UNKNOWN = "UNKNOWN"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.bluetooth.onlyNewAndUnique
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -52,7 +52,6 @@ class BlePodMonitor @Inject constructor(
|
||||
private val timeSource: TimeSource,
|
||||
private val generalSettings: GeneralSettings,
|
||||
bluetoothManager: BluetoothManager2,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val permissionTool: PermissionTool,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
@@ -60,6 +59,62 @@ class BlePodMonitor @Inject constructor(
|
||||
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
|
||||
private val cacheLock = Mutex()
|
||||
|
||||
/**
|
||||
* Drops all cached observations. The troubleshooter calls this between probe attempts so a
|
||||
* previous compat combo's cached devices (kept up to [STALE_DEVICE_TIMEOUT]) can't leak into
|
||||
* the next attempt and falsely satisfy it — including via [preferCaseContextPod], which would
|
||||
* otherwise hand back a stale snapshot whose timestamp predates the fresh scan.
|
||||
*/
|
||||
suspend fun clearDeviceCache() = cacheLock.withLock {
|
||||
log(TAG) { "clearDeviceCache()" }
|
||||
deviceCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral override that disables the proximity-pairing scan filter so
|
||||
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
|
||||
* every process start; the troubleshooter is the only writer.
|
||||
*/
|
||||
private val unfilteredOverride = MutableStateFlow(false)
|
||||
|
||||
fun setUnfilteredOverride(enabled: Boolean) {
|
||||
unfilteredOverride.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral override for the three BLE compatibility options. The troubleshooter uses this to
|
||||
* probe combinations without writing the user's persisted settings: while set, it fully replaces
|
||||
* the persisted compat values for the active scan. Resets to null on every process start; the
|
||||
* troubleshooter is the only writer and always clears it when finished, so clearing it restores
|
||||
* the user's original settings for free.
|
||||
*/
|
||||
private val compatOverride = MutableStateFlow<CompatOverride?>(null)
|
||||
|
||||
fun setCompatOverride(override: CompatOverride?) {
|
||||
log(TAG) { "setCompatOverride($override)" }
|
||||
compatOverride.value = override
|
||||
}
|
||||
|
||||
data class CompatOverride(
|
||||
val offloadedFilteringDisabled: Boolean,
|
||||
val offloadedBatchingDisabled: Boolean,
|
||||
val indirectCallback: Boolean,
|
||||
)
|
||||
|
||||
/** Persisted compat settings, transparently replaced by [compatOverride] while it is set. */
|
||||
private val effectiveCompat: Flow<CompatOverride> = combine(
|
||||
compatOverride,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.useIndirectScanResultCallback.flow,
|
||||
) { override, filteringDisabled, batchingDisabled, indirectCallback ->
|
||||
override ?: CompatOverride(
|
||||
offloadedFilteringDisabled = filteringDisabled,
|
||||
offloadedBatchingDisabled = batchingDisabled,
|
||||
indirectCallback = indirectCallback,
|
||||
)
|
||||
}
|
||||
|
||||
val devices: Flow<List<BlePodSnapshot>> = combine(
|
||||
permissionTool.missingScanPermissions,
|
||||
bluetoothManager.isBluetoothEnabled
|
||||
@@ -134,23 +189,15 @@ class BlePodMonitor @Inject constructor(
|
||||
|
||||
private fun createBleScanner() = combine(
|
||||
bleScanModeController.scannerMode,
|
||||
debugSettings.showUnfiltered.flow,
|
||||
generalSettings.isOffloadedBatchingDisabled.flow,
|
||||
generalSettings.isOffloadedFilteringDisabled.flow,
|
||||
generalSettings.useIndirectScanResultCallback.flow,
|
||||
) {
|
||||
scannermode,
|
||||
showUnfiltered,
|
||||
isOffloadedBatchingDisabled,
|
||||
isOffloadedFilteringDisabled,
|
||||
useIndirectScanResultCallback,
|
||||
->
|
||||
unfilteredOverride,
|
||||
effectiveCompat,
|
||||
) { scannermode, showUnfiltered, compat ->
|
||||
ScannerOptions(
|
||||
scannerMode = scannermode,
|
||||
showUnfiltered = showUnfiltered,
|
||||
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
|
||||
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
|
||||
disableDirectCallback = useIndirectScanResultCallback,
|
||||
offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
|
||||
offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
|
||||
disableDirectCallback = compat.indirectCallback,
|
||||
)
|
||||
}
|
||||
.throttleLatest(1000)
|
||||
|
||||
@@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.Bugs
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
@@ -30,9 +29,13 @@ import eu.darken.capod.main.core.MonitorMode
|
||||
import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.MonitorCoroutineScope
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimator
|
||||
import eu.darken.capod.monitor.core.battery.displayKey
|
||||
import eu.darken.capod.monitor.core.battery.estimateFor
|
||||
import eu.darken.capod.monitor.core.MonitorModeResolver
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.ble.BlePodMonitor
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
@@ -42,6 +45,9 @@ import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationReaction
|
||||
import eu.darken.capod.reaction.core.charged.ChargedReaction
|
||||
import eu.darken.capod.reaction.core.charged.ChargedReactionNotifications
|
||||
import eu.darken.capod.reaction.core.sleep.SleepReaction
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpWindow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -78,9 +84,14 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var autoConnect: AutoConnect
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var sleepReaction: SleepReaction
|
||||
@Inject lateinit var chargedReaction: ChargedReaction
|
||||
@Inject lateinit var chargedReactionNotifications: ChargedReactionNotifications
|
||||
@Inject lateinit var conversationReaction: ConversationReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapConnectionManager: AapConnectionManager
|
||||
@Inject lateinit var monitorModeResolver: MonitorModeResolver
|
||||
@Inject lateinit var batteryEstimator: BatteryEstimator
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -88,6 +99,10 @@ class MonitorService : Service() {
|
||||
private var foregroundStartFailed = false
|
||||
private var injectionComplete = false
|
||||
|
||||
@Volatile
|
||||
private var latestNotificationSettings: NotificationSettings =
|
||||
NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false)
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun promoteToForeground(notification: Notification): Boolean {
|
||||
return try {
|
||||
@@ -188,27 +203,71 @@ class MonitorService : Service() {
|
||||
}
|
||||
|
||||
private suspend fun doMonitor() {
|
||||
// Seed snapshot eagerly so onDestroy() has correct values even if we abort below.
|
||||
latestNotificationSettings = NotificationSettings(
|
||||
useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(),
|
||||
keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(),
|
||||
)
|
||||
|
||||
val permissionsMissingOnStart = permissionTool.missingScanPermissions.first()
|
||||
if (permissionsMissingOnStart.isNotEmpty()) {
|
||||
log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" }
|
||||
return
|
||||
}
|
||||
|
||||
val monitorJob = deviceMonitor.primaryDevice()
|
||||
val deviceFlow = deviceMonitor.primaryDeviceByTier
|
||||
.setupCommonEventHandlers(TAG) { "BlePodMonitor" }
|
||||
.distinctUntilChangedBy { it?.toNotificationKey() }
|
||||
.throttleLatest(1000)
|
||||
.onEach { currentDevice ->
|
||||
val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking
|
||||
|
||||
val notificationSettingsFlow = combine(
|
||||
generalSettings.useExtraMonitorNotification.flow,
|
||||
generalSettings.keepConnectedNotificationAfterDisconnect.flow,
|
||||
) { useExtra, keepAfter ->
|
||||
NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter)
|
||||
}
|
||||
|
||||
val monitorJob = combine(
|
||||
deviceFlow,
|
||||
notificationSettingsFlow,
|
||||
batteryEstimator.estimates,
|
||||
) { currentDevice, settings, estimates ->
|
||||
NotificationInput(
|
||||
device = currentDevice,
|
||||
settings = settings,
|
||||
estimate = currentDevice?.let { estimates.estimateFor(it) },
|
||||
)
|
||||
}
|
||||
// The estimates map churns on fields the notification doesn't display (and for other
|
||||
// profiles' devices) — only re-notify when something visible changed.
|
||||
.distinctUntilChangedBy { input ->
|
||||
Triple(
|
||||
input.device?.toNotificationKey(),
|
||||
input.settings,
|
||||
input.device?.let { input.estimate?.displayKey(it) },
|
||||
)
|
||||
}
|
||||
.onEach { (currentDevice, settings, estimate) ->
|
||||
latestNotificationSettings = settings
|
||||
|
||||
notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID,
|
||||
notifications.getNotification(currentDevice, showHint = useExtraNotification),
|
||||
notifications.getNotification(
|
||||
currentDevice,
|
||||
estimate = estimate,
|
||||
showHint = settings.useExtraNotification,
|
||||
),
|
||||
)
|
||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) {
|
||||
notificationManager.notify(
|
||||
|
||||
when (val action = decideExtraNotificationAction(currentDevice, settings)) {
|
||||
is ExtraNotificationAction.Post -> notificationManager.notify(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED,
|
||||
notifications.getNotificationConnected(currentDevice),
|
||||
notifications.getNotificationConnected(action.device, estimate),
|
||||
)
|
||||
ExtraNotificationAction.Cancel -> notificationManager.cancel(
|
||||
MonitorNotifications.NOTIFICATION_ID_CONNECTED
|
||||
)
|
||||
ExtraNotificationAction.KeepExisting -> Unit
|
||||
}
|
||||
}
|
||||
.catch {
|
||||
@@ -224,7 +283,7 @@ class MonitorService : Service() {
|
||||
emptyFlow()
|
||||
} else {
|
||||
combine(
|
||||
generalSettings.monitorMode.flow,
|
||||
monitorModeResolver.effectiveMode,
|
||||
profilesRepo.profiles,
|
||||
bluetoothManager.connectedDevices,
|
||||
aapConnectionManager.allStates,
|
||||
@@ -300,6 +359,33 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "sleepReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
chargedReaction.monitor()
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChargedReaction.Event.ShowNotification -> chargedReactionNotifications.show(
|
||||
profileId = event.profileId,
|
||||
deviceLabel = event.deviceLabel,
|
||||
thresholdPercent = event.thresholdPercent,
|
||||
)
|
||||
|
||||
is ChargedReaction.Event.CancelNotification ->
|
||||
chargedReactionNotifications.cancel(event.profileId)
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "chargedReaction" }
|
||||
.catch { log(TAG, WARN) { "chargedReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
conversationReaction.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||
.catch { log(TAG, WARN) { "conversationReaction failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
batteryEstimator.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "batteryEstimator" }
|
||||
.catch { log(TAG, WARN) { "batteryEstimator failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
@@ -310,7 +396,13 @@ class MonitorService : Service() {
|
||||
monitorScope.cancel("Service destroyed")
|
||||
|
||||
if (injectionComplete) {
|
||||
if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) {
|
||||
try {
|
||||
chargedReactionNotifications.cancelAll()
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "Failed to cancel charged notifications: ${e.message}" }
|
||||
}
|
||||
val snapshot = latestNotificationSettings
|
||||
if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) {
|
||||
try {
|
||||
notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED)
|
||||
} catch (e: Exception) {
|
||||
@@ -359,10 +451,17 @@ internal fun buildMonitorModeState(
|
||||
hasAapSession = aapStates.isNotEmpty(),
|
||||
)
|
||||
|
||||
internal data class NotificationInput(
|
||||
val device: PodDevice?,
|
||||
val settings: NotificationSettings,
|
||||
val estimate: BatteryEstimate?,
|
||||
)
|
||||
|
||||
private data class NotificationDeviceKey(
|
||||
val profileId: String?,
|
||||
val label: String?,
|
||||
val model: PodModel,
|
||||
val isLive: Boolean,
|
||||
val hasDualPods: Boolean,
|
||||
val hasCase: Boolean,
|
||||
val hasEarDetection: Boolean,
|
||||
@@ -377,6 +476,7 @@ private data class NotificationDeviceKey(
|
||||
val isLeftInEar: Boolean?,
|
||||
val isRightInEar: Boolean?,
|
||||
val isBeingWorn: Boolean?,
|
||||
val batteryEstimateEnabled: Boolean,
|
||||
val iconRes: Int,
|
||||
val leftPodIcon: Int,
|
||||
val rightPodIcon: Int,
|
||||
@@ -387,6 +487,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
||||
profileId = profileId,
|
||||
label = label,
|
||||
model = model,
|
||||
isLive = isLive,
|
||||
hasDualPods = hasDualPods,
|
||||
hasCase = hasCase,
|
||||
hasEarDetection = hasEarDetection,
|
||||
@@ -401,8 +502,30 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isBeingWorn = isBeingWorn,
|
||||
batteryEstimateEnabled = batteryEstimateEnabled,
|
||||
iconRes = iconRes,
|
||||
leftPodIcon = leftPodIcon,
|
||||
rightPodIcon = rightPodIcon,
|
||||
caseIcon = caseIcon,
|
||||
)
|
||||
|
||||
internal data class NotificationSettings(
|
||||
val useExtraNotification: Boolean,
|
||||
val keepAfterDisconnect: Boolean,
|
||||
)
|
||||
|
||||
internal sealed interface ExtraNotificationAction {
|
||||
object Cancel : ExtraNotificationAction
|
||||
data class Post(val device: PodDevice) : ExtraNotificationAction
|
||||
object KeepExisting : ExtraNotificationAction
|
||||
}
|
||||
|
||||
internal fun decideExtraNotificationAction(
|
||||
device: PodDevice?,
|
||||
settings: NotificationSettings,
|
||||
): ExtraNotificationAction = when {
|
||||
!settings.useExtraNotification -> ExtraNotificationAction.Cancel
|
||||
device != null && device.isLive -> ExtraNotificationAction.Post(device)
|
||||
settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting
|
||||
else -> ExtraNotificationAction.Cancel
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import android.widget.RemoteViews
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.displayMinutes
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryDurationShort
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
|
||||
import eu.darken.capod.pods.core.apple.ble.isKnownBattery
|
||||
@@ -68,12 +71,10 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUnknownDevice(device: PodDevice): RemoteViews = RemoteViews(
|
||||
@@ -83,13 +84,13 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.device, device.getLabel(context))
|
||||
}
|
||||
|
||||
fun createBigContentView(device: PodDevice): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device)
|
||||
fun createBigContentView(device: PodDevice, estimate: BatteryEstimate? = null): RemoteViews = when {
|
||||
device.hasDualPods -> createDualPodsBig(device, estimate)
|
||||
device.model != PodModel.UNKNOWN -> createSinglePodBig(device, estimate)
|
||||
else -> createUnknownDeviceBig(device)
|
||||
}
|
||||
|
||||
private fun createDualPodsBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
private fun createDualPodsBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_dual_pods_big
|
||||
).apply {
|
||||
@@ -97,7 +98,11 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
val leftPercent = device.batteryLeft
|
||||
setImageViewResource(R.id.pod_left_icon, device.leftPodIcon)
|
||||
setProgressBar(R.id.pod_left_progress, 100, percentToInt(leftPercent), false)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
setTextViewText(
|
||||
R.id.pod_left_label,
|
||||
formatBatteryPercent(context, leftPercent) +
|
||||
estimateSuffix(estimate?.left, device.isLeftPodCharging == true)
|
||||
)
|
||||
val isLeftPodCharging = device.isLeftPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_left_charging, if (isLeftPodCharging) View.VISIBLE else View.GONE)
|
||||
val isLeftPodInEar = device.isLeftInEar ?: false
|
||||
@@ -117,14 +122,18 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
val rightPercent = device.batteryRight
|
||||
setImageViewResource(R.id.pod_right_icon, device.rightPodIcon)
|
||||
setProgressBar(R.id.pod_right_progress, 100, percentToInt(rightPercent), false)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
setTextViewText(
|
||||
R.id.pod_right_label,
|
||||
formatBatteryPercent(context, rightPercent) +
|
||||
estimateSuffix(estimate?.right, device.isRightPodCharging == true)
|
||||
)
|
||||
val isRightPodCharging = device.isRightPodCharging ?: false
|
||||
setViewVisibility(R.id.pod_right_charging, if (isRightPodCharging) View.VISIBLE else View.GONE)
|
||||
val isRightPodInEar = device.isRightInEar ?: false
|
||||
setViewVisibility(R.id.pod_right_ear, if (isRightPodInEar) View.VISIBLE else View.GONE)
|
||||
}
|
||||
|
||||
private fun createSinglePodBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
private fun createSinglePodBig(device: PodDevice, estimate: BatteryEstimate?): RemoteViews = RemoteViews(
|
||||
context.packageName,
|
||||
R.layout.monitor_notification_single_pods_big
|
||||
).apply {
|
||||
@@ -132,16 +141,18 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
setTextViewText(R.id.headphones_label, device.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, device.iconRes)
|
||||
setProgressBar(R.id.headphones_battery_progress, 100, percentToInt(headsetPercent), false)
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
setTextViewText(
|
||||
R.id.headphones_battery_label,
|
||||
formatBatteryPercent(context, headsetPercent) +
|
||||
estimateSuffix(estimate?.headset, device.isHeadsetBeingCharged == true)
|
||||
)
|
||||
if (device.hasEarDetection) {
|
||||
setViewVisibility(R.id.headphones_worn, if (device.isBeingWorn == true) View.VISIBLE else View.GONE)
|
||||
}
|
||||
if (device.isHeadsetBeingCharged != null) {
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (device.isHeadsetBeingCharged == true) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUnknownDeviceBig(device: PodDevice): RemoteViews = RemoteViews(
|
||||
@@ -154,4 +165,7 @@ class MonitorNotificationViewFactory @Inject constructor(
|
||||
private fun percentToInt(percent: Float): Int =
|
||||
if (isKnownBattery(percent)) (percent * 100).roundToInt().coerceIn(0, 100) else 0
|
||||
|
||||
private fun estimateSuffix(pod: BatteryEstimate.Pod?, charging: Boolean): String =
|
||||
pod?.displayMinutes(charging)?.let { " · ${formatBatteryDurationShort(context, it)}" } ?: ""
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.notifications.PendingIntentCompat
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
|
||||
import javax.inject.Inject
|
||||
@@ -56,7 +57,8 @@ class MonitorNotifications @Inject constructor(
|
||||
private fun getBuilder(
|
||||
device: PodDevice?,
|
||||
channelId: String,
|
||||
showHint: Boolean = false
|
||||
estimate: BatteryEstimate? = null,
|
||||
showHint: Boolean = false,
|
||||
): NotificationCompat.Builder {
|
||||
if (device == null) {
|
||||
return baseBuilder(channelId).apply {
|
||||
@@ -119,18 +121,21 @@ class MonitorNotifications @Inject constructor(
|
||||
|
||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||
setCustomContentView(notificationViewFactory.createContentView(device))
|
||||
setCustomBigContentView(notificationViewFactory.createBigContentView(device))
|
||||
setCustomBigContentView(notificationViewFactory.createBigContentView(device, estimate))
|
||||
setContentTitle("$batteryText ~ $stateText")
|
||||
setSubText(null)
|
||||
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotification(podDevice: PodDevice?, showHint: Boolean = false): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, showHint).build()
|
||||
fun getNotification(
|
||||
podDevice: PodDevice?,
|
||||
estimate: BatteryEstimate? = null,
|
||||
showHint: Boolean = false,
|
||||
): Notification = getBuilder(podDevice, NOTIFICATION_CHANNEL_ID, estimate, showHint).build()
|
||||
|
||||
fun getNotificationConnected(podDevice: PodDevice?): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
|
||||
fun getNotificationConnected(podDevice: PodDevice?, estimate: BatteryEstimate? = null): Notification =
|
||||
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED, estimate).build()
|
||||
|
||||
fun getStartupNotification(): Notification =
|
||||
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
|
||||
|
||||
@@ -14,6 +14,7 @@ enum class PodModel(
|
||||
@DrawableRes val leftPodIconRes: Int? = null,
|
||||
@DrawableRes val rightPodIconRes: Int? = null,
|
||||
@DrawableRes val caseIconRes: Int? = null,
|
||||
val batterySpec: BatterySpec? = null,
|
||||
) {
|
||||
@SerialName("airpods.gen1")
|
||||
AIRPODS_GEN1(
|
||||
@@ -26,6 +27,7 @@ enum class PodModel(
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -43,6 +45,7 @@ enum class PodModel(
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen1_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen1_right,
|
||||
@@ -64,6 +67,7 @@ enum class PodModel(
|
||||
hasMicrophoneMode = true,
|
||||
hasEarDetectionToggle = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOff = 6f, chargeFractionPerHour = 2.0f),
|
||||
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
@@ -86,6 +90,7 @@ enum class PodModel(
|
||||
hasEarDetectionToggle = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen3_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen3_right,
|
||||
@@ -117,6 +122,7 @@ enum class PodModel(
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 4f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
|
||||
@@ -142,6 +148,7 @@ enum class PodModel(
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 4.5f, listeningHoursAncOff = 5f, chargeFractionPerHour = 2.4f),
|
||||
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
@@ -175,6 +182,7 @@ enum class PodModel(
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
|
||||
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
@@ -208,6 +216,7 @@ enum class PodModel(
|
||||
hasStemConfig = true,
|
||||
hasSleepDetection = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 6f, chargeFractionPerHour = 2.0f),
|
||||
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
@@ -242,6 +251,7 @@ enum class PodModel(
|
||||
hasSleepDetection = true,
|
||||
hasDynamicEndOfCharge = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 8f, chargeFractionPerHour = 1.5f),
|
||||
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||
leftPodIconRes = R.drawable.device_airpods_pro2_left,
|
||||
rightPodIconRes = R.drawable.device_airpods_pro2_right,
|
||||
@@ -262,6 +272,7 @@ enum class PodModel(
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||
modelNumbers = setOf("A2096"), // headphones
|
||||
),
|
||||
|
||||
@@ -279,6 +290,7 @@ enum class PodModel(
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||
modelNumbers = setOf("A3184"), // headphones
|
||||
),
|
||||
|
||||
@@ -300,6 +312,7 @@ enum class PodModel(
|
||||
hasListeningModeCycle = true,
|
||||
hasAllowOffOption = true,
|
||||
),
|
||||
batterySpec = BatterySpec(listeningHoursAncOn = 20f, chargeFractionPerHour = 0.9f),
|
||||
modelNumbers = setOf("A3454"), // headphones
|
||||
),
|
||||
|
||||
@@ -580,4 +593,27 @@ enum class PodModel(
|
||||
*/
|
||||
val hasDynamicEndOfCharge: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Apple's published single-charge listening-time ratings, in hours, used to seed the battery
|
||||
* time-remaining estimate before observed drain is available and as an upper bound on it. These
|
||||
* are the POD-ONLY listening figures from Apple's tech-spec pages — NOT the "with charging case"
|
||||
* aggregate. Left null for models Apple doesn't publish figures for (Beats, unknown), which simply
|
||||
* fall back to purely observed drain. Figures verified against apple.com tech specs (2026-07).
|
||||
*/
|
||||
data class BatterySpec(
|
||||
/** Listening hours with Active Noise Cancellation / Transparency active. */
|
||||
val listeningHoursAncOn: Float? = null,
|
||||
/** Listening hours with noise control off, when Apple publishes a distinct figure; else null. */
|
||||
val listeningHoursAncOff: Float? = null,
|
||||
/**
|
||||
* Nominal charge rate (battery fraction gained per hour of charging), derived from Apple's
|
||||
* published quick-charge claim: e.g. "5 minutes in the case provides around 1 hour of
|
||||
* listening time" against a 6 h rating is (1/6) / (5/60) = 2.0/hr. Where the model has two
|
||||
* listening ratings it's normalized against the LONGER one, so the seeded "until charged"
|
||||
* never under-promises. Seeds the charge ETA on the very first charge, before any charge
|
||||
* rate has been measured; a live fit takes over within minutes.
|
||||
*/
|
||||
val chargeFractionPerHour: Float? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -53,8 +54,8 @@ class AapConnectionManager @Inject constructor(
|
||||
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
|
||||
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
|
||||
|
||||
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
|
||||
private val _disconnectEvents = MutableSharedFlow<AapDisconnectEvent>(extraBufferCapacity = 16)
|
||||
val disconnectEvents: SharedFlow<AapDisconnectEvent> = _disconnectEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
|
||||
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
|
||||
@@ -73,6 +74,17 @@ class AapConnectionManager @Inject constructor(
|
||||
private val _sleepEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val sleepEvents: SharedFlow<BluetoothAddress> = _sleepEvents.asSharedFlow()
|
||||
|
||||
/**
|
||||
* Emits when a connected device reports a Conversational Awareness transition (START / RESUME /
|
||||
* HOLD / STOP, AAP command 0x4B). Paired with the origin address so the conversation reaction
|
||||
* can gate on the primary device. Every well-formed frame is classified and emitted (unknown
|
||||
* status bytes map to HOLD); only structurally malformed frames are dropped (see [AapSessionEngine]).
|
||||
*/
|
||||
private val _conversationalAwarenessEvents =
|
||||
MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>(extraBufferCapacity = 16)
|
||||
val conversationalAwarenessEvents: SharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>> =
|
||||
_conversationalAwarenessEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a SetAncMode(OFF) command was rejected by the device (inferred by the engine). */
|
||||
private val _offRejectedEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val offRejectedEvents: SharedFlow<BluetoothAddress> = _offRejectedEvents.asSharedFlow()
|
||||
@@ -140,6 +152,13 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Forward conversational-awareness speaking transitions from this connection (child coroutine).
|
||||
launch {
|
||||
connection.conversationalAwarenessEvents.collect { event ->
|
||||
_conversationalAwarenessEvents.tryEmit(address to event)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward OFF-rejection events from this connection (child coroutine)
|
||||
launch {
|
||||
connection.offRejected.collect {
|
||||
@@ -169,7 +188,7 @@ class AapConnectionManager @Inject constructor(
|
||||
}
|
||||
|
||||
if (!wasIntentional) {
|
||||
_disconnectEvents.tryEmit(address)
|
||||
_disconnectEvents.tryEmit(AapDisconnectEvent(address, connection.wasEverReady))
|
||||
}
|
||||
|
||||
// End this collector coroutine (also cancels child key-forwarding coroutine)
|
||||
@@ -198,3 +217,13 @@ class AapConnectionManager @Inject constructor(
|
||||
connection.send(command)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted on an unintentional AAP disconnect. [wasEverReady] tells the reconnect layer whether this
|
||||
* session ever completed the handshake (reached READY) — a never-ready disconnect is a failed
|
||||
* handshake/short session and feeds the escalating reconnect backoff; a was-ready drop reconnects promptly.
|
||||
*/
|
||||
data class AapDisconnectEvent(
|
||||
val address: BluetoothAddress,
|
||||
val wasEverReady: Boolean,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -22,12 +23,14 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
@@ -45,19 +48,25 @@ internal class AapConnection(
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
timeSource: TimeSource,
|
||||
private val connectTimeout: Duration = DEFAULT_CONNECT_TIMEOUT,
|
||||
private val handshakeTimeout: Duration = DEFAULT_HANDSHAKE_TIMEOUT,
|
||||
) {
|
||||
|
||||
private val engine = AapSessionEngine(profile, timeSource)
|
||||
|
||||
/** True once this connection's session reached READY at least once. Survives the engine reset. */
|
||||
val wasEverReady: Boolean get() = engine.wasEverReady
|
||||
|
||||
val state: StateFlow<AapPodState> get() = engine.state
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
|
||||
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> get() = engine.sleepEvents
|
||||
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> get() = engine.conversationalAwarenessEvents
|
||||
val offRejected: SharedFlow<Unit> get() = engine.offRejected
|
||||
val settingRejected: SharedFlow<AapCommand> get() = engine.settingRejected
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var readerJob: Job? = null
|
||||
private val disconnected = AtomicBoolean(false)
|
||||
private val writeMutex = Mutex()
|
||||
private val framer = AapFramer()
|
||||
|
||||
@@ -107,6 +116,27 @@ internal class AapConnection(
|
||||
|
||||
// Launch read loop in the provided scope — connect() returns immediately
|
||||
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
||||
|
||||
// Handshake watchdog: the socket can connect and the handshake be sent, but if the peer
|
||||
// never replies the engine sits in HANDSHAKING forever (blocking read, no deadline).
|
||||
// Bound it: if neither READY nor DISCONNECTED is reached in time, tear the socket down so
|
||||
// the reconnect path can recover. READY / an earlier DISCONNECTED end the wait with no action.
|
||||
scope.launch {
|
||||
val settled = withTimeoutOrNull(handshakeTimeout) {
|
||||
state.first {
|
||||
it.connectionState == AapPodState.ConnectionState.READY ||
|
||||
it.connectionState == AapPodState.ConnectionState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
// Re-check after the timeout: READY may have landed in the boundary race between
|
||||
// withTimeoutOrNull cancelling and us getting here — don't tear down a live session.
|
||||
if (settled == null && state.value.connectionState != AapPodState.ConnectionState.READY) {
|
||||
log(TAG, Logging.Priority.WARN) {
|
||||
"Handshake timed out after $handshakeTimeout for ${device.address} — disconnecting"
|
||||
}
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
|
||||
cleanupSocket()
|
||||
@@ -116,6 +146,9 @@ internal class AapConnection(
|
||||
}
|
||||
|
||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||
// Idempotent: the handshake watchdog and the manager's DISCONNECTED observer can both reach
|
||||
// here for the same dying session. Run the teardown exactly once.
|
||||
if (!disconnected.compareAndSet(false, true)) return@withContext
|
||||
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
@@ -259,6 +292,13 @@ internal class AapConnection(
|
||||
companion object {
|
||||
private const val PSM = 0x1001
|
||||
internal val DEFAULT_CONNECT_TIMEOUT = 5.seconds
|
||||
|
||||
/**
|
||||
* Upper bound on the post-handshake wait for the first sign of life (READY). Normal
|
||||
* handshakes complete in well under 2s; 10s tolerates a congested 2.4 GHz band before
|
||||
* giving up so the reconnect path can recover instead of wedging in HANDSHAKING forever.
|
||||
*/
|
||||
internal val DEFAULT_HANDSHAKE_TIMEOUT = 10.seconds
|
||||
private val TAG = logTag("AAP", "Connection")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import eu.darken.capod.pods.core.apple.aap.protocol.AapMessageType
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapPacket
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSleepEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -54,6 +55,11 @@ internal class AapSessionEngine(
|
||||
MutableSharedFlow<AapSleepEvent>(extraBufferCapacity = 4, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val sleepEvents: SharedFlow<AapSleepEvent> = _sleepEvents.asSharedFlow()
|
||||
|
||||
private val _conversationalAwarenessEvents =
|
||||
MutableSharedFlow<ConversationAwarenessEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val conversationalAwarenessEvents: SharedFlow<ConversationAwarenessEvent> =
|
||||
_conversationalAwarenessEvents.asSharedFlow()
|
||||
|
||||
private val _offRejected =
|
||||
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val offRejected: SharedFlow<Unit> = _offRejected.asSharedFlow()
|
||||
@@ -80,6 +86,15 @@ internal class AapSessionEngine(
|
||||
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
|
||||
private var runtimeState = EngineRuntimeState()
|
||||
|
||||
/**
|
||||
* True once this session has reached [AapPodState.ConnectionState.READY] at least once.
|
||||
* Deliberately NOT cleared by [reset] — consumers read it at disconnect time to tell a
|
||||
* working session that dropped apart from one that never completed the handshake (drives
|
||||
* the reconnect backoff). Cleared only when a fresh session starts.
|
||||
*/
|
||||
private var _wasEverReady: Boolean = false
|
||||
val wasEverReady: Boolean get() = _wasEverReady
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
dispatch(AapEngineEvent.SessionStarted(scope))
|
||||
}
|
||||
@@ -124,6 +139,7 @@ internal class AapSessionEngine(
|
||||
when (event) {
|
||||
is AapEngineEvent.SessionStarted -> {
|
||||
scope = event.scope
|
||||
_wasEverReady = false
|
||||
runtimeState = runtimeState.copy(handshakeResponseReceived = false)
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
|
||||
}
|
||||
@@ -153,11 +169,14 @@ internal class AapSessionEngine(
|
||||
private fun handleConnectResponse(packet: AapPacket.ConnectResponse) {
|
||||
if (packet.status != 0) {
|
||||
log(TAG, ERROR) {
|
||||
"ConnectResponse failed: status=0x${"%04X".format(packet.status)} " +
|
||||
"ConnectResponse rejected (status=0x${"%04X".format(packet.status)}) — tearing down: " +
|
||||
"major=${packet.major} minor=${packet.minor} " +
|
||||
"features=0x${"%016X".format(packet.features.toLong())}"
|
||||
}
|
||||
_state.value = _state.value.copy(connectResponseStatus = packet.status)
|
||||
// Hard protocol rejection: don't wait out the handshake watchdog — disconnect now so
|
||||
// the reconnect path can recover (or back off). Nested dispatch mirrors the existing
|
||||
// inbound-update re-dispatch pattern.
|
||||
dispatch(AapEngineEvent.ResetRequested)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,6 +205,7 @@ internal class AapSessionEngine(
|
||||
_state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
|
||||
) {
|
||||
runtimeState = runtimeState.copy(handshakeResponseReceived = true)
|
||||
_wasEverReady = true
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
@@ -301,6 +321,13 @@ internal class AapSessionEngine(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-emit every (well-formed) Conversational Awareness frame as a classified event for the
|
||||
// conversation reaction: START / RESUME / HOLD / STOP. The decoder already dropped malformed
|
||||
// frames (rawValue stays null only in that case). The raw payload remains logged.
|
||||
if (value is AapSetting.ConversationalAwarenessState) {
|
||||
value.rawValue?.let { _conversationalAwarenessEvents.tryEmit(ConversationAwarenessEvent.fromStatus(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTimerFired(key: EngineTimerKey) {
|
||||
|
||||
@@ -99,9 +99,18 @@ sealed class AapSetting {
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
/** Push-only from device — reports speaking detection state (command 0x4B). */
|
||||
/**
|
||||
* Push-only from device — reports speaking detection state (command 0x4B).
|
||||
*
|
||||
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
|
||||
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` while
|
||||
* the wearer is talking — onset (`1`, `2`) or resumed after a pause (`5`); every other value
|
||||
* (`0`, `3`, `4`, `0x0B`, …) is `false`. START/RESUME/HOLD/STOP classification for the reaction
|
||||
* lives in [ConversationAwarenessEvent].
|
||||
*/
|
||||
data class ConversationalAwarenessState(
|
||||
val speaking: Boolean,
|
||||
val rawValue: Int? = null,
|
||||
) : AapSetting()
|
||||
|
||||
data class MicrophoneMode(
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package eu.darken.capod.pods.core.apple.aap.protocol
|
||||
|
||||
/**
|
||||
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
|
||||
*
|
||||
* Status-byte mapping, derived from a labelled capture set across AirPods Pro 3 and Pro 2 USB-C
|
||||
* (`protocol-research/conversationalawareness/` — both models share one firmware train and emit
|
||||
* byte-identical sequences). Each scenario was captured with a known action (normal talking,
|
||||
* bursty talking, single-pod, volume-up abort, pod removal/case):
|
||||
* - `1`, `2` → [START] (conversation onset / wind-up → engage the reaction). A conversation always
|
||||
* opens `1` then `2`; re-onset only ever happens after a terminal.
|
||||
* - `5` → [RESUME] (speech resumed after a pause; the wind-down was aborted, CA stays engaged). In
|
||||
* bursty speech the pod cycles `3,5,3,5,…`; `5` is NEVER a terminal. Misreading `5` as a stop was
|
||||
* the root cause of the premature-resume bug — see [ConversationReaction].
|
||||
* - `6`, `8`, `9` → [STOP] (conversation ended → disengage). The usual terminal is the `8`→`9`
|
||||
* pair; `6` is a standalone terminal seen live on Pro 2 USB-C fw `…6814`. Pod removal / case-close
|
||||
* also emit `8`,`9` (sometimes with no prior `1`,`2`).
|
||||
* - any other value (`3` pause, `4` / `0x0B` wind-down, `7` abort, and anything unrecognised)
|
||||
* → [HOLD]: a transitional "possible/real wind-down" frame. The real wind-down runs `3→0x0B→4`
|
||||
* then the `8,9` terminal; `7` precedes an aborted terminal. Unknown values are deliberately
|
||||
* classified as HOLD (arm the safety fuse, never resume immediately) rather than guessed at.
|
||||
*
|
||||
* The pod sends NO frames during active speech — it stays engaged (and silent) for as long as it
|
||||
* hears nearby voices, 20-30s+ observed. So frame-silence must NOT be read as "speaking ended".
|
||||
* A HOLD means the wind-down may have begun; with only ONE pod worn the `8,9` terminal is
|
||||
* deterministically dropped — the flurry ends on `4` (#608, reproduced on Pro 3 and Pro 2) — so
|
||||
* [ConversationReaction] treats a HOLD as "terminal imminent" and arms a short fuse, with a long
|
||||
* stale backstop for a fully-dropped flurry. A RESUME (`5`) cancels that fuse and re-arms the long
|
||||
* backstop; trailing frames after a terminal are ignored once disengaged.
|
||||
*/
|
||||
enum class ConversationAwarenessEvent {
|
||||
START,
|
||||
RESUME,
|
||||
HOLD,
|
||||
STOP,
|
||||
;
|
||||
|
||||
companion object {
|
||||
val SPEAKING_STATUSES = setOf(1, 2)
|
||||
val RESUME_STATUSES = setOf(5)
|
||||
val STOPPED_STATUSES = setOf(6, 8, 9)
|
||||
|
||||
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
|
||||
in SPEAKING_STATUSES -> START
|
||||
in RESUME_STATUSES -> RESUME
|
||||
in STOPPED_STATUSES -> STOP
|
||||
else -> HOLD
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -183,12 +183,27 @@ class DefaultAapDeviceProfile(
|
||||
return AapSetting.PmeConfig::class to AapSetting.PmeConfig(sets)
|
||||
}
|
||||
|
||||
// Conversation Awareness State is a separate command type (push-only)
|
||||
// Conversation Awareness State is a separate command type (push-only). Two payload shapes
|
||||
// are seen: a legacy single byte, and the 4-byte `02 00 01 <status>` form on Pro 3 fw 81.x.
|
||||
// The status (speaking marker) is the last byte in both — but we validate the shape rather
|
||||
// than blindly taking payload.last(), so a truncated/garbled frame can't be misread as a
|
||||
// speaking transition. Unrecognised shapes return null → logged as an unhandled message.
|
||||
if (message.commandType == AapMessageType.CONVERSATIONAL_AWARENESS.value) {
|
||||
if (message.payload.isEmpty()) return null
|
||||
val value = message.payload[0].toInt() and 0xFF
|
||||
val speaking = value == 0x01
|
||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
||||
val p = message.payload
|
||||
val status = when {
|
||||
p.size == 1 -> p[0].toInt() and 0xFF
|
||||
p.size == 4 &&
|
||||
p[0] == 0x02.toByte() && p[1] == 0x00.toByte() && p[2] == 0x01.toByte() ->
|
||||
p[3].toInt() and 0xFF
|
||||
else -> return null
|
||||
}
|
||||
// speaking = the wearer is currently speaking: onset (1,2) or resumed after a pause (5).
|
||||
// See ConversationAwarenessEvent. (Consumers read rawValue for full classification; this
|
||||
// bool just exposes "is talking now", so a resume must count as speaking too.)
|
||||
val speaking = status in ConversationAwarenessEvent.SPEAKING_STATUSES ||
|
||||
status in ConversationAwarenessEvent.RESUME_STATUSES
|
||||
return AapSetting.ConversationalAwarenessState::class to
|
||||
AapSetting.ConversationalAwarenessState(speaking, rawValue = status)
|
||||
}
|
||||
|
||||
if (message.commandType != AapMessageType.CONTROL.value) return null
|
||||
|
||||
@@ -30,6 +30,17 @@ fun formatBatteryPercent(context: Context, percent: Float): String =
|
||||
if (isKnownBattery(percent)) "${(percent * 100).roundToInt()}%"
|
||||
else context.getString(R.string.general_value_not_available_label)
|
||||
|
||||
/** Formats an earbud time-remaining estimate as a compact duration, e.g. "2h 15m" / "5h" / "45m". */
|
||||
fun formatBatteryDurationShort(context: Context, minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val mins = minutes % 60
|
||||
return when {
|
||||
hours > 0 && mins > 0 -> context.getString(R.string.battery_time_remaining_format_hm, hours, mins)
|
||||
hours > 0 -> context.getString(R.string.battery_time_remaining_format_h, hours)
|
||||
else -> context.getString(R.string.battery_time_remaining_format_m, mins)
|
||||
}
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun getBatteryDrawable(percent: Float): Int = when {
|
||||
!isKnownBattery(percent) -> R.drawable.ic_baseline_battery_unknown_24
|
||||
|
||||
@@ -4,6 +4,14 @@ import eu.darken.capod.common.bluetooth.BleScanResult
|
||||
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* How far back to look when recovering the lid state from a recent in-case-pod broadcast.
|
||||
* Time-bounded rather than count-bounded: BLE scan batching means a fixed number of frames is not a
|
||||
* stable time window, and an old reading must not be allowed to resurrect a stale OPEN/CLOSED.
|
||||
*/
|
||||
private val MAX_LID_RECOVERY_AGE: Duration = Duration.ofSeconds(2)
|
||||
|
||||
interface ApplePodsFactory {
|
||||
fun isResponsible(message: ProximityMessage): Boolean
|
||||
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
|
||||
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
|
||||
|
||||
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
|
||||
// A pod broadcasting from inside the case has authoritative case state
|
||||
// A pod broadcasting from inside the case has authoritative case state. Out-of-case frames
|
||||
// (one pod removed) report UNKNOWN via DualApplePods.caseLidState and are skipped here.
|
||||
if (basic.hasCaseContext && basic.caseLidState in setOf(
|
||||
DualApplePods.LidState.OPEN,
|
||||
DualApplePods.LidState.CLOSED,
|
||||
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
|
||||
return basic.caseLidState
|
||||
}
|
||||
|
||||
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN.
|
||||
// Check recent history for a sibling broadcast that has case context.
|
||||
val fromCaseContext = history
|
||||
.takeLast(4)
|
||||
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
|
||||
// stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
|
||||
// bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
|
||||
val recentHistory = history
|
||||
.filterIsInstance<DualApplePods>()
|
||||
.filter { Duration.between(it.seenLastAt, basic.seenLastAt).abs() <= MAX_LID_RECOVERY_AGE }
|
||||
|
||||
val fromCaseContext = recentHistory
|
||||
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
||||
?.caseLidState
|
||||
|
||||
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
|
||||
// No case-context broadcast in recent history — current value is best we have
|
||||
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
|
||||
|
||||
// Last resort: any non-UNKNOWN from history
|
||||
return history
|
||||
.takeLast(2)
|
||||
.filterIsInstance<DualApplePods>()
|
||||
// Last resort: any non-UNKNOWN from recent history
|
||||
return recentHistory
|
||||
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
|
||||
?.caseLidState
|
||||
?: DualApplePods.LidState.NOT_IN_CASE
|
||||
|
||||
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
|
||||
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
|
||||
|
||||
val caseLidState: LidState
|
||||
get() = LidState.fromRaw(pubCaseLidState, hasCaseContext)
|
||||
get() = LidState.fromRaw(
|
||||
raw = pubCaseLidState,
|
||||
hasCaseContext = hasCaseContext,
|
||||
// The lid bit is only trustworthy from a pod broadcasting inside the case (bit 6), or
|
||||
// while both pods are in the case (bit 2). A bit4-only frame comes from the out-of-case
|
||||
// pod and carries a stale lid byte (see LidState.fromRaw).
|
||||
lidReadingReliable = isThisPodInThecase || areBothPodsInCase,
|
||||
)
|
||||
|
||||
/**
|
||||
* TODO this is glitchy
|
||||
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun fromRaw(raw: UByte, hasCaseContext: Boolean): LidState {
|
||||
/**
|
||||
* Derives the lid state from the raw lid byte.
|
||||
*
|
||||
* The open/closed bit is only meaningful when broadcast by a pod that is itself inside
|
||||
* the case ([isThisPodInThecase]) or while both pods are in the case ([areBothPodsInCase]).
|
||||
* When only one pod is in the case and the *other*, out-of-case pod is the one
|
||||
* broadcasting (bit4-only), its lid byte is stale and decodes to a phantom OPEN even while
|
||||
* the case is physically shut (verified on AirPods Pro 1 & Pro 3). Such frames must report
|
||||
* [UNKNOWN] so they don't drive case-open reactions; consumers recover the real state from
|
||||
* an in-case-pod broadcast instead.
|
||||
*/
|
||||
fun fromRaw(raw: UByte, hasCaseContext: Boolean, lidReadingReliable: Boolean): LidState {
|
||||
if (!hasCaseContext) return NOT_IN_CASE
|
||||
if (!lidReadingReliable) return UNKNOWN
|
||||
|
||||
return when ((raw.toInt() shr 3) and 0x01) {
|
||||
0 -> OPEN
|
||||
|
||||
@@ -16,6 +16,12 @@ data class KnownDevice(
|
||||
val seenCounter: Int,
|
||||
val history: List<ApplePods>,
|
||||
val lastCaseBattery: Float?,
|
||||
/**
|
||||
* Profile id this device has ever IRK-resolved to, or null if it has only ever been seen as a
|
||||
* keyless device. Durable — kept even when [history] is trimmed — so the weak history-recovery
|
||||
* paths can reliably refuse to hand an IRK-backed identity to a foreign same-model frame.
|
||||
*/
|
||||
val boundProfileId: String? = null,
|
||||
) {
|
||||
val lastPayload: ProximityPayload
|
||||
get() = history.last().payload
|
||||
|
||||
@@ -76,7 +76,8 @@ class PodHistoryRepo @Inject constructor(
|
||||
val caseIgnored = if (current is DualApplePods) {
|
||||
val target = current.getCaseMatchMarkings()
|
||||
knownDevices.values.filter { known ->
|
||||
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
|
||||
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target } &&
|
||||
known.isIrkConsistentWith(current)
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
@@ -143,7 +144,7 @@ class PodHistoryRepo @Inject constructor(
|
||||
if (recognizedDevice == null) {
|
||||
val currentMarkers = payload.getFuzzyIdentifier()
|
||||
recognizedDevice = knownDevices.values
|
||||
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
|
||||
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers && it.isIrkConsistentWith(current) }
|
||||
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
|
||||
}
|
||||
|
||||
@@ -154,6 +155,24 @@ class PodHistoryRepo @Inject constructor(
|
||||
return recognizedDevice
|
||||
}
|
||||
|
||||
/**
|
||||
* The weak history paths (fuzzy markers, case-ignored markings) match on low-entropy broadcast
|
||||
* data — model + battery nibbles + colour — which a stranger's same-model AirPods at a similar
|
||||
* battery satisfies. Those paths must not let a foreign frame inherit the identity of one of OUR
|
||||
* keyed (IRK-backed) devices, or the foreign snapshot poisons that device's cache slot and the
|
||||
* dashboard card glitches (Symptom B).
|
||||
*
|
||||
* A [KnownDevice] is "IRK-backed" if it has ever resolved against a profile's IRK
|
||||
* ([KnownDevice.boundProfileId], latched durably so history trimming can't erode it). Such a
|
||||
* device may only be claimed via the strong address/IRK paths, or by a current frame that
|
||||
* resolved to the SAME profile. Keyless devices (never IRK-backed) are unaffected — fuzzy
|
||||
* recovery still works for them exactly as before.
|
||||
*/
|
||||
private fun KnownDevice.isIrkConsistentWith(current: ApplePods): Boolean {
|
||||
val bound = boundProfileId ?: return true // not IRK-backed -> keyless, weak match allowed
|
||||
return current.meta.profile?.id == bound
|
||||
}
|
||||
|
||||
fun updateHistory(device: ApplePods) {
|
||||
val existing = knownDevices[device.identifier]
|
||||
|
||||
@@ -162,12 +181,16 @@ class PodHistoryRepo @Inject constructor(
|
||||
.mapNotNull { it.batteryCasePercent }
|
||||
.lastOrNull()
|
||||
|
||||
// Latch the IRK-resolved profile id (never cleared by an unbound frame), so it survives history trimming.
|
||||
val boundProfileId = device.meta.profile?.id?.takeIf { device.meta.isIRKMatch }
|
||||
|
||||
knownDevices[device.identifier] = if (existing != null) {
|
||||
val history = existing.history.plus(device)
|
||||
existing.copy(
|
||||
seenCounter = existing.seenCounter + 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
|
||||
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery,
|
||||
boundProfileId = boundProfileId ?: existing.boundProfileId,
|
||||
)
|
||||
} else {
|
||||
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
|
||||
@@ -177,7 +200,8 @@ class PodHistoryRepo @Inject constructor(
|
||||
seenFirstAt = device.seenFirstAt,
|
||||
seenCounter = 1,
|
||||
history = history,
|
||||
lastCaseBattery = history.determineLatestCaseBattery()
|
||||
lastCaseBattery = history.determineLatestCaseBattery(),
|
||||
boundProfileId = boundProfileId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package eu.darken.capod.pods.core.apple.ble.protocol
|
||||
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import dagger.Reusable
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -16,6 +18,17 @@ object ProximityPairing {
|
||||
return null
|
||||
}
|
||||
|
||||
// Pods also emit type 0x07 frames whose payload does not use the plaintext status
|
||||
// format, e.g. prefix 0x07 from the identity address on connect (#603). Their bytes
|
||||
// are garbage at the standard offsets and would create a ghost device.
|
||||
if (message.data.firstOrNull() != PAIRING_MESSAGE_PREFIX) {
|
||||
log(TAG, DEBUG) {
|
||||
val hex = message.data.joinToString(" ") { "%02X".format(it.toInt()) }
|
||||
"Dropping proximity pairing message with unsupported prefix: [$hex]"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return ProximityMessage(
|
||||
type = message.type,
|
||||
length = message.length,
|
||||
@@ -49,4 +62,9 @@ object ProximityPairing {
|
||||
|
||||
// This is the default message length among official Apple devices, clones may have different length
|
||||
internal const val PAIRING_MESSAGE_LENGTH = 25
|
||||
|
||||
// First payload byte of every known plaintext status broadcast, including pairing mode
|
||||
internal val PAIRING_MESSAGE_PREFIX = 0x01.toUByte()
|
||||
|
||||
private val TAG = logTag("Pod", "Apple", "ProximityPairing")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.IdentityResolvingKey
|
||||
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityEncryptionKey
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionsConfig
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -25,11 +27,19 @@ data class AppleDeviceProfile(
|
||||
@SerialName("address") override val address: String? = null,
|
||||
@SerialName("reactionAutoPause") val autoPause: Boolean = false,
|
||||
@SerialName("reactionAutoPlay") val autoPlay: Boolean = false,
|
||||
@SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false,
|
||||
@SerialName("reactionOnePodMode") val onePodMode: Boolean = false,
|
||||
@SerialName("reactionAutoConnect") val autoConnect: Boolean = false,
|
||||
@SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||
@SerialName("reactionShowPopUpOnCaseOpen") val showPopUpOnCaseOpen: Boolean = false,
|
||||
@SerialName("reactionShowPopUpOnConnection") val showPopUpOnConnection: Boolean = false,
|
||||
@SerialName("reactionConversationAction") val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||
@SerialName("reactionConversationVolumeReduction") val conversationVolumeReduction: Int = ReactionConfig.DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||
@SerialName("reactionNotifyWhenCharged") val notifyWhenCharged: Boolean = false,
|
||||
@SerialName("reactionChargedThreshold") val chargedThreshold: Int = ReactionConfig.DEFAULT_CHARGED_THRESHOLD,
|
||||
@SerialName("reactionChargedSlotScope") val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
|
||||
/** Whether the dashboard battery time-remaining estimate is shown for this device. */
|
||||
@SerialName("batteryEstimateEnabled") val batteryEstimateEnabled: Boolean = true,
|
||||
/**
|
||||
* Last-known device-side AllowOffOption (AAP setting 0x34). Persisted so the UI can honor
|
||||
* the learned value across sessions — AAP state is dropped on disconnect, but whether OFF
|
||||
@@ -49,11 +59,17 @@ data class AppleDeviceProfile(
|
||||
get() = ReactionConfig(
|
||||
autoPause = autoPause,
|
||||
autoPlay = autoPlay,
|
||||
startMusicOnWear = startMusicOnWear,
|
||||
onePodMode = onePodMode,
|
||||
autoConnect = autoConnect,
|
||||
autoConnectCondition = autoConnectCondition,
|
||||
showPopUpOnCaseOpen = showPopUpOnCaseOpen,
|
||||
showPopUpOnConnection = showPopUpOnConnection,
|
||||
conversationAction = conversationAction,
|
||||
conversationVolumeReduction = conversationVolumeReduction,
|
||||
notifyWhenCharged = notifyWhenCharged,
|
||||
chargedThreshold = chargedThreshold,
|
||||
chargedSlotScope = chargedSlotScope,
|
||||
)
|
||||
|
||||
override fun toString(): String = "AppleDeviceProfile(" +
|
||||
@@ -62,10 +78,17 @@ data class AppleDeviceProfile(
|
||||
"identityKey=${if (identityKey == null) "null" else "<redacted>"}, " +
|
||||
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
||||
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
||||
"startMusicOnWear=$startMusicOnWear, " +
|
||||
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
||||
"autoConnectCondition=$autoConnectCondition, " +
|
||||
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
||||
"showPopUpOnConnection=$showPopUpOnConnection, " +
|
||||
"conversationAction=$conversationAction, " +
|
||||
"conversationVolumeReduction=$conversationVolumeReduction, " +
|
||||
"notifyWhenCharged=$notifyWhenCharged, " +
|
||||
"chargedThreshold=$chargedThreshold, " +
|
||||
"chargedSlotScope=$chargedSlotScope, " +
|
||||
"batteryEstimateEnabled=$batteryEstimateEnabled, " +
|
||||
"learnedAllowOffEnabled=$learnedAllowOffEnabled, " +
|
||||
"lastRequestedListeningModeCycleMask=$lastRequestedListeningModeCycleMask, " +
|
||||
"stemActions=$stemActions" +
|
||||
|
||||
@@ -11,6 +11,7 @@ import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.serialization.SerializationCapod
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import eu.darken.capod.monitor.core.battery.BatteryDrainStore
|
||||
import eu.darken.capod.monitor.core.cache.DeviceStateCache
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -30,6 +31,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
private val generalSettings: GeneralSettings,
|
||||
private val settings: DeviceProfilesSettings,
|
||||
private val deviceStateCache: DeviceStateCache,
|
||||
private val batteryDrainStore: BatteryDrainStore,
|
||||
@SerializationCapod private val json: Json,
|
||||
) {
|
||||
|
||||
@@ -183,6 +185,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
settings.profiles.valueBlocking = DeviceProfilesContainer(updatedProfiles)
|
||||
log(VERBOSE) { "Removed device profile with ID: $profileId" }
|
||||
deviceStateCache.delete(profileId)
|
||||
batteryDrainStore.delete(profileId)
|
||||
}
|
||||
|
||||
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
|
||||
@@ -199,6 +202,7 @@ class DeviceProfilesRepo @Inject constructor(
|
||||
suspend fun clear() = mutex.withLock {
|
||||
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
|
||||
deviceStateCache.deleteAll()
|
||||
batteryDrainStore.deleteAll()
|
||||
}
|
||||
|
||||
private fun checkAddressUniqueness(profile: DeviceProfile, existingProfiles: List<DeviceProfile>) {
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
package eu.darken.capod.profiles.core
|
||||
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
|
||||
import eu.darken.capod.reaction.core.charged.ChargedSlotScope
|
||||
import eu.darken.capod.reaction.core.conversation.ConversationAction
|
||||
|
||||
data class ReactionConfig(
|
||||
val autoPause: Boolean = false,
|
||||
val autoPlay: Boolean = false,
|
||||
val startMusicOnWear: Boolean = false,
|
||||
val onePodMode: Boolean = false,
|
||||
val autoConnect: Boolean = false,
|
||||
val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN,
|
||||
val showPopUpOnCaseOpen: Boolean = false,
|
||||
val showPopUpOnConnection: Boolean = false,
|
||||
)
|
||||
val conversationAction: ConversationAction = ConversationAction.NOTHING,
|
||||
/** Percentage to lower media volume by when [conversationAction] is LOWER_VOLUME (clamped 10..90 on use). */
|
||||
val conversationVolumeReduction: Int = DEFAULT_CONVERSATION_VOLUME_REDUCTION,
|
||||
val notifyWhenCharged: Boolean = false,
|
||||
/** Battery percentage at which the charged notification fires (clamped 50..100 on use). */
|
||||
val chargedThreshold: Int = DEFAULT_CHARGED_THRESHOLD,
|
||||
val chargedSlotScope: ChargedSlotScope = ChargedSlotScope.PODS_AND_CASE,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_CHARGED_THRESHOLD = 100
|
||||
const val MIN_CHARGED_THRESHOLD = 50
|
||||
const val MAX_CHARGED_THRESHOLD = 100
|
||||
const val CHARGED_THRESHOLD_STEP = 10
|
||||
|
||||
const val DEFAULT_CONVERSATION_VOLUME_REDUCTION = 50
|
||||
const val MIN_CONVERSATION_VOLUME_REDUCTION = 10
|
||||
|
||||
/** 100% lowers media volume all the way to 0 — i.e. full mute while speaking. */
|
||||
const val MAX_CONVERSATION_VOLUME_REDUCTION = 100
|
||||
}
|
||||
}
|
||||
|
||||
interface HasReactionConfig {
|
||||
val reactionConfig: ReactionConfig
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package eu.darken.capod.reaction.core.autoconnect
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.bluetooth.NudgeAvailability
|
||||
import eu.darken.capod.common.bluetooth.NudgeCapabilityStore
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
@@ -26,6 +28,7 @@ import javax.inject.Singleton
|
||||
class AutoConnect @Inject constructor(
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val nudgeCapabilityStore: NudgeCapabilityStore,
|
||||
) {
|
||||
|
||||
fun monitor(): Flow<Unit> = deviceMonitor.primaryDevice()
|
||||
@@ -36,7 +39,9 @@ class AutoConnect @Inject constructor(
|
||||
combine(
|
||||
bluetoothManager.connectedDevices,
|
||||
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
|
||||
Triple(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode)
|
||||
// Include caseLidState: for the CASE_OPEN condition it is derived from history
|
||||
// and can flip to OPEN while the selected frame's raw bytes are unchanged.
|
||||
listOf(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode, it.caseLidState)
|
||||
},
|
||||
) { connectedDevices, mainDevice ->
|
||||
connectedDevices to mainDevice
|
||||
@@ -98,13 +103,14 @@ class AutoConnect @Inject constructor(
|
||||
return@map
|
||||
}
|
||||
|
||||
if (!bluetoothManager.isNudgeAvailable) {
|
||||
log(TAG, WARN) { "nudgeConnection is not available on this device, skipping" }
|
||||
if (nudgeCapabilityStore.availability.value == NudgeAvailability.BROKEN) {
|
||||
log(TAG, WARN) { "nudgeConnection is known broken on this device, skipping" }
|
||||
return@map
|
||||
}
|
||||
|
||||
val result = bluetoothManager.nudgeConnection(bondedDevice)
|
||||
log(TAG) { "nudgeConnection($bondedDevice) returned $result" }
|
||||
nudgeCapabilityStore.record(result)
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package eu.darken.capod.reaction.core.charged
|
||||
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.devicesWithProfiles
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasCase
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Input
|
||||
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Output
|
||||
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.Slot
|
||||
import eu.darken.capod.reaction.core.charged.ChargingSessionStateMachine.SlotData
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChargedReaction @Inject constructor(
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
|
||||
sealed class Event {
|
||||
data class ShowNotification(
|
||||
val profileId: String,
|
||||
val deviceLabel: String,
|
||||
val thresholdPercent: Int,
|
||||
) : Event()
|
||||
|
||||
data class CancelNotification(val profileId: String) : Event()
|
||||
}
|
||||
|
||||
private data class ChargedConfig(val scope: ChargedSlotScope, val thresholdPercent: Int)
|
||||
|
||||
fun monitor(): Flow<Event> = flow {
|
||||
// Machines live in the collection scope: a monitor (service) restart starts fresh sessions.
|
||||
val machines = mutableMapOf<String, ChargingSessionStateMachine>()
|
||||
val configs = mutableMapOf<String, ChargedConfig>()
|
||||
combine(
|
||||
profilesRepo.profiles,
|
||||
deviceMonitor.devicesWithProfiles(),
|
||||
) { profiles, devices -> profiles.filterIsInstance<AppleDeviceProfile>() to devices }
|
||||
.collect { (profiles, devices) ->
|
||||
val enabled = profiles.filter { it.notifyWhenCharged }.associateBy { it.id }
|
||||
|
||||
// Profile deleted or toggle disabled → reset; a missing device snapshot is
|
||||
// handled below as staleness, never as a reset.
|
||||
machines.keys.filter { it !in enabled }.forEach { profileId ->
|
||||
val output = machines.remove(profileId)!!.process(Input.Reset)
|
||||
configs.remove(profileId)
|
||||
if (output == Output.CANCEL) emit(Event.CancelNotification(profileId))
|
||||
}
|
||||
|
||||
for (profile in enabled.values) {
|
||||
val machine = machines.getOrPut(profile.id) { ChargingSessionStateMachine() }
|
||||
val thresholdPercent = profile.chargedThreshold.coerceIn(
|
||||
ReactionConfig.MIN_CHARGED_THRESHOLD,
|
||||
ReactionConfig.MAX_CHARGED_THRESHOLD,
|
||||
)
|
||||
// A persisted CASE scope on a caseless model (restored backup, model change)
|
||||
// would otherwise produce an eternally-empty slot set.
|
||||
val scope = if (profile.model.features.hasCase) profile.chargedSlotScope else ChargedSlotScope.PODS
|
||||
val config = ChargedConfig(scope, thresholdPercent)
|
||||
val previousConfig = configs.put(profile.id, config)
|
||||
if (previousConfig != null && previousConfig != config) {
|
||||
// Scope/threshold changed: stale highwater slots and an already-shown
|
||||
// notification don't fit the new settings — start evaluation over.
|
||||
log(TAG, INFO) { "Settings changed for ${profile.id} ($previousConfig -> $config), resetting" }
|
||||
if (machine.process(Input.Reset) == Output.CANCEL) {
|
||||
emit(Event.CancelNotification(profile.id))
|
||||
}
|
||||
}
|
||||
val device = devices.firstOrNull { it.profileId == profile.id }
|
||||
val slots = device?.liveChargingSlots(scope).orEmpty()
|
||||
// No live slot data (device gone, lid closed, or AAP hasn't pushed battery
|
||||
// yet) is treated as staleness — the session suspends instead of resetting.
|
||||
val input = if (device == null || slots.isEmpty()) {
|
||||
Input.StaleUpdate
|
||||
} else {
|
||||
Input.LiveUpdate(
|
||||
slots = slots,
|
||||
threshold = thresholdPercent / 100f,
|
||||
activity = device.chargingActivity(),
|
||||
)
|
||||
}
|
||||
val phaseBefore = machine.phase
|
||||
val output = machine.process(input)
|
||||
if (machine.phase != phaseBefore) {
|
||||
log(TAG, VERBOSE) {
|
||||
"${profile.id}: $phaseBefore -> ${machine.phase} (output=$output, input=$input)"
|
||||
}
|
||||
}
|
||||
when (output) {
|
||||
Output.SHOW -> {
|
||||
val label = device?.label ?: profile.label
|
||||
log(TAG, INFO) { "Charge complete for ${profile.id} ($label) at $thresholdPercent%" }
|
||||
emit(Event.ShowNotification(profile.id, label, thresholdPercent))
|
||||
}
|
||||
|
||||
Output.CANCEL -> {
|
||||
log(TAG, INFO) { "Charging session over for ${profile.id}, cancelling notification" }
|
||||
emit(Event.CancelNotification(profile.id))
|
||||
}
|
||||
|
||||
Output.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "chargedReaction" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "Charged")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-slot live readings only — AAP first, then live BLE. The [PodDevice] facade getters are
|
||||
* deliberately bypassed because they silently fall back to the disk cache; stale cached values
|
||||
* must never start, complete, or end a charging session.
|
||||
*/
|
||||
/**
|
||||
* Signals that the user is handling the device, used to dismiss an already-shown notification.
|
||||
* All sources are live (ear detection: AAP/BLE; lid: BLE; DISCONNECTED slots: AAP) — the cache
|
||||
* never feeds in here.
|
||||
*/
|
||||
internal fun PodDevice.chargingActivity(): ChargingSessionStateMachine.Activity =
|
||||
ChargingSessionStateMachine.Activity(
|
||||
// Only trust AAP ear detection — BLE ear bits are phantom for in-case pods (see
|
||||
// PodDevice.hasAapEarDetection). Unknown when there's no AAP session, which is the
|
||||
// normal state while charging in the case, so phantom "worn" never dismisses.
|
||||
wornSlots = when {
|
||||
!hasAapEarDetection -> null
|
||||
hasDualPods -> buildSet {
|
||||
if (isLeftInEar == true) add(Slot.LEFT)
|
||||
if (isRightInEar == true) add(Slot.RIGHT)
|
||||
}
|
||||
else -> if (isBeingWorn == true) setOf(Slot.HEADSET) else emptySet()
|
||||
},
|
||||
lid = when (caseLidState) {
|
||||
DualApplePods.LidState.OPEN -> ChargingSessionStateMachine.Lid.OPEN
|
||||
DualApplePods.LidState.CLOSED -> ChargingSessionStateMachine.Lid.CLOSED
|
||||
DualApplePods.LidState.NOT_IN_CASE -> ChargingSessionStateMachine.Lid.NOT_IN_CASE
|
||||
else -> null
|
||||
},
|
||||
disconnectedSlots = buildSet {
|
||||
if (leftPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.LEFT)
|
||||
if (rightPodChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.RIGHT)
|
||||
if (caseChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.CASE)
|
||||
if (headsetChargingState == AapPodState.ChargingState.DISCONNECTED) add(Slot.HEADSET)
|
||||
},
|
||||
)
|
||||
|
||||
internal fun PodDevice.liveChargingSlots(scope: ChargedSlotScope): Map<Slot, SlotData> {
|
||||
val slots = mutableMapOf<Slot, SlotData>()
|
||||
fun add(slot: Slot, battery: Float?, charging: Boolean?) {
|
||||
if (battery == null || battery < 0f || charging == null) return
|
||||
slots[slot] = SlotData(battery = battery, isCharging = charging)
|
||||
}
|
||||
if (scope != ChargedSlotScope.CASE) {
|
||||
add(
|
||||
Slot.LEFT,
|
||||
aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent,
|
||||
aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging,
|
||||
)
|
||||
add(
|
||||
Slot.RIGHT,
|
||||
aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent,
|
||||
aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging,
|
||||
)
|
||||
add(
|
||||
Slot.HEADSET,
|
||||
aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent,
|
||||
aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged,
|
||||
)
|
||||
}
|
||||
if (scope != ChargedSlotScope.PODS) {
|
||||
add(
|
||||
Slot.CASE,
|
||||
aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent,
|
||||
aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging,
|
||||
)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
+87
@@ -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),
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package eu.darken.capod.reaction.core.charged
|
||||
|
||||
/**
|
||||
* Tracks one charging session for one profile and decides when the "charged" notification
|
||||
* should be shown or cancelled. Pure Kotlin, no Android/DI/Flow dependencies; instances are
|
||||
* driven sequentially from a single coroutine (no internal locking).
|
||||
*
|
||||
* A session starts when any slot reports charging and collects every slot that charges while
|
||||
* the session runs. Per-slot battery highwater marks decide completion: a slot that stops
|
||||
* charging at/above the threshold finished naturally (firmware flips the charging flag off at
|
||||
* 100%), while a slot that stops below the threshold was genuinely unplugged and resets the
|
||||
* session. Stale data suspends the session instead of resetting it — brief BLE gaps while the
|
||||
* device sits on the charger must not lose progress.
|
||||
*
|
||||
* After firing, the notification is taken down again when the user demonstrably reacted to it:
|
||||
* battery discharging below the threshold, a new charging session starting, or any activity
|
||||
* signal showing the pods are being handled (worn-pod set changed, pod removed from the case,
|
||||
* case lid moved). Activity is judged against baselines captured at fire time so pre-existing
|
||||
* state doesn't dismiss — only a change does:
|
||||
* - Worn pods and DISCONNECTED slots baseline on the firing frame. They only apply to
|
||||
* sessions containing pod slots — wearing pods says nothing about a case-only charge.
|
||||
* - The lid signal baselines on the UNION of values seen before the fire (this session plus
|
||||
* a few frames leading into it). With one pod worn and one charging, both pods broadcast
|
||||
* and the monitor's dedup alternates between their frames (OPEN from the in-case pod,
|
||||
* NOT_IN_CASE from the worn pod) — a single-frame baseline would read that steady flapping
|
||||
* as activity. Lid movement is a handling signal for any session, including case-only.
|
||||
*
|
||||
* An activity dismissal latches into [Phase.DISMISSED] so a still-full, still-charging device
|
||||
* can't immediately re-fire.
|
||||
*/
|
||||
class ChargingSessionStateMachine {
|
||||
|
||||
enum class Slot { LEFT, RIGHT, CASE, HEADSET }
|
||||
|
||||
/** [battery] is a fraction 0..1, matching the monitor layer's battery values. */
|
||||
data class SlotData(val battery: Float, val isCharging: Boolean)
|
||||
|
||||
/**
|
||||
* Case posture from the BLE lid byte. NOT_IN_CASE isn't a lid position — it means the
|
||||
* broadcasting pod is physically outside the case, which is just as much a handling
|
||||
* signal as a lid movement (it's the only BLE-side trace of "pod taken out").
|
||||
*/
|
||||
enum class Lid { OPEN, CLOSED, NOT_IN_CASE }
|
||||
|
||||
/** Signals that the user is physically handling the device. All optional/unknown-safe. */
|
||||
data class Activity(
|
||||
/** Pods currently worn (per-side); null when no ear data at all. */
|
||||
val wornSlots: Set<Slot>? = null,
|
||||
val lid: Lid? = null,
|
||||
/** Slots physically absent from the case (AAP ChargingState.DISCONNECTED). */
|
||||
val disconnectedSlots: Set<Slot> = emptySet(),
|
||||
)
|
||||
|
||||
sealed interface Input {
|
||||
/**
|
||||
* Fresh data from a live source (BLE/AAP). [slots] only contains slots with live
|
||||
* readings — cached values must never be fed in here. [threshold] is a fraction 0..1.
|
||||
*/
|
||||
data class LiveUpdate(
|
||||
val slots: Map<Slot, SlotData>,
|
||||
val threshold: Float,
|
||||
val activity: Activity = Activity(),
|
||||
) : Input
|
||||
|
||||
/** Device has no live data right now (out of range, lid closed, cache-only). */
|
||||
data object StaleUpdate : Input
|
||||
|
||||
/** Toggle disabled, settings changed, or profile deleted. */
|
||||
data object Reset : Input
|
||||
}
|
||||
|
||||
enum class Output { NONE, SHOW, CANCEL }
|
||||
|
||||
enum class Phase { IDLE, CHARGING, FIRED, DISMISSED, SUSPENDED }
|
||||
|
||||
var phase: Phase = Phase.IDLE
|
||||
private set
|
||||
|
||||
/** Phase to resume into when live data returns; only meaningful while SUSPENDED. */
|
||||
private var suspendedFrom: Phase = Phase.IDLE
|
||||
|
||||
/** Battery highwater per slot that joined the session. */
|
||||
private val highwater = mutableMapOf<Slot, Float>()
|
||||
|
||||
/**
|
||||
* Rolling window of the most recent lid observations across all phases. Seeds the at-fire
|
||||
* baseline so the OPEN/NOT_IN_CASE alternation (worn pod broadcasts NOT_IN_CASE while the
|
||||
* in-case pod broadcasts OPEN; the monitor's dedup serves them in turn) is absorbed — even
|
||||
* for an instant fire right after a settings-change reset, since the window is never cleared.
|
||||
*/
|
||||
private val recentLids = ArrayDeque<Lid>()
|
||||
|
||||
// Activity baselines frozen at fire; see class docs.
|
||||
private val baselineLids = mutableSetOf<Lid>()
|
||||
private var baselineWorn: Set<Slot>? = null
|
||||
private var baselineDisconnected: Set<Slot> = emptySet()
|
||||
|
||||
fun process(input: Input): Output {
|
||||
if (input is Input.LiveUpdate) input.activity.lid?.let(::recordRecentLid)
|
||||
return when (phase) {
|
||||
Phase.IDLE -> processIdle(input)
|
||||
Phase.CHARGING -> processCharging(input)
|
||||
Phase.FIRED -> processFired(input)
|
||||
Phase.DISMISSED -> processDismissed(input)
|
||||
Phase.SUSPENDED -> processSuspended(input)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processIdle(input: Input): Output {
|
||||
if (input !is Input.LiveUpdate) return Output.NONE
|
||||
val charging = input.slots.filterValues { it.isCharging }
|
||||
if (charging.isEmpty()) return Output.NONE
|
||||
highwater.clear()
|
||||
charging.forEach { (slot, data) -> highwater[slot] = data.battery }
|
||||
phase = Phase.CHARGING
|
||||
return fireIfComplete(input)
|
||||
}
|
||||
|
||||
private fun processCharging(input: Input): Output = when (input) {
|
||||
is Input.Reset -> reset(cancel = false)
|
||||
is Input.StaleUpdate -> suspend(Phase.CHARGING)
|
||||
is Input.LiveUpdate -> {
|
||||
// Charging slots join the session (e.g. case plugged in later); slots already in
|
||||
// it advance their mark; slots absent from this update stay frozen.
|
||||
input.slots.forEach { (slot, data) ->
|
||||
if (data.isCharging || slot in highwater) {
|
||||
highwater[slot] = maxOf(highwater[slot] ?: 0f, data.battery)
|
||||
}
|
||||
}
|
||||
val unplugged = highwater.any { (slot, mark) ->
|
||||
input.slots[slot]?.isCharging == false && mark < input.threshold
|
||||
}
|
||||
if (unplugged) reset(cancel = false) else fireIfComplete(input)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processFired(input: Input): Output = when (input) {
|
||||
is Input.Reset -> reset(cancel = true)
|
||||
is Input.StaleUpdate -> suspend(Phase.FIRED)
|
||||
is Input.LiveUpdate -> when {
|
||||
// "All slots stopped charging" alone is NOT an unplug signal — firmware flips the
|
||||
// charging flag off at 100% while still on power. Slots that never charged this
|
||||
// session (e.g. an idle half-empty case) carry no signal either. The session ends
|
||||
// on: a new charge context, discharge below threshold, or user activity.
|
||||
isNewChargeContext(input) -> {
|
||||
// Only CANCEL is reported now; the next update finds IDLE and starts (and, if
|
||||
// the data already satisfies the threshold, fires) the new session.
|
||||
phase = Phase.IDLE
|
||||
highwater.clear()
|
||||
Output.CANCEL
|
||||
}
|
||||
|
||||
isSessionDischarged(input) -> reset(cancel = true)
|
||||
|
||||
isActivityDetected(input.activity) -> {
|
||||
phase = Phase.DISMISSED
|
||||
Output.CANCEL
|
||||
}
|
||||
|
||||
else -> Output.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/** Like FIRED, but the notification is already gone — same exits, silent. */
|
||||
private fun processDismissed(input: Input): Output = when (input) {
|
||||
is Input.Reset -> reset(cancel = false)
|
||||
is Input.StaleUpdate -> suspend(Phase.DISMISSED)
|
||||
is Input.LiveUpdate -> when {
|
||||
isNewChargeContext(input) -> {
|
||||
// Nothing to cancel, so unlike FIRED the new session can start right away.
|
||||
phase = Phase.IDLE
|
||||
highwater.clear()
|
||||
processIdle(input)
|
||||
}
|
||||
|
||||
isSessionDischarged(input) -> reset(cancel = false)
|
||||
|
||||
else -> Output.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun processSuspended(input: Input): Output = when (input) {
|
||||
is Input.Reset -> reset(cancel = suspendedFrom == Phase.FIRED)
|
||||
is Input.StaleUpdate -> Output.NONE
|
||||
is Input.LiveUpdate -> {
|
||||
phase = suspendedFrom
|
||||
if (phase == Phase.CHARGING && hasRegressed(input)) {
|
||||
// Battery dropped well below a session mark while we were blind — the device
|
||||
// was unplugged and used in the meantime. Start over with the fresh data.
|
||||
phase = Phase.IDLE
|
||||
highwater.clear()
|
||||
processIdle(input)
|
||||
} else {
|
||||
process(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Something is charging below the threshold — a fresh charging session is beginning. */
|
||||
private fun isNewChargeContext(input: Input.LiveUpdate): Boolean =
|
||||
input.slots.any { it.value.isCharging && it.value.battery < input.threshold }
|
||||
|
||||
/** A session slot is off power and drained below the threshold — device is in use. */
|
||||
private fun isSessionDischarged(input: Input.LiveUpdate): Boolean = highwater.keys.any { slot ->
|
||||
input.slots[slot]?.let { !it.isCharging && it.battery < input.threshold } == true
|
||||
}
|
||||
|
||||
private fun isActivityDetected(activity: Activity): Boolean {
|
||||
// Worn pods and pod removal only speak about pod slots; a case-only session ignores them.
|
||||
if (highwater.keys.any { it != Slot.CASE }) {
|
||||
activity.wornSlots?.let { worn ->
|
||||
when (val baseline = baselineWorn) {
|
||||
// No ear data at fire time: pods can't be worn while charging in the case,
|
||||
// so any worn pod is activity — only "nothing worn" is adoptable as baseline.
|
||||
null -> if (worn.isNotEmpty()) return true else baselineWorn = emptySet()
|
||||
else -> if (worn != baseline) return true
|
||||
}
|
||||
}
|
||||
val newlyDisconnected = activity.disconnectedSlots.any {
|
||||
it != Slot.CASE && it in highwater && it !in baselineDisconnected
|
||||
}
|
||||
if (newlyDisconnected) return true
|
||||
}
|
||||
activity.lid?.let { lid ->
|
||||
if (baselineLids.isEmpty()) {
|
||||
// Lid never observed pre-fire: first sighting is the baseline — it was most
|
||||
// likely in that posture all along (open is the normal charging posture).
|
||||
baselineLids.add(lid)
|
||||
} else if (lid !in baselineLids) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun hasRegressed(input: Input.LiveUpdate): Boolean = highwater.any { (slot, mark) ->
|
||||
input.slots[slot]?.let { it.battery < mark - REGRESSION_TOLERANCE } == true
|
||||
}
|
||||
|
||||
private fun recordRecentLid(lid: Lid) {
|
||||
recentLids.addLast(lid)
|
||||
while (recentLids.size > RECENT_LID_FRAMES) recentLids.removeFirst()
|
||||
}
|
||||
|
||||
private fun fireIfComplete(input: Input.LiveUpdate): Output {
|
||||
if (highwater.isEmpty() || highwater.any { it.value < input.threshold }) return Output.NONE
|
||||
phase = Phase.FIRED
|
||||
baselineLids.clear()
|
||||
baselineLids += recentLids
|
||||
baselineWorn = input.activity.wornSlots
|
||||
baselineDisconnected = input.activity.disconnectedSlots.intersect(highwater.keys)
|
||||
return Output.SHOW
|
||||
}
|
||||
|
||||
private fun reset(cancel: Boolean): Output {
|
||||
phase = Phase.IDLE
|
||||
highwater.clear()
|
||||
// recentLids is an ambient rolling window — intentionally kept across resets so an
|
||||
// instant re-fire after a settings change still has the recent lid alternation.
|
||||
baselineLids.clear()
|
||||
baselineWorn = null
|
||||
baselineDisconnected = emptySet()
|
||||
return if (cancel) Output.CANCEL else Output.NONE
|
||||
}
|
||||
|
||||
private fun suspend(from: Phase): Output {
|
||||
suspendedFrom = from
|
||||
phase = Phase.SUSPENDED
|
||||
return Output.NONE
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Battery drop (fraction) below a slot's highwater mark that counts as a real
|
||||
* regression on resume. Public BLE reports in 10% steps, so a single-step flicker
|
||||
* (exactly 0.1) must not reset the session.
|
||||
*/
|
||||
private const val REGRESSION_TOLERANCE = 0.1f
|
||||
|
||||
/**
|
||||
* Pre-fire lid observations kept as baseline context. At the observed 0.5-1.5s
|
||||
* emission cadence this is a few seconds — enough to capture dedup alternation
|
||||
* leading into an instant fire without dragging in long-stale postures.
|
||||
*/
|
||||
private const val RECENT_LID_FRAMES = 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.darken.capod.reaction.core.conversation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.darken.capod.R
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What CAPod does when the pods report that the wearer started speaking (Conversational
|
||||
* Awareness). On Android the firmware does not duck audio on its own, so CAPod performs the
|
||||
* reaction itself. The action is reverted when speaking stops.
|
||||
*
|
||||
* Extend by adding entries — the persisted [SerialName] strings are stable identifiers.
|
||||
*/
|
||||
@Serializable
|
||||
enum class ConversationAction(
|
||||
val identifier: String,
|
||||
@StringRes val labelRes: Int,
|
||||
) {
|
||||
@SerialName("conversation.action.nothing")
|
||||
NOTHING("conversation.action.nothing", R.string.settings_conversation_action_nothing_label),
|
||||
|
||||
@SerialName("conversation.action.lower_volume")
|
||||
LOWER_VOLUME("conversation.action.lower_volume", R.string.settings_conversation_action_lower_volume_label),
|
||||
|
||||
@SerialName("conversation.action.pause")
|
||||
PAUSE("conversation.action.pause", R.string.settings_conversation_action_pause_label),
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package eu.darken.capod.reaction.core.conversation
|
||||
|
||||
import eu.darken.capod.common.MediaControl
|
||||
import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.BluetoothAddress
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
|
||||
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
|
||||
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
|
||||
*
|
||||
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP],
|
||||
* status `8,9`). The pod sends NO frames during active speech — it stays engaged for as long as it
|
||||
* hears nearby voices (observed: CA held engaged 21-32s with zero `0x4B` frames, indefinitely
|
||||
* against ambient noise). So frame-silence must NOT be read as "speaking ended"; after a START only
|
||||
* the long [STALE_TIMEOUT] backstop applies, and a link drop is handled by the owner-disconnect revert.
|
||||
*
|
||||
* A [ConversationAwarenessEvent.HOLD] frame (`3` pause, `0x0B`/`4` wind-down, `7` abort) is evidence
|
||||
* the wind-down may have begun. The real wind-down is a short flurry `3→0x0B→4` then the `8,9`
|
||||
* terminal — but the terminal is sometimes dropped entirely (single-pod wear: fw `…6589`/`…6861`
|
||||
* emitted `3,0xB,4` then nothing, stranding the volume low — #608). So a HOLD frame re-arms the
|
||||
* timer with the short [WIND_DOWN_TIMEOUT] fuse: if no terminal (or resume) follows, speech is
|
||||
* treated as ended anyway.
|
||||
*
|
||||
* A [ConversationAwarenessEvent.RESUME] frame (`5`) means speech resumed after a pause — in bursty
|
||||
* talking the pod cycles `3,5,3,5,…` while CA stays engaged. It is NOT a terminal: it cancels any
|
||||
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
|
||||
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
|
||||
*
|
||||
* Removing/inserting a pod makes the firmware re-key CA — it emits a terminal then a fresh onset
|
||||
* around the physical change even though the conversation continues. A terminal arriving within
|
||||
* [EAR_TRANSITION_WINDOW] of an AAP ear-detection change is therefore treated as a re-key artifact:
|
||||
* it defers to the wind-down fuse instead of disengaging, so a brief pod removal mid-conversation
|
||||
* neither strands a pause nor causes a duck→restore→re-duck volume blip.
|
||||
*
|
||||
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
|
||||
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
|
||||
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
|
||||
* session never strands the user at low volume.
|
||||
*/
|
||||
@Singleton
|
||||
class ConversationReaction @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val mediaControl: MediaControl,
|
||||
@AppScope private val appScope: CoroutineScope,
|
||||
private val timeSource: TimeSource,
|
||||
) {
|
||||
|
||||
private sealed interface Kind {
|
||||
data object Paused : Kind
|
||||
data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind
|
||||
}
|
||||
|
||||
private data class Active(
|
||||
val id: Long,
|
||||
val owner: BluetoothAddress,
|
||||
val kind: Kind,
|
||||
val at: Long,
|
||||
)
|
||||
|
||||
/** What the single pending [disengageJob] is currently waiting for. */
|
||||
private enum class TimerPhase {
|
||||
/** Long backstop while only START/RESUME seen — inferred end if it fires. */
|
||||
STALE_BACKSTOP,
|
||||
|
||||
/** Short fuse armed by a wind-down HOLD — terminal imminent (or dropped, #608). */
|
||||
WIND_DOWN_FUSE,
|
||||
|
||||
/** Brief wait on an uncorroborated cold terminal to see if a pod-removal ear change follows. */
|
||||
STOP_SETTLE,
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var active: Active? = null
|
||||
private var disengageJob: Job? = null
|
||||
private var timerPhase: TimerPhase? = null
|
||||
// Bumped on every (re)arm. A timer job captures its generation and only acts if still current —
|
||||
// guards against a stale job that already passed its delay (so cancel() no longer stops it) being
|
||||
// re-armed to the SAME phase while it blocks on the mutex. This is a runtime concurrency guard: the
|
||||
// single-threaded virtual-time test harness serializes the timer callback and event handlers, so a
|
||||
// re-arm there always cancels the prior job before it runs — the race is not reproducible in a unit
|
||||
// test (a test would exercise cancel(), not this check). Left deliberately uncovered, not an oversight.
|
||||
private var timerGeneration = 0L
|
||||
private var idCounter = 0L
|
||||
|
||||
// Per-owner AAP ear-detection tracking, so a CA terminal that lands right after a pod
|
||||
// removal/insertion can be recognised as a re-key artifact rather than a real conversation end.
|
||||
private val lastEarDetection = mutableMapOf<BluetoothAddress, AapSetting.EarDetection?>()
|
||||
private val earTransitionAt = mutableMapOf<BluetoothAddress, Long>()
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
||||
// Tracks ear-detection transitions (for terminal-artifact suppression) and reverts a stranded
|
||||
// duck when the owning device leaves the AAP state map for any reason (intentional or not) —
|
||||
// disconnectEvents only fires for unintentional drops.
|
||||
aapManager.allStates.onEach { states -> onStatesUpdated(states) },
|
||||
)
|
||||
.map { }
|
||||
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
||||
.onCompletion { withContext(NonCancellable) { onMonitorCompleted() } }
|
||||
.setupCommonEventHandlers(TAG) { "conversationReaction" }
|
||||
|
||||
private suspend fun onEvent(address: BluetoothAddress, event: ConversationAwarenessEvent) = when (event) {
|
||||
ConversationAwarenessEvent.START -> onSpeakingStart(address)
|
||||
ConversationAwarenessEvent.RESUME -> onSpeakingResume(address)
|
||||
ConversationAwarenessEvent.HOLD -> onSpeakingHold(address)
|
||||
ConversationAwarenessEvent.STOP -> onSpeakingStop(address)
|
||||
}
|
||||
|
||||
private suspend fun onSpeakingStart(address: BluetoothAddress) {
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
if (primary?.address != address) {
|
||||
log(TAG) { "START from $address ignored — not primary device (primary=${primary?.address})" }
|
||||
return
|
||||
}
|
||||
val action = primary.reactions.conversationAction
|
||||
if (action == ConversationAction.NOTHING) return
|
||||
|
||||
mutex.withLock {
|
||||
val current = active
|
||||
if (current != null && current.owner == address) {
|
||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
||||
// A START also cancels a pending wind-down fuse / settle: the wearer is speaking again.
|
||||
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||
return
|
||||
}
|
||||
// A different device started speaking while we were active — undo the old one first and
|
||||
// cancel its pending timer (the no-op engage paths below would otherwise leak it).
|
||||
if (current != null) {
|
||||
revert(current, "superseded by $address")
|
||||
clearActive()
|
||||
}
|
||||
|
||||
when (action) {
|
||||
ConversationAction.PAUSE -> {
|
||||
val paused = mediaControl.sendPause(rememberForResume = false)
|
||||
if (paused) {
|
||||
val record = Active(nextId(), address, Kind.Paused, timeSource.elapsedRealtime())
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → paused media" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → nothing playing, no pause" }
|
||||
}
|
||||
}
|
||||
|
||||
ConversationAction.LOWER_VOLUME -> {
|
||||
val reduction = primary.reactions.conversationVolumeReduction
|
||||
.coerceIn(
|
||||
ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION,
|
||||
ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION,
|
||||
)
|
||||
val duck = mediaControl.duckMusicVolume(reduction)
|
||||
if (duck != null) {
|
||||
val record = Active(
|
||||
nextId(),
|
||||
address,
|
||||
Kind.Ducked(duck.priorVolume, duck.appliedVolume),
|
||||
timeSource.elapsedRealtime(),
|
||||
)
|
||||
active = record
|
||||
armTimer(record, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" }
|
||||
} else {
|
||||
active = null
|
||||
log(TAG) { "START on $address → duck no-op" }
|
||||
}
|
||||
}
|
||||
|
||||
ConversationAction.NOTHING -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speech resumed (status `5`) after a pause — the wind-down was aborted, the wearer is talking
|
||||
* again. Cancel any armed wind-down fuse and re-arm the long [STALE_TIMEOUT] backstop, exactly
|
||||
* like a duplicate-START keep-alive, so media stays paused/ducked through the conversation. Does
|
||||
* NOT engage from scratch: a RESUME with no active session is ignored (a conversation always
|
||||
* opens with a START, and a stray `5` should never start a pause/duck on its own).
|
||||
*/
|
||||
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner != address) return
|
||||
armTimer(current, TimerPhase.STALE_BACKSTOP)
|
||||
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional wind-down frame (`3` pause, `0x0B`/`4` wind-down, `7` abort). The pod is silent
|
||||
* during active speech, so this frame means the wind-down may have begun and a terminal frame is
|
||||
* imminent — but firmware sometimes drops it (#608, single-pod wear). Arm the short fuse: if no
|
||||
* terminal (or a fresh START / RESUME) follows, disengage anyway.
|
||||
*/
|
||||
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner != address) return
|
||||
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
|
||||
}
|
||||
|
||||
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
mutex.withLock {
|
||||
val current = active ?: return
|
||||
if (current.owner != address) {
|
||||
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
||||
return
|
||||
}
|
||||
// The 0x06 ear-detection frame may have arrived but not yet been processed by the
|
||||
// allStates collector — fold the latest snapshot in before deciding (in-app ordering race).
|
||||
refreshEarTransition(address)
|
||||
|
||||
// Pulling/inserting a pod makes the firmware re-key CA — a terminal (8,9) then a fresh
|
||||
// onset (1,2) around the physical change, even though the conversation continues.
|
||||
when {
|
||||
// Ear change already seen → re-key artifact. Defer to the fuse; a re-onset cancels it.
|
||||
isRecentEarTransition(address) -> {
|
||||
armTimer(current, TimerPhase.WIND_DOWN_FUSE)
|
||||
log(TAG) { "STOP from $address during ear transition — deferring to fuse" }
|
||||
}
|
||||
// Corroborated by a preceding wind-down (3,0xB,4 / abort 7): a real end → resume now.
|
||||
timerPhase == TimerPhase.WIND_DOWN_FUSE -> {
|
||||
clearActive()
|
||||
disengage(current, primary, "STOP on $address", applyAgeGuard = false)
|
||||
}
|
||||
// Cold terminal with no wind-down and no ear change yet. On primary-pod removal the
|
||||
// firmware sends the terminal tens of ms BEFORE the ear-detection frame (measured ~30ms
|
||||
// on Pro 3), so wait a short settle to see if an ear change lands before treating it as
|
||||
// a real end.
|
||||
else -> {
|
||||
armTimer(current, TimerPhase.STOP_SETTLE)
|
||||
log(TAG) { "STOP from $address — uncorroborated, settling" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful disengage (STOP event or timer expiry). Must be called under [mutex]. [applyAgeGuard]
|
||||
* gates resume on [PAUSE_RESUME_WINDOW]: `true` only for the inferred stale-backstop end (we lost
|
||||
* track and timed out — surprise-resuming long after is worse than a stranded pause); `false` for
|
||||
* an explicit terminal or the wind-down fuse, where a recent/real end signal makes resume correct.
|
||||
*/
|
||||
private suspend fun disengage(record: Active, primary: PodDevice?, reason: String, applyAgeGuard: Boolean) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Paused -> {
|
||||
// Resume the pause WE caused, regardless of the current action setting. Gating on
|
||||
// "action still == PAUSE" would strand media paused if the user switched the action
|
||||
// (or set NOTHING) mid-conversation — undoing our own side effect is the least
|
||||
// surprising behaviour. The remaining guards are about real device/playback state.
|
||||
val age = timeSource.elapsedRealtime() - record.at
|
||||
when {
|
||||
applyAgeGuard && age.milliseconds > PAUSE_RESUME_WINDOW ->
|
||||
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
|
||||
primary?.address != record.owner ->
|
||||
log(TAG) { "$reason — resume skipped (primary switched)" }
|
||||
wornForResume(primary) == false ->
|
||||
log(TAG) { "$reason — resume skipped (not worn)" }
|
||||
mediaControl.isPlaying ->
|
||||
log(TAG) { "$reason — resume skipped (already playing)" }
|
||||
else -> {
|
||||
mediaControl.sendPlay()
|
||||
log(TAG, INFO) { "$reason → resumed media" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onStatesUpdated(states: Map<BluetoothAddress, AapPodState>) = mutex.withLock {
|
||||
// Record when each device's AAP ear-detection last changed (pod removed/inserted/cased).
|
||||
for ((address, state) in states) recordEarIfChanged(address, state.aapEarDetection)
|
||||
lastEarDetection.keys.retainAll(states.keys)
|
||||
earTransitionAt.keys.retainAll(states.keys)
|
||||
|
||||
val current = active ?: return
|
||||
if (current.owner !in states.keys) {
|
||||
clearActive()
|
||||
revert(current, "owner ${current.owner} gone")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called under [mutex]. Notes an AAP ear-detection transition for [address]. The very
|
||||
* first observation of a device only seeds the baseline — it is not counted as a transition.
|
||||
*/
|
||||
private fun recordEarIfChanged(address: BluetoothAddress, ear: AapSetting.EarDetection?) {
|
||||
if (lastEarDetection.containsKey(address) && lastEarDetection[address] != ear) {
|
||||
earTransitionAt[address] = timeSource.elapsedRealtime()
|
||||
}
|
||||
lastEarDetection[address] = ear
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. Folds the latest allStates snapshot in for [address]. */
|
||||
private fun refreshEarTransition(address: BluetoothAddress) {
|
||||
recordEarIfChanged(address, aapManager.allStates.value[address]?.aapEarDetection)
|
||||
}
|
||||
|
||||
private suspend fun onMonitorCompleted() = mutex.withLock {
|
||||
val current = active ?: return
|
||||
clearActive()
|
||||
revert(current, "monitor completed")
|
||||
}
|
||||
|
||||
/** Forced revert (disconnect / supersede / shutdown): undo volume ducks; leave pauses as-is. */
|
||||
private fun revert(record: Active, reason: String) {
|
||||
when (val kind = record.kind) {
|
||||
is Kind.Ducked -> revertDuck(kind, reason)
|
||||
is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertDuck(kind: Kind.Ducked, reason: String) {
|
||||
// Restore to the pre-duck volume unconditionally — we do NOT gate on
|
||||
// currentMusicVolume() == appliedVolume. Such a guard skips the restore whenever anything
|
||||
// else moved the volume meanwhile (e.g. a concurrent volume-manager app), which lets the
|
||||
// baseline ratchet down across conversations. Restoring deterministically to the level we
|
||||
// saved at engage avoids that; the trade-off is overriding a deliberate mid-conversation
|
||||
// volume change (rare). Matches librepods' unconditional restore.
|
||||
log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (ducked to ${kind.appliedVolume}, now ${mediaControl.currentMusicVolume()}, $reason)" }
|
||||
mediaControl.restoreMusicVolume(kind.priorVolume)
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */
|
||||
private fun clearActive() {
|
||||
disengageJob?.cancel()
|
||||
disengageJob = null
|
||||
timerPhase = null
|
||||
active = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called under [mutex]. (Re)arms the single disengage timer for [record] in [phase],
|
||||
* replacing whatever was pending. On expiry [onTimerExpired] decides per phase. Guarded on both
|
||||
* [Active.id] and [phase] so a late timer can't act on a newer session or a re-armed phase.
|
||||
*/
|
||||
private fun armTimer(record: Active, phase: TimerPhase) {
|
||||
disengageJob?.cancel()
|
||||
timerPhase = phase
|
||||
val generation = ++timerGeneration
|
||||
val timeout = when (phase) {
|
||||
TimerPhase.STALE_BACKSTOP -> STALE_TIMEOUT
|
||||
TimerPhase.WIND_DOWN_FUSE -> WIND_DOWN_TIMEOUT
|
||||
TimerPhase.STOP_SETTLE -> STOP_SETTLE_DELAY
|
||||
}
|
||||
disengageJob = appScope.launch {
|
||||
delay(timeout)
|
||||
val primary = deviceMonitor.primaryDevice().first()
|
||||
mutex.withLock {
|
||||
// generation guards against a stale job re-armed to the same phase for the same record.
|
||||
if (active?.id == record.id && timerGeneration == generation) onTimerExpired(record, phase, primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called under [mutex] from inside the firing timer job. Detaches the slot WITHOUT
|
||||
* cancelling (we ARE that job — self-cancel could abort a following suspension), then acts:
|
||||
* - STOP_SETTLE + an ear change appeared meanwhile → re-key artifact, hand off to the fuse.
|
||||
* - otherwise force-end the session. The age guard applies only to the inferred STALE backstop;
|
||||
* the wind-down fuse and a settled cold terminal carry real/recent end evidence → resume regardless.
|
||||
*/
|
||||
private suspend fun onTimerExpired(record: Active, phase: TimerPhase, primary: PodDevice?) {
|
||||
disengageJob = null
|
||||
timerPhase = null
|
||||
if (phase == TimerPhase.STOP_SETTLE && isRecentEarTransition(record.owner)) {
|
||||
armTimer(record, TimerPhase.WIND_DOWN_FUSE)
|
||||
log(TAG) { "STOP settle elapsed for ${record.owner} — ear transition appeared, deferring to fuse" }
|
||||
return
|
||||
}
|
||||
active = null
|
||||
val reason = when (phase) {
|
||||
TimerPhase.STALE_BACKSTOP -> "no terminal within backstop"
|
||||
TimerPhase.WIND_DOWN_FUSE -> "no terminal within wind-down fuse"
|
||||
TimerPhase.STOP_SETTLE -> "cold terminal settled"
|
||||
}
|
||||
disengage(record, primary, reason, applyAgeGuard = phase == TimerPhase.STALE_BACKSTOP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pod is worn enough to resume into, honoring One-Pod Mode (mirrors
|
||||
* [eu.darken.capod.reaction.core.autoconnect.AutoConnect]): with One-Pod Mode on, a single pod
|
||||
* in ear counts as worn, falling back to the both-pods reading only when the single-pod signal is
|
||||
* unknown. Returns `null` when ear state is genuinely unknown — the caller treats only an explicit
|
||||
* `false` as not-worn, so an unknown stays lenient (resumes).
|
||||
*/
|
||||
private fun wornForResume(device: PodDevice): Boolean? =
|
||||
if (device.reactions.onePodMode) device.isEitherPodInEar ?: device.isBeingWorn
|
||||
else device.isBeingWorn
|
||||
|
||||
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
|
||||
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
|
||||
val at = earTransitionAt[address] ?: return false
|
||||
return (timeSource.elapsedRealtime() - at).milliseconds <= EAR_TRANSITION_WINDOW
|
||||
}
|
||||
|
||||
private fun nextId(): Long = ++idCounter
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "Conversation")
|
||||
|
||||
/**
|
||||
* Backstop fuse while engaged with no wind-down evidence yet (START/RESUME frames seen).
|
||||
* Must stay LONG: the pod sends zero frames during active speech and stays engaged against
|
||||
* ambient noise, so a short timeout here resumes media mid-conversation (the original 12s
|
||||
* value did exactly that). Only recovers a session whose entire wind-down flurry was lost
|
||||
* while the link stays up. Kept longer than [PAUSE_RESUME_WINDOW] so a back-stopped pause
|
||||
* is never auto-resumed (only a duck is restored — a stranded low volume is the worse
|
||||
* failure).
|
||||
*/
|
||||
private val STALE_TIMEOUT = 5.minutes
|
||||
|
||||
/**
|
||||
* Short fuse armed by a transitional [ConversationAwarenessEvent.HOLD] frame: the wind-down
|
||||
* has begun, so a terminal frame should follow within seconds. With only one pod worn the
|
||||
* pod deterministically drops the terminal (#608, reproduced on Pro 3 and Pro 2) — this
|
||||
* fuse disengages instead of stranding the volume low for [STALE_TIMEOUT]. Must be ≥ ~5s:
|
||||
* gaps up to 2.8s were observed between consecutive wind-down frames, and each HOLD re-arms
|
||||
* this fuse. A fresh START or RESUME (`5`, speech resumed) re-arms the long fuse instead.
|
||||
*/
|
||||
private val WIND_DOWN_TIMEOUT = 6.seconds
|
||||
|
||||
/**
|
||||
* For an *inferred* end only (the [STALE_TIMEOUT] backstop fired — we lost track of the
|
||||
* conversation), a pause older than this no longer auto-resumes: surprise playback long after
|
||||
* is worse than a stranded pause. An explicit terminal frame, or the short wind-down fuse,
|
||||
* resumes regardless of age — those carry real/recent evidence the conversation just ended.
|
||||
*/
|
||||
private val PAUSE_RESUME_WINDOW = 2.minutes
|
||||
|
||||
/**
|
||||
* A CA terminal landing within this window of an AAP ear-detection change is treated as a pod
|
||||
* removal/insertion re-key artifact (firmware emits 8,9 then 1,2 around the physical change),
|
||||
* not a real end. The artifact terminal and the ear change land within tens of ms of each other
|
||||
* (measured ~30ms on Pro 3, either side depending on which pod); a real end is seconds away.
|
||||
* Kept well under that gap so genuine terminals aren't deferred to the fuse. (The ear-detection
|
||||
* 0x06 frames are emitted on removal even when the EarDetectionEnabled setting is off, so this
|
||||
* suppression does not depend on that setting being enabled.)
|
||||
*/
|
||||
private val EAR_TRANSITION_WINDOW = 2.seconds
|
||||
|
||||
/**
|
||||
* How long to wait on an *uncorroborated cold* terminal (no preceding wind-down HOLD, no ear
|
||||
* change yet) before treating it as a real end. Covers the reverse frame-ordering on
|
||||
* primary-pod removal, where the firmware sends the terminal tens of ms BEFORE the ear-detection
|
||||
* frame (measured ~30ms on Pro 3) — too early for the backward-looking check. Only cold
|
||||
* terminals settle; a normal end
|
||||
* (`…3,0xB,4,8`) and an abort (`7,8`) are already corroborated by the fuse and resume instantly,
|
||||
* so this adds no latency to genuine conversation ends.
|
||||
*/
|
||||
private val STOP_SETTLE_DELAY = 250.milliseconds
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -179,6 +180,7 @@ class PlayPause @Inject constructor(
|
||||
onePodMode = reactions.onePodMode,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
startMusicOnWear = reactions.startMusicOnWear,
|
||||
)
|
||||
|
||||
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
|
||||
@@ -204,6 +206,7 @@ class PlayPause @Inject constructor(
|
||||
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
|
||||
isCurrentlyPlaying = isCurrentlyPlaying,
|
||||
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
|
||||
startMusicOnWear = reactions.startMusicOnWear,
|
||||
)
|
||||
pendingPlayConfirmation = confirmation.pending
|
||||
|
||||
@@ -221,6 +224,8 @@ class PlayPause @Inject constructor(
|
||||
rawDecision = confirmation.decision,
|
||||
currentState = currState,
|
||||
autoPauseEnabled = reactions.autoPause,
|
||||
now = current.ble?.seenLastAt,
|
||||
generatedAtNanos = current.ble?.scanResult?.generatedAtNanos,
|
||||
)
|
||||
pendingPauseDebounce = debounceResult.pending
|
||||
|
||||
@@ -238,7 +243,7 @@ class PlayPause @Inject constructor(
|
||||
"rawShouldPlay=${confirmation.decision.shouldPlay}"
|
||||
}
|
||||
PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) {
|
||||
"Pause debounce committed: source=$source confirmed pause"
|
||||
"Pause debounce committed: source=$source, ${debounceResult.decision.reason}"
|
||||
}
|
||||
PauseDebounceEvent.NONE -> {}
|
||||
}
|
||||
@@ -263,7 +268,7 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
|
||||
decision.shouldPause && reactions.autoPause -> {
|
||||
val pauseSent = mediaControl.sendPause()
|
||||
val pauseSent = mediaControl.sendPause(rememberForResume = true)
|
||||
log(TAG, INFO) {
|
||||
"autoPause triggered: source=$source, " +
|
||||
"wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " +
|
||||
@@ -288,10 +293,11 @@ class PlayPause @Inject constructor(
|
||||
onePodMode: Boolean,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean = false,
|
||||
startMusicOnWear: Boolean = false,
|
||||
): PlayPauseDecision = if (onePodMode) {
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||
} else {
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear)
|
||||
}
|
||||
|
||||
private fun evaluateOnePodMode(
|
||||
@@ -299,6 +305,7 @@ class PlayPause @Inject constructor(
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
startMusicOnWear: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val netChange = current.podCount - previous.podCount
|
||||
|
||||
@@ -310,8 +317,13 @@ class PlayPause @Inject constructor(
|
||||
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
|
||||
)
|
||||
|
||||
// Net increase: pod(s) inserted → play
|
||||
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
|
||||
// Net increase: pod(s) inserted → play, but only when we paused recently OR the user
|
||||
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||
// so we never override a user-initiated pause or fire over silence.
|
||||
netChange > 0 && (
|
||||
wasRecentlyPausedByUs ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
|
||||
@@ -332,13 +344,19 @@ class PlayPause @Inject constructor(
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByCap: Boolean,
|
||||
startMusicOnWear: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val wasWorn = previous.bothInEar
|
||||
val isWorn = current.bothInEar
|
||||
|
||||
return when {
|
||||
// Transition: not worn → worn, and not playing → play
|
||||
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
|
||||
// Transition: not worn → worn → play, but only when we paused recently OR the user
|
||||
// opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay
|
||||
// so we never override a user-initiated pause or fire over silence.
|
||||
!wasWorn && isWorn && (
|
||||
wasRecentlyPausedByCap ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "Normal mode: both pods in ear",
|
||||
@@ -371,6 +389,7 @@ class PlayPause @Inject constructor(
|
||||
shouldStageBleOnlyPlay: Boolean,
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
startMusicOnWear: Boolean = false,
|
||||
): PlayConfirmationResult {
|
||||
val activePending = pending?.takeIf {
|
||||
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
|
||||
@@ -378,7 +397,10 @@ class PlayPause @Inject constructor(
|
||||
|
||||
if (activePending != null &&
|
||||
currentState == activePending.targetState &&
|
||||
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
|
||||
(
|
||||
wasRecentlyPausedByUs ||
|
||||
(startMusicOnWear && !isCurrentlyPlaying)
|
||||
)
|
||||
) {
|
||||
return PlayConfirmationResult(
|
||||
decision = PlayPauseDecision(
|
||||
@@ -419,12 +441,21 @@ class PlayPause @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample-count debounce for pause decisions when the ear-detection source is an
|
||||
* unauthenticated BLE advertisement.
|
||||
* Hybrid sample-count + time-cap debounce for pause decisions when the ear-detection source
|
||||
* is an unauthenticated BLE advertisement.
|
||||
*
|
||||
* RF interference can produce a single corrupt advert that decodes as not-worn,
|
||||
* triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires
|
||||
* 3 consecutive not-worn samples before firing.
|
||||
* RF interference can produce a single corrupt advert that decodes as not-worn, triggering a
|
||||
* false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires 3 consecutive not-worn
|
||||
* samples before firing on the *count* path.
|
||||
*
|
||||
* On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full
|
||||
* sample count would take ~4s. To cap that, the pause also commits early once the not-worn
|
||||
* condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are
|
||||
* still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct*
|
||||
* radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path
|
||||
* therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed
|
||||
* uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0.
|
||||
* When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive.
|
||||
*
|
||||
* The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause]
|
||||
* — subsequent samples after the initial detection are not-worn → not-worn, and
|
||||
@@ -443,6 +474,8 @@ class PlayPause @Inject constructor(
|
||||
rawDecision: PlayPauseDecision,
|
||||
currentState: EarDetectionState,
|
||||
autoPauseEnabled: Boolean,
|
||||
now: Instant? = null,
|
||||
generatedAtNanos: Long? = null,
|
||||
): PauseDebounceResult {
|
||||
val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK ||
|
||||
source == EarDetectionSource.BLE_ANONYMOUS
|
||||
@@ -506,6 +539,8 @@ class PlayPause @Inject constructor(
|
||||
profileId = profileId,
|
||||
initialPodCount = currentState.podCount,
|
||||
confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES,
|
||||
startedAt = now,
|
||||
startedGeneratedAtNanos = generatedAtNanos,
|
||||
),
|
||||
event = PauseDebounceEvent.STARTED,
|
||||
)
|
||||
@@ -526,7 +561,10 @@ class PlayPause @Inject constructor(
|
||||
shouldPause = false,
|
||||
reason = "Debouncing pause (rebound tolerated)",
|
||||
),
|
||||
pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1),
|
||||
pending = activePending.copy(
|
||||
resetTolerance = activePending.resetTolerance - 1,
|
||||
reboundTolerated = true,
|
||||
),
|
||||
event = PauseDebounceEvent.ADVANCED,
|
||||
)
|
||||
}
|
||||
@@ -535,12 +573,41 @@ class PlayPause @Inject constructor(
|
||||
|
||||
// Confirmation: count <= initialPodCount, decrement remaining.
|
||||
val remaining = activePending.confirmationsRemaining - 1
|
||||
if (remaining <= 0) {
|
||||
val commitOnCount = remaining <= 0
|
||||
|
||||
// Time-cap early commit: once the not-worn condition has persisted at least
|
||||
// PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the
|
||||
// pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep
|
||||
// the "≥2 consecutive, distinct not-worn receptions" invariant:
|
||||
// - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time
|
||||
// path is disabled and we fall back to pure sample-count.
|
||||
// - distinct generatedAtNanos: the confirming sample must be a different radio reception
|
||||
// than the first, so a stack re-delivering one cached advert in a later batch (fresh
|
||||
// seenLastAt, same reception) cannot early-commit on a single physical advert. A
|
||||
// broken/constant OEM timebase just disables the time path (fail-safe to count).
|
||||
// - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing
|
||||
// snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can
|
||||
// only delay, never prematurely fire.
|
||||
val startedAt = activePending.startedAt
|
||||
val startedNanos = activePending.startedGeneratedAtNanos
|
||||
val elapsed = if (startedAt != null && now != null) {
|
||||
Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val commitOnTime = !activePending.reboundTolerated &&
|
||||
elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP &&
|
||||
startedNanos != null && generatedAtNanos != null &&
|
||||
generatedAtNanos != startedNanos
|
||||
|
||||
if (commitOnCount || commitOnTime) {
|
||||
val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)"
|
||||
return PauseDebounceResult(
|
||||
decision = PlayPauseDecision(
|
||||
shouldPlay = false,
|
||||
shouldPause = true,
|
||||
reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
||||
reason = "Debounced pause confirmed via $mode " +
|
||||
"(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})",
|
||||
),
|
||||
pending = null,
|
||||
event = PauseDebounceEvent.COMMITTED,
|
||||
@@ -655,6 +722,18 @@ class PlayPause @Inject constructor(
|
||||
// shows a pod returning shouldn't kill the pending, since the next sample may
|
||||
// confirm the pods are still out.
|
||||
val resetTolerance: Int = 1,
|
||||
// Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first
|
||||
// not-worn sample. null → the time-cap early commit is inactive for this pending.
|
||||
val startedAt: Instant? = null,
|
||||
// Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first
|
||||
// not-worn sample. The time-cap commit requires the confirming sample to be a
|
||||
// DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the
|
||||
// same cached advert in a later batch cannot early-commit on one physical advert.
|
||||
val startedGeneratedAtNanos: Long? = null,
|
||||
// Set once a count-up rebound has been tolerated. Disables the time-cap early commit
|
||||
// (the not-worn samples are no longer strictly consecutive), falling back to pure
|
||||
// sample-count confirmation.
|
||||
val reboundTolerated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */
|
||||
@@ -782,5 +861,19 @@ class PlayPause @Inject constructor(
|
||||
* decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total.
|
||||
*/
|
||||
internal const val PAUSE_DEBOUNCE_SAMPLES = 2
|
||||
|
||||
/**
|
||||
* Upper bound on how long the sample-count debounce is allowed to stretch. Once the
|
||||
* not-worn condition has persisted this long (measured from the first not-worn sample's
|
||||
* observation time) with at least one further *distinct* not-worn reception and no
|
||||
* tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't
|
||||
* been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver
|
||||
* scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s.
|
||||
*
|
||||
* Does not weaken corruption protection: an early commit still requires ≥2 consecutive,
|
||||
* distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value
|
||||
* see no change (count commits first). Tunable.
|
||||
*/
|
||||
internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.common.flow.withPrevious
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import java.time.Duration
|
||||
@@ -32,10 +34,14 @@ class PopUpReaction @Inject constructor(
|
||||
|
||||
private val caseCoolDowns = java.util.concurrent.ConcurrentHashMap<String, Instant>()
|
||||
|
||||
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
|
||||
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDeviceByTier
|
||||
.distinctUntilChangedBy {
|
||||
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid).
|
||||
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex)
|
||||
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
|
||||
// lid state. The latter is essential: caseLidState is recovered from history, so it can
|
||||
// flip OPEN<->CLOSED while the *selected* frame's raw bytes stay identical (e.g. a steady
|
||||
// out-of-case frame while a sibling in-case frame updates). Keying on rawDataHex alone
|
||||
// would swallow that transition and the popup would miss its show/hide.
|
||||
listOf(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex, it?.caseLidState)
|
||||
}
|
||||
.withPrevious()
|
||||
.setupCommonEventHandlers(TAG) { "popUpCase" }
|
||||
@@ -71,7 +77,7 @@ class PopUpReaction @Inject constructor(
|
||||
throttleCasePopUps(current)
|
||||
}
|
||||
|
||||
private fun throttleCasePopUps(current: PodDevice): Event? {
|
||||
internal fun throttleCasePopUps(current: PodDevice): Event? {
|
||||
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
|
||||
val now = timeSource.now()
|
||||
val lastShown = caseCoolDowns[cooldownKey]
|
||||
@@ -95,9 +101,9 @@ class PopUpReaction @Inject constructor(
|
||||
}
|
||||
|
||||
decision.shouldHide -> {
|
||||
if (!decision.shouldResetCooldown) {
|
||||
caseCoolDowns[cooldownKey] = now
|
||||
}
|
||||
// Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
|
||||
// here would let a transient UNKNOWN (e.g. a brief out-of-case frame) suppress a
|
||||
// genuine OPEN for the whole cooldown window. CLOSED still resets it above.
|
||||
Event.PopupHide(now)
|
||||
}
|
||||
|
||||
@@ -115,7 +121,7 @@ class PopUpReaction @Inject constructor(
|
||||
|
||||
private fun monitorConnection(): Flow<Event> = combine(
|
||||
bluetoothManager.connectedDevices,
|
||||
deviceMonitor.primaryDevice().distinctUntilChangedBy {
|
||||
deviceMonitor.primaryDeviceByTier.distinctUntilChangedBy {
|
||||
Triple(it?.profileId, it?.reactions?.showPopUpOnConnection, it?.rawDataHex)
|
||||
},
|
||||
) { devices, broadcast ->
|
||||
@@ -192,7 +198,48 @@ class PopUpReaction @Inject constructor(
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "popUpConnection" }
|
||||
|
||||
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
|
||||
/**
|
||||
* Backstop for case-open popups that never receive a CLOSED frame because the device left BLE
|
||||
* range while the lid was open — otherwise the overlay lingers until manually dismissed (one of
|
||||
* the symptoms in #598). A ticker re-checks the primary device's freshness; once a previously
|
||||
* fresh OPEN broadcast goes stale past [CASE_OPEN_STALE_TIMEOUT] (no newer advertisement, or the
|
||||
* device dropped to cache-only), a single Hide is emitted. The lid-driven [monitorCase] still
|
||||
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
|
||||
*/
|
||||
private fun monitorCaseStaleClose(): Flow<Event> = combine(
|
||||
deviceMonitor.primaryDeviceByTier,
|
||||
staleCheckTicker(),
|
||||
) { device, _ -> isCaseOpenBroadcastFresh(device) }
|
||||
.distinctUntilChanged()
|
||||
.withPrevious()
|
||||
.mapNotNull { (wasFresh, isFresh) ->
|
||||
if (wasFresh == true && !isFresh) {
|
||||
log(TAG) { "Case-open broadcast went stale, emitting Hide" }
|
||||
Event.PopupHide(timeSource.now())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "popUpCaseStale" }
|
||||
|
||||
/** True while the primary device is eligible and currently advertising a fresh OPEN lid. */
|
||||
internal fun isCaseOpenBroadcastFresh(device: PodDevice?): Boolean {
|
||||
if (device?.reactions?.showPopUpOnCaseOpen != true) return false
|
||||
if (device.caseLidState != DualApplePods.LidState.OPEN) return false
|
||||
// Track BLE freshness specifically, not PodDevice.seenLastAt (which also counts AAP/cache):
|
||||
// the lid is a BLE-only signal, so a live AAP socket must not keep a stale OPEN on screen.
|
||||
val bleSeenLastAt = device.ble?.seenLastAt ?: return false
|
||||
return Duration.between(bleSeenLastAt, timeSource.now()) <= CASE_OPEN_STALE_TIMEOUT
|
||||
}
|
||||
|
||||
private fun staleCheckTicker(): Flow<Unit> = flow {
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(STALE_CHECK_INTERVAL.toMillis())
|
||||
}
|
||||
}
|
||||
|
||||
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection(), monitorCaseStaleClose())
|
||||
|
||||
sealed class Event {
|
||||
data class PopupShow(
|
||||
@@ -296,5 +343,11 @@ class PopUpReaction @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "PopUp")
|
||||
|
||||
/** A case-open popup is force-dismissed once its OPEN broadcast hasn't refreshed for this long. */
|
||||
private val CASE_OPEN_STALE_TIMEOUT: Duration = Duration.ofSeconds(4)
|
||||
|
||||
/** How often the stale-close backstop re-evaluates freshness. */
|
||||
private val STALE_CHECK_INTERVAL: Duration = Duration.ofSeconds(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import eu.darken.capod.common.TimeSource
|
||||
import eu.darken.capod.common.bluetooth.ScannerMode
|
||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||
import eu.darken.capod.common.datastore.valueBlocking
|
||||
import eu.darken.capod.common.debug.DebugSettings
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.uix.ViewModel4
|
||||
@@ -23,14 +23,13 @@ import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -43,12 +42,14 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
private val bleScanModeController: BleScanModeController,
|
||||
private val deviceMonitor: DeviceMonitor,
|
||||
private val debugSettings: DebugSettings,
|
||||
private val timeSource: TimeSource,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
|
||||
|
||||
/** Guards against re-entrant runs (e.g. a double tap on "Try again"). */
|
||||
private val runLock = Mutex()
|
||||
|
||||
data class State(val bleState: BleState)
|
||||
|
||||
val state = _bleState.map { State(it) }.asLiveState()
|
||||
@@ -84,166 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
|
||||
if (!runLock.tryLock()) {
|
||||
log(TAG, WARN) { "troubleShootBle() ignored, a run is already in progress" }
|
||||
return@launch
|
||||
}
|
||||
log(TAG, INFO) { "troubleShootBle()" }
|
||||
|
||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@override
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
}
|
||||
}
|
||||
try {
|
||||
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
|
||||
// The combo under which supported headphones were detected (set in sweep 2).
|
||||
var supportedCombo: BlePodMonitor.CompatOverride? = null
|
||||
// Set only when the run reaches a terminal success. Persisted on exit; staying null
|
||||
// (any failure, or cancellation) means we just clear the override, which restores the
|
||||
// user's original — never-touched — settings.
|
||||
var comboToPersist: BlePodMonitor.CompatOverride? = null
|
||||
try {
|
||||
run {
|
||||
progress("Checking for headphones...")
|
||||
if (findLiveBleHeadphones()) {
|
||||
success("Headphones found, nothing to troubleshoot.")
|
||||
return@override
|
||||
} else {
|
||||
progress("Headphones not detected.\n")
|
||||
}
|
||||
}
|
||||
|
||||
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ hardwareFilteringDisabled,
|
||||
hardwareBatchingDisabled,
|
||||
indirectCallback,
|
||||
unfiltered ->
|
||||
val sb = StringBuilder("SCAN - Settings: ")
|
||||
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
|
||||
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
|
||||
sb.append("indirectCallback=$indirectCallback, ")
|
||||
sb.append("unfiltered=$unfiltered")
|
||||
progress(sb.toString())
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
|
||||
debugSettings.showUnfiltered.valueBlocking = unfiltered
|
||||
val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
|
||||
{ combo, unfiltered ->
|
||||
progress(
|
||||
"SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
|
||||
"batchingDisabled=${combo.offloadedBatchingDisabled}, " +
|
||||
"indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
|
||||
)
|
||||
blePodMonitor.setCompatOverride(combo)
|
||||
blePodMonitor.setUnfilteredOverride(unfiltered)
|
||||
val devices = collectFreshDevices()
|
||||
log(TAG) { "SCAN: Fresh BLE devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val devices = withTimeoutOrNull(STEP_TIME) {
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
log(TAG) { "SCAN: BLE Devices: $devices" }
|
||||
if (devices.isNotEmpty()) {
|
||||
progress("SCAN: Received data from ${devices.size} BLE devices")
|
||||
devices
|
||||
} else {
|
||||
progress("SCAN: No data received")
|
||||
devices
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
|
||||
if (!gotData) {
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("We received at least some BLE data.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
|
||||
doScan(combo, false).any { it !is UnknownSnapshotBle }
|
||||
}
|
||||
if (supportedCombo == null) {
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
if (findLiveBleHeadphones()) {
|
||||
comboToPersist = supportedCombo
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = collectFreshDevices()
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxByOrNull { it.signalQuality }
|
||||
|
||||
if (candidate == null) {
|
||||
failure(
|
||||
"No supported headphones found near your device.",
|
||||
BleState.Result.Failure.Type.HEADPHONES,
|
||||
)
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
if (findLiveBleHeadphones()) {
|
||||
comboToPersist = supportedCombo
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
blePodMonitor.setUnfilteredOverride(false)
|
||||
try {
|
||||
// Persist the winning combo before dropping the override so the effective scan
|
||||
// settings stay equal with no restart flicker. Done in its own try so the
|
||||
// override is still cleared (restoring originals) even if a write throws.
|
||||
comboToPersist?.let { persistCompat(it) }
|
||||
} finally {
|
||||
blePodMonitor.setCompatOverride(null)
|
||||
}
|
||||
}
|
||||
|
||||
run {
|
||||
progress("Checking if we can receive BLE data at all.")
|
||||
if (doScan(false, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, true, false, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, true, true).isNotEmpty()) return@run
|
||||
if (doScan(true, false, false, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, true, true).isNotEmpty()) return@run
|
||||
if (doScan(false, true, false, true).isNotEmpty()) return@run
|
||||
|
||||
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
debugSettings.showUnfiltered.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("We received at least some BLE data.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for supported headphones.")
|
||||
|
||||
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
|
||||
|
||||
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
|
||||
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = false
|
||||
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("Found some headphones that are supported by CAPod.\n")
|
||||
|
||||
run {
|
||||
progress("Checking for your headphones with new BLE settings...")
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
if (mainDevice != null) {
|
||||
success("Found your headphones, new BLE settings worked :)!")
|
||||
return@override
|
||||
}
|
||||
}
|
||||
|
||||
progress("Still no headphones detected that count as yours.\n")
|
||||
|
||||
run {
|
||||
progress("Checking all closeby headphones.")
|
||||
|
||||
val otherDevices = withTimeoutOrNull(STEP_TIME) {
|
||||
val start = timeSource.elapsedRealtime()
|
||||
blePodMonitor.devices
|
||||
.take(10)
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.toList()
|
||||
.flatten()
|
||||
.distinctBy { it.address }
|
||||
} ?: emptyList()
|
||||
|
||||
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
|
||||
|
||||
if (otherDevices.isEmpty()) {
|
||||
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
return@override
|
||||
}
|
||||
|
||||
progress("Headphones found nearby, but not detected as yours.\n")
|
||||
progress("Creating profile for closest headphones.")
|
||||
|
||||
val candidate = otherDevices
|
||||
.filter { it !is UnknownSnapshotBle }
|
||||
.maxBy { it.signalQuality }
|
||||
|
||||
log(TAG, INFO) { "Candidate is $candidate" }
|
||||
|
||||
profilesRepo.addProfile(
|
||||
profile = AppleDeviceProfile(
|
||||
label = context.getString(R.string.troubleshooter_title),
|
||||
model = candidate.model,
|
||||
),
|
||||
addFirst = true,
|
||||
)
|
||||
|
||||
val mainDevice = withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
|
||||
}
|
||||
|
||||
if (mainDevice != null) {
|
||||
success("Success! Detected your headphones.")
|
||||
} else {
|
||||
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits up to [STEP_TIME] for the primary profile to be backed by a *fresh, live BLE*
|
||||
* observation. A cached-only / AAP-only primary does not count — the troubleshooter is about
|
||||
* whether BLE advertisements are actually reaching us. The freshness cutoff (snapshot seen at or
|
||||
* after this call) means it reflects the currently-active scan settings and doesn't rely on the
|
||||
* device cache having been cleared beforehand.
|
||||
*/
|
||||
private suspend fun findLiveBleHeadphones(): Boolean {
|
||||
val threshold = timeSource.now()
|
||||
return withTimeoutOrNull(STEP_TIME) {
|
||||
deviceMonitor.primaryDevice().firstOrNull { device ->
|
||||
device?.ble != null && (device.seenLastAt?.let { it >= threshold } == true)
|
||||
} != null
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects BLE devices observed *after the current scan settings take effect*. Option changes
|
||||
* restart the scan after a throttle, and [BlePodMonitor] keeps a 20s device cache, so without a
|
||||
* freshness cutoff a stale observation from a previous combo could be mistaken for a "win".
|
||||
*/
|
||||
private suspend fun collectFreshDevices(): List<BlePodSnapshot> {
|
||||
// Drop anything cached under a previous combo so it can't satisfy this attempt.
|
||||
blePodMonitor.clearDeviceCache()
|
||||
val freshThreshold = timeSource.now().plusMillis(SCAN_SETTLE_MS)
|
||||
val start = timeSource.elapsedRealtime()
|
||||
val collected = mutableListOf<BlePodSnapshot>()
|
||||
// Accumulate as we go: a timeout must not discard what we already observed. toList() only
|
||||
// returns once the flow completes, which a quiet channel may never do within the window.
|
||||
withTimeoutOrNull(STEP_TIME) {
|
||||
blePodMonitor.devices
|
||||
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
|
||||
.collect { snapshots -> collected.addAll(snapshots) }
|
||||
}
|
||||
return collected
|
||||
.filter { it.seenLastAt >= freshThreshold }
|
||||
.distinctBy { it.address }
|
||||
}
|
||||
|
||||
private fun persistCompat(combo: BlePodMonitor.CompatOverride) {
|
||||
generalSettings.isOffloadedFilteringDisabled.valueBlocking = combo.offloadedFilteringDisabled
|
||||
generalSettings.isOffloadedBatchingDisabled.valueBlocking = combo.offloadedBatchingDisabled
|
||||
generalSettings.useIndirectScanResultCallback.valueBlocking = combo.indirectCallback
|
||||
}
|
||||
|
||||
sealed class BleState {
|
||||
class Intro : BleState()
|
||||
|
||||
@@ -294,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
|
||||
companion object {
|
||||
const val STEP_TIME = 10 * 1000L
|
||||
|
||||
/**
|
||||
* Grace period after switching compat settings before an observation counts as "fresh".
|
||||
* Covers [BlePodMonitor]'s ~1s scan-option throttle plus the scanner restart.
|
||||
*/
|
||||
const val SCAN_SETTLE_MS = 1500L
|
||||
|
||||
/**
|
||||
* Compatibility combinations to probe, ordered fewest-disables-first so the first one that
|
||||
* works (and gets persisted) is the *minimal* set of overrides — e.g. a phone that only
|
||||
* needs batching disabled won't also get filtering disabled. Triple semantics:
|
||||
* (offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).
|
||||
*/
|
||||
val COMPAT_COMBOS: List<BlePodMonitor.CompatOverride> = listOf(
|
||||
BlePodMonitor.CompatOverride(false, false, false), // baseline (no overrides)
|
||||
BlePodMonitor.CompatOverride(false, true, false), // batching only
|
||||
BlePodMonitor.CompatOverride(true, false, false), // filtering only
|
||||
BlePodMonitor.CompatOverride(false, false, true), // indirect callback only
|
||||
BlePodMonitor.CompatOverride(false, true, true), // batching + indirect
|
||||
BlePodMonitor.CompatOverride(true, false, true), // filtering + indirect
|
||||
BlePodMonitor.CompatOverride(true, true, false), // filtering + batching
|
||||
BlePodMonitor.CompatOverride(true, true, true), // everything
|
||||
)
|
||||
|
||||
val TAG = logTag("TroubleShooter", "VM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<clip>
|
||||
<shape android:shape="rectangle">
|
||||
<corners android:radius="4dp" />
|
||||
<solid android:color="?android:attr/colorAccent" />
|
||||
<solid android:color="@color/brand_primary" />
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
android:id="@+id/pod_left_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/device_airpods_gen1_left" />
|
||||
|
||||
<ProgressBar
|
||||
@@ -45,7 +45,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="95%" />
|
||||
tools:text="95% · 5h 40m" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
@@ -58,8 +58,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
|
||||
<ImageView
|
||||
@@ -67,8 +67,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_hearing_24" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -88,8 +88,8 @@
|
||||
android:id="@+id/pod_case_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/device_airpods_gen1_case" />
|
||||
|
||||
<ProgressBar
|
||||
@@ -122,8 +122,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -142,8 +142,8 @@
|
||||
android:id="@+id/pod_right_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/device_airpods_gen1_right" />
|
||||
|
||||
<ProgressBar
|
||||
@@ -163,7 +163,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="92%" />
|
||||
tools:text="92% · 5h 25m" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="36dp"
|
||||
@@ -176,8 +176,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
|
||||
<ImageView
|
||||
@@ -185,8 +185,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tintMode="multiply"
|
||||
android:tint="?android:attr/colorAccent"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_hearing_24" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
|
||||
<ImageView
|
||||
@@ -46,6 +48,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_hearing_24" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -76,6 +80,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -105,6 +111,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
|
||||
<ImageView
|
||||
@@ -113,6 +121,8 @@
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="2dp"
|
||||
android:tint="@color/notification_icon_tint"
|
||||
android:tintMode="src_in"
|
||||
android:src="@drawable/ic_baseline_hearing_24" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user