From 5bd10a5f54bd01378ff7b28375f54d7c87407f90 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 15 Apr 2026 06:44:10 +0200 Subject: [PATCH] fix(aap): Update feature flags for Max 2 and Powerbeats Pro 2, sync ear detection Add H2-chip feature flags (adaptive ANC, conversation awareness, personalized volume, listening mode cycle, InitExt) to AirPods Max 2. Add sleep detection and ear detection toggle to Powerbeats Pro 2. Auto-sync device ear detection setting when auto-play/auto-pause reactions are toggled. Add flag invariant tests to prevent future drift. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 3 +- .../devicesettings/DeviceSettingsViewModel.kt | 38 ++++++++-- .../darken/capod/pods/core/apple/PodModel.kt | 9 +++ .../DefaultAapDeviceProfileNewSettingsTest.kt | 71 +++++++++++++++++++ .../devices/DefaultAapDeviceProfileTest.kt | 8 +++ 5 files changed, 124 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 0c2c578a..6c43e76e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -358,7 +358,8 @@ fun DeviceSettingsScreen( if (device.isAapConnected) { val convAwareness = device.conversationalAwareness val hasAnyAapReaction = - (features.hasConversationAwareness && convAwareness != null) || features.hasSleepDetection + (features.hasConversationAwareness && convAwareness != null) || + features.hasSleepDetection if (features.hasConversationAwareness && convAwareness != null) { SettingsSwitchItem( icon = Icons.TwoTone.Hearing, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 84f97426..a64e1a74 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -302,14 +302,44 @@ class DeviceSettingsViewModel @Inject constructor( launch { updateProfileNow { it.copy(onePodMode = enabled) } } } - fun setAutoPlay(enabled: Boolean) { + fun setAutoPlay(enabled: Boolean) = launch { log(TAG, INFO) { "setAutoPlay($enabled)" } - proGatedReaction(enabled) { it.copy(autoPlay = enabled) } + if (enabled && !upgradeRepo.isPro()) { + navTo(Nav.Main.Upgrade) + return@launch + } + updateProfileNow { it.copy(autoPlay = enabled) } + syncEarDetection(autoPlay = enabled) } - fun setAutoPause(enabled: Boolean) { + fun setAutoPause(enabled: Boolean) = launch { log(TAG, INFO) { "setAutoPause($enabled)" } - proGatedReaction(enabled) { it.copy(autoPause = enabled) } + if (enabled && !upgradeRepo.isPro()) { + navTo(Nav.Main.Upgrade) + return@launch + } + updateProfileNow { it.copy(autoPause = enabled) } + syncEarDetection(autoPause = enabled) + } + + /** + * Keeps the device-side Automatic Ear Detection setting in sync with the + * auto-play / auto-pause reaction toggles. When either reaction is active + * the device must report ear-in / ear-out events; when both are off the + * setting is disabled to match the user's intent. + * + * Only sends when the model supports the setting and AAP is ready — + * silent no-op otherwise (reactions are per-profile and work offline). + */ + private suspend fun syncEarDetection(autoPlay: Boolean? = null, autoPause: Boolean? = null) { + val profileId = targetProfileId.value ?: return + val device = deviceMonitor.getDeviceForProfile(profileId) ?: return + if (device.model?.features?.hasEarDetectionToggle != true) return + if (!device.isAapReady) return + val reactions = device.reactions + val effectiveAutoPlay = autoPlay ?: reactions.autoPlay + val effectiveAutoPause = autoPause ?: reactions.autoPause + sendInternal(AapCommand.SetEarDetectionEnabled(effectiveAutoPlay || effectiveAutoPause)) } fun setAutoConnect(enabled: Boolean) = launch { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index 4f0d2743..0877c079 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -271,10 +271,17 @@ enum class PodModel( Features( hasEarDetection = true, hasAncControl = true, + hasAdaptiveAnc = true, + hasConversationAwareness = true, hasPressSpeed = true, hasPressHoldDuration = true, + hasPersonalizedVolume = true, hasToneVolume = true, + hasAdaptiveAudioNoise = true, hasEarDetectionToggle = true, + hasListeningModeCycle = true, + hasAllowOffOption = true, + needsInitExt = true, ), modelNumbers = setOf("A3454"), // headphones ), @@ -410,6 +417,8 @@ enum class PodModel( hasCase = true, hasEarDetection = true, hasAncControl = true, + hasEarDetectionToggle = true, + hasSleepDetection = true, ), modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case leftPodIconRes = R.drawable.device_powerbeats_pro2_left, diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt index 98976a82..a6e4759d 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -339,5 +339,76 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { f.hasListeningModeCycle shouldBe false f.hasStemConfig shouldBe false } + + @Test fun `Max 2 has H2 features`() { + val f = PodModel.AIRPODS_MAX2.features + f.hasAdaptiveAnc shouldBe true + f.hasConversationAwareness shouldBe true + f.hasPersonalizedVolume shouldBe true + f.hasAdaptiveAudioNoise shouldBe true + f.hasListeningModeCycle shouldBe true + f.hasAllowOffOption shouldBe true + f.hasEarDetectionToggle shouldBe true + f.needsInitExt shouldBe true + // Headphone — no stem/swipe/dual-pod/case features + f.hasDualPods shouldBe false + f.hasCase shouldBe false + f.hasStemConfig shouldBe false + f.hasVolumeSwipe shouldBe false + f.hasSleepDetection shouldBe false + } + + @Test fun `Powerbeats Pro 2 has sleep detection and ear detection toggle`() { + val f = PodModel.POWERBEATS_PRO2.features + f.hasSleepDetection shouldBe true + f.hasEarDetectionToggle shouldBe true + f.hasAncControl shouldBe true + f.hasEarDetection shouldBe true + } + } + + // ── Feature Flag Invariants ───────────────────────────── + + @Nested + inner class FeatureFlagInvariants { + @Test fun `adaptiveAnc implies ancControl`() { + for (model in PodModel.entries) { + if (model.features.hasAdaptiveAnc) { + model.features.hasAncControl shouldBe true + } + } + } + + @Test fun `listeningModeCycle implies ancControl`() { + for (model in PodModel.entries) { + if (model.features.hasListeningModeCycle) { + model.features.hasAncControl shouldBe true + } + } + } + + @Test fun `sleepDetection implies earDetection`() { + for (model in PodModel.entries) { + if (model.features.hasSleepDetection) { + model.features.hasEarDetection shouldBe true + } + } + } + + @Test fun `adaptiveAudioNoise implies adaptiveAnc`() { + for (model in PodModel.entries) { + if (model.features.hasAdaptiveAudioNoise) { + model.features.hasAdaptiveAnc shouldBe true + } + } + } + + @Test fun `allowOffOption implies listeningModeCycle`() { + for (model in PodModel.entries) { + if (model.features.hasAllowOffOption) { + model.features.hasListeningModeCycle shouldBe true + } + } + } } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt index 02b21728..cc3091c4 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileTest.kt @@ -65,6 +65,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { fun `null for basic AirPods`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN3).encodeInitExt().shouldBeNull() } @Test fun `null for Pro 1`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO).encodeInitExt().shouldBeNull() } @Test fun `null for Max`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX).encodeInitExt().shouldBeNull() } + @Test fun `returned for Max 2`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX2).encodeInitExt().shouldNotBeNull() } @Test fun `has correct command byte`() { @@ -109,6 +110,13 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() { AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, ) } + + @Test + fun `Max 2 supports OFF, ON, TRANSPARENCY, ADAPTIVE`() { + ancModesFor(PodModel.AIRPODS_MAX2) shouldContainExactly listOf( + AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE, + ) + } } // ── ANC Mode encode/decode ───────────────────────────────