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 1e1f9e49..7ceebe5b 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 @@ -158,6 +158,7 @@ fun DeviceSettingsScreenHost( onNavigateUp = { vm.navUp() }, onAncModeChange = { vm.setAncMode(it) }, onConversationalAwarenessChange = { vm.setConversationalAwareness(it) }, + onNcWithOneAirPodChange = { vm.setNcWithOneAirPod(it) }, onPersonalizedVolumeChange = { vm.setPersonalizedVolume(it) }, onToneVolumeChange = { vm.setToneVolume(it) }, onAdaptiveAudioNoiseChange = { vm.setAdaptiveAudioNoise(it) }, @@ -194,6 +195,7 @@ fun DeviceSettingsScreen( onNavigateUp: () -> Unit, onAncModeChange: (AapSetting.AncMode.Value) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {}, + onNcWithOneAirPodChange: (Boolean) -> Unit = {}, onPersonalizedVolumeChange: (Boolean) -> Unit = {}, onToneVolumeChange: (Int) -> Unit = {}, onAdaptiveAudioNoiseChange: (Int) -> Unit = {}, @@ -369,10 +371,10 @@ fun DeviceSettingsScreen( requiresUpgrade = !isPro, ) if (features.hasDualPods) { - val onePodModeActive = reactions.autoPlay || + val onePodModeActive = reactions.onePodMode || + reactions.autoPlay || reactions.autoPause || - (reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR) || - features.hasNcOneAirpod + (reactions.autoConnect && reactions.autoConnectCondition == AutoConnectCondition.IN_EAR) SettingsBaseItem( title = stringResource(R.string.settings_onepod_mode_label), subtitle = stringResource(R.string.settings_onepod_mode_description), @@ -547,14 +549,27 @@ fun DeviceSettingsScreen( onModeSelected = onAncModeChange, enabled = enabled, ) + val ncWithOneAirPod = device.ncWithOneAirPod + val showNcWithOneAirPod = features.hasNcOneAirpod && ncWithOneAirPod != null val hasNoiseExtras = (features.hasAdaptiveAudioNoise && adaptiveNoise != null) || - features.hasListeningModeCycle + features.hasListeningModeCycle || + showNcWithOneAirPod if (hasNoiseExtras) { HorizontalDivider( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant, ) } + if (showNcWithOneAirPod) { + SettingsSwitchItem( + icon = Icons.TwoTone.LooksOne, + title = stringResource(R.string.device_settings_nc_one_airpod_label), + subtitle = stringResource(R.string.device_settings_nc_one_airpod_description), + checked = ncWithOneAirPod!!.enabled, + onCheckedChange = onNcWithOneAirPodChange, + enabled = enabled, + ) + } if (features.hasListeningModeCycle && cycleMask != null && cycleSubtitle != null) { SettingsPreferenceItem( icon = Icons.TwoTone.Loop, 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 f3332bcb..f9e4b1d3 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 @@ -226,6 +226,8 @@ class DeviceSettingsViewModel @Inject constructor( fun setConversationalAwareness(enabled: Boolean) = send(AapCommand.SetConversationalAwareness(enabled)) + fun setNcWithOneAirPod(enabled: Boolean) = send(AapCommand.SetNcWithOneAirPod(enabled)) + fun setPersonalizedVolume(enabled: Boolean) = send(AapCommand.SetPersonalizedVolume(enabled)) fun setToneVolume(level: Int) = sendProGated(AapCommand.SetToneVolume(level)) @@ -317,11 +319,6 @@ class DeviceSettingsViewModel @Inject constructor( log(TAG, INFO) { "setOnePodMode($enabled)" } launch { updateProfileNow { it.copy(onePodMode = enabled) } - // Opportunistic immediate sync — NcOnePodSender handles deferred/reconnect - val device = deviceMonitor.getDeviceForProfile(targetProfileId.value ?: return@launch) - if (device != null && device.isAapConnected && device.model.features.hasNcOneAirpod) { - sendInternal(AapCommand.SetNcWithOneAirPod(enabled)) - } } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt index 60b9a01f..f2e9040b 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/aap/AapLifecycleManager.kt @@ -26,7 +26,6 @@ class AapLifecycleManager @Inject constructor( private val aapKeyPersister: AapKeyPersister, private val stemConfigSender: StemConfigSender, private val stemPressReaction: StemPressReaction, - private val ncOnePodSender: NcOnePodSender, ) { fun start() { log(TAG) { "start()" } @@ -35,7 +34,6 @@ class AapLifecycleManager @Inject constructor( aapKeyPersister.monitor(), stemConfigSender.monitor(), stemPressReaction.monitor(), - ncOnePodSender.monitor(), ) .catch { e -> log(TAG, WARN) { "AAP lifecycle error: ${e.asLog()}" } } .setupCommonEventHandlers(TAG) { "aapActive" } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/aap/NcOnePodSender.kt b/app/src/main/java/eu/darken/capod/monitor/core/aap/NcOnePodSender.kt deleted file mode 100644 index 0ca2508e..00000000 --- a/app/src/main/java/eu/darken/capod/monitor/core/aap/NcOnePodSender.kt +++ /dev/null @@ -1,58 +0,0 @@ -package eu.darken.capod.monitor.core.aap - -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.pods.core.apple.aap.AapConnectionManager -import eu.darken.capod.pods.core.apple.aap.AapPodState -import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand -import eu.darken.capod.profiles.core.AppleDeviceProfile -import eu.darken.capod.profiles.core.DeviceProfilesRepo -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NcOnePodSender @Inject constructor( - private val aapManager: AapConnectionManager, - private val profilesRepo: DeviceProfilesRepo, -) { - fun monitor(): Flow = combine( - aapManager.allStates, - profilesRepo.profiles, - ) { states, profiles -> - val appleProfiles = profiles.filterIsInstance() - states.entries - .filter { (_, s) -> s.connectionState == AapPodState.ConnectionState.READY } - .mapNotNull { (address, _) -> - val profile = appleProfiles.firstOrNull { it.address == address } - if (profile != null && profile.model.features.hasNcOneAirpod) { - address to profile.onePodMode - } else { - null - } - } - } - .distinctUntilChanged() - .onEach { commands -> - for ((address, enabled) in commands) { - try { - aapManager.sendCommand(address, AapCommand.SetNcWithOneAirPod(enabled)) - log(TAG) { "Sent SetNcWithOneAirPod($enabled) to $address" } - } catch (e: Exception) { - log(TAG, WARN) { "SetNcWithOneAirPod send failed for $address: $e" } - } - } - } - .map { } - .setupCommonEventHandlers(TAG) { "ncOnePod" } - - companion object { - private val TAG = logTag("Monitor", "NcOnePodSender") - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f0147e1..0dc52e40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,7 +82,7 @@ Don\'t let the system group collected BLE data before forwarding it to us. One pod mode - Auto play/pause, auto-connect, and on supported models noise cancellation, react to a single pod instead of requiring both. + Auto play/pause and auto-connect react to a single pod instead of requiring both. Show case popup Show a popup when the device case is opened (experimental). Show connection popup @@ -465,7 +465,7 @@ Advanced AirPods settings require a Bluetooth feature that is not available on this phone. This may be resolved by a future Android update from your device manufacturer. Sound Controls - Noise Cancellation with One AirPod + ANC with one pod Keep noise cancellation active with only one AirPod Personalized Volume Adjust media volume based on environment diff --git a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt index bd3831b7..abf8afc0 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -346,4 +346,49 @@ class DeviceSettingsViewModelTest : BaseTest() { vm.state.first().hasCustomLongPressStemAction shouldBe true } + + @Test + fun `setOnePodMode updates profile and does not send SetNcWithOneAirPod`() = runVmTest { + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setOnePodMode(true) + + coVerify { profilesRepo.updateAppleProfile(testAddress, any()) } + coVerify(exactly = 0) { + aapManager.sendCommand(any(), any()) + } + } + + @Test + fun `setNcWithOneAirPod sends command and does not touch profile`() = runVmTest { + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setNcWithOneAirPod(true) + + coVerify { aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) } + coVerify(exactly = 0) { profilesRepo.updateAppleProfile(any(), any()) } + } + + @Test + fun `setNcWithOneAirPod failure emits SendFailed event`() = runVmTest { + val failure = IllegalStateException("socket closed") + coEvery { + aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) + } throws failure + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.setNcWithOneAirPod(true) + + val event = vm.events.first() + val sendFailed = event.shouldBeInstanceOf() + sendFailed.command shouldBe AapCommand.SetNcWithOneAirPod(true) + sendFailed.message shouldBe "socket closed" + } } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/aap/NcOnePodSenderTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/aap/NcOnePodSenderTest.kt deleted file mode 100644 index 5a6907bd..00000000 --- a/app/src/test/java/eu/darken/capod/monitor/core/aap/NcOnePodSenderTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -package eu.darken.capod.monitor.core.aap - -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.AapPodState -import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand -import eu.darken.capod.profiles.core.AppleDeviceProfile -import eu.darken.capod.profiles.core.DeviceProfile -import eu.darken.capod.profiles.core.DeviceProfilesRepo -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import testhelpers.BaseTest - -class NcOnePodSenderTest : BaseTest() { - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var aapManager: AapConnectionManager - private lateinit var profilesRepo: DeviceProfilesRepo - - private lateinit var profilesFlow: MutableStateFlow> - private lateinit var allStatesFlow: MutableStateFlow> - - private val testAddress = "AA:BB:CC:DD:EE:FF" - private val testAddress2 = "11:22:33:44:55:66" - - private val proProfile = AppleDeviceProfile( - label = "AirPods Pro 3", - model = PodModel.AIRPODS_PRO3, - address = testAddress, - onePodMode = false, - ) - - private val nonNcProfile = AppleDeviceProfile( - label = "AirPods Gen 3", - model = PodModel.UNKNOWN, - address = testAddress2, - onePodMode = true, - ) - - @BeforeEach - fun setup() { - profilesFlow = MutableStateFlow(emptyList()) - allStatesFlow = MutableStateFlow(emptyMap()) - - aapManager = mockk(relaxed = true) { - every { allStates } returns allStatesFlow - } - - profilesRepo = mockk(relaxUnitFun = true) { - every { profiles } returns profilesFlow - } - } - - private fun createSender() = NcOnePodSender( - aapManager = aapManager, - profilesRepo = profilesRepo, - ) - - @Test - fun `sends enabled when READY and onePodMode is true`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(proProfile.copy(onePodMode = true)) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - coVerify(exactly = 1) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) - } - - job.cancel() - } - - @Test - fun `sends disabled when READY and onePodMode is false`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(proProfile.copy(onePodMode = false)) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - coVerify(exactly = 1) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(false)) - } - - job.cancel() - } - - @Test - fun `does not send when device does not support NC one airpod`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(nonNcProfile) - allStatesFlow.value = mapOf( - testAddress2 to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - coVerify(exactly = 0) { aapManager.sendCommand(any(), any()) } - - job.cancel() - } - - @Test - fun `does not send when connection is not READY`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(proProfile.copy(onePodMode = true)) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.CONNECTING) - ) - advanceUntilIdle() - - coVerify(exactly = 0) { aapManager.sendCommand(any(), any()) } - - job.cancel() - } - - @Test - fun `sends again when onePodMode toggles`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - - // Enable - profilesFlow.value = listOf(proProfile.copy(onePodMode = true)) - advanceUntilIdle() - - // Disable - profilesFlow.value = listOf(proProfile.copy(onePodMode = false)) - advanceUntilIdle() - - coVerify(ordering = io.mockk.Ordering.ORDERED) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(false)) - } - - job.cancel() - } - - @Test - fun `does not resend for unrelated state changes`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(proProfile.copy(onePodMode = true)) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - // Unrelated AAP state change (e.g. battery update) — same READY, same profile - allStatesFlow.value = mapOf( - testAddress to AapPodState( - connectionState = AapPodState.ConnectionState.READY, - lastMessageAt = java.time.Instant.now(), - ) - ) - advanceUntilIdle() - - // Should only have sent once — distinctUntilChanged filters the second emission - coVerify(exactly = 1) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) - } - - job.cancel() - } - - @Test - fun `handles send failure without crashing the flow`() = runTest(testDispatcher) { - coEvery { - aapManager.sendCommand(any(), any()) - } throws RuntimeException("Connection lost") - - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf(proProfile.copy(onePodMode = true)) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - // Flow should survive the exception - coVerify(exactly = 1) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) - } - - job.cancel() - } - - @Test - fun `does not send to unmatched addresses`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - // Profile for testAddress, but AAP connection on a different address - profilesFlow.value = listOf(proProfile) - allStatesFlow.value = mapOf( - "XX:XX:XX:XX:XX:XX" to AapPodState(connectionState = AapPodState.ConnectionState.READY) - ) - advanceUntilIdle() - - coVerify(exactly = 0) { aapManager.sendCommand(any(), any()) } - - job.cancel() - } - - @Test - fun `only sends to NC-capable device when multiple devices connected`() = runTest(testDispatcher) { - val sender = createSender() - val job = launch { sender.monitor().toList() } - - profilesFlow.value = listOf( - proProfile.copy(onePodMode = true), - nonNcProfile, - ) - allStatesFlow.value = mapOf( - testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY), - testAddress2 to AapPodState(connectionState = AapPodState.ConnectionState.READY), - ) - advanceUntilIdle() - - coVerify(exactly = 1) { - aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true)) - } - coVerify(exactly = 0) { - aapManager.sendCommand(testAddress2, any()) - } - - job.cancel() - } -}