mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
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.
This commit is contained in:
@@ -358,7 +358,8 @@ fun DeviceSettingsScreen(
|
|||||||
if (device.isAapConnected) {
|
if (device.isAapConnected) {
|
||||||
val convAwareness = device.conversationalAwareness
|
val convAwareness = device.conversationalAwareness
|
||||||
val hasAnyAapReaction =
|
val hasAnyAapReaction =
|
||||||
(features.hasConversationAwareness && convAwareness != null) || features.hasSleepDetection
|
(features.hasConversationAwareness && convAwareness != null) ||
|
||||||
|
features.hasSleepDetection
|
||||||
if (features.hasConversationAwareness && convAwareness != null) {
|
if (features.hasConversationAwareness && convAwareness != null) {
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon = Icons.TwoTone.Hearing,
|
icon = Icons.TwoTone.Hearing,
|
||||||
|
|||||||
+34
-4
@@ -302,14 +302,44 @@ class DeviceSettingsViewModel @Inject constructor(
|
|||||||
launch { updateProfileNow { it.copy(onePodMode = enabled) } }
|
launch { updateProfileNow { it.copy(onePodMode = enabled) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAutoPlay(enabled: Boolean) {
|
fun setAutoPlay(enabled: Boolean) = launch {
|
||||||
log(TAG, INFO) { "setAutoPlay($enabled)" }
|
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)" }
|
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 {
|
fun setAutoConnect(enabled: Boolean) = launch {
|
||||||
|
|||||||
@@ -271,10 +271,17 @@ enum class PodModel(
|
|||||||
Features(
|
Features(
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
|
hasAdaptiveAnc = true,
|
||||||
|
hasConversationAwareness = true,
|
||||||
hasPressSpeed = true,
|
hasPressSpeed = true,
|
||||||
hasPressHoldDuration = true,
|
hasPressHoldDuration = true,
|
||||||
|
hasPersonalizedVolume = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
|
hasAdaptiveAudioNoise = true,
|
||||||
hasEarDetectionToggle = true,
|
hasEarDetectionToggle = true,
|
||||||
|
hasListeningModeCycle = true,
|
||||||
|
hasAllowOffOption = true,
|
||||||
|
needsInitExt = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A3454"), // headphones
|
modelNumbers = setOf("A3454"), // headphones
|
||||||
),
|
),
|
||||||
@@ -410,6 +417,8 @@ enum class PodModel(
|
|||||||
hasCase = true,
|
hasCase = true,
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
|
hasEarDetectionToggle = true,
|
||||||
|
hasSleepDetection = true,
|
||||||
),
|
),
|
||||||
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
|
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
|
||||||
leftPodIconRes = R.drawable.device_powerbeats_pro2_left,
|
leftPodIconRes = R.drawable.device_powerbeats_pro2_left,
|
||||||
|
|||||||
+71
@@ -339,5 +339,76 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
|
|||||||
f.hasListeningModeCycle shouldBe false
|
f.hasListeningModeCycle shouldBe false
|
||||||
f.hasStemConfig 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -65,6 +65,7 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
|||||||
fun `null for basic AirPods`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN3).encodeInitExt().shouldBeNull() }
|
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 Pro 1`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO).encodeInitExt().shouldBeNull() }
|
||||||
@Test fun `null for Max`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX).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
|
@Test
|
||||||
fun `has correct command byte`() {
|
fun `has correct command byte`() {
|
||||||
@@ -109,6 +110,13 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
|||||||
AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY,
|
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 ───────────────────────────────
|
// ── ANC Mode encode/decode ───────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user