Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`() {
Expand Down Expand Up @@ -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 ───────────────────────────────
Expand Down