From ccd95a09818495cc496df9ea23c67744a9cd3618 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 8 Apr 2026 11:47:38 +0200 Subject: [PATCH] feat(aap): Show 'Not connected' card when AAP is unavailable --- .../common/bluetooth/BluetoothManager2.kt | 7 +- .../ui/devicesettings/DeviceSettingsScreen.kt | 96 ++++++++ .../devicesettings/DeviceSettingsViewModel.kt | 67 +++++- app/src/main/res/values/strings.xml | 4 + .../DeviceSettingsViewModelTest.kt | 218 ++++++++++++++++++ 5 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt index 3466086d..97751b02 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/BluetoothManager2.kt @@ -315,10 +315,9 @@ class BluetoothManager2 @Inject constructor( "connect", BluetoothDevice::class.java ).apply { isAccessible = true } - connectMethod.invoke(bluetoothProfile.proxy, device.internal) - - log(TAG) { "Nudged connection to $device" } - true + val accepted = connectMethod.invoke(bluetoothProfile.proxy, device.internal) 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) 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 ae8cb8f5..ffc06691 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 @@ -1,5 +1,7 @@ package eu.darken.capod.main.ui.devicesettings +import android.content.Intent +import android.provider.Settings import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.selection.selectable @@ -30,6 +32,7 @@ import androidx.compose.material.icons.twotone.Timer import androidx.compose.material.icons.twotone.TouchApp import androidx.compose.material.icons.twotone.Visibility import androidx.compose.material.icons.twotone.VisibilityOff +import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -91,6 +94,17 @@ fun DeviceSettingsScreenHost( LaunchedEffect(address) { vm.initialize(address) } + val context = LocalContext.current + LaunchedEffect(Unit) { + vm.events.collect { event -> + when (event) { + DeviceSettingsViewModel.Event.OpenBluetoothSettings -> { + context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + } + } + } + } + val state by vm.state.collectAsStateWithLifecycle(initialValue = null) val currentState = state ?: return @@ -115,6 +129,7 @@ fun DeviceSettingsScreenHost( onSleepDetectionChange = { vm.setSleepDetection(it) }, onDeviceNameChange = { vm.setDeviceName(it) }, onStemActionsClick = { vm.navToStemConfig() }, + onForceConnect = { vm.forceConnect() }, ) } @@ -141,6 +156,7 @@ fun DeviceSettingsScreen( onSleepDetectionChange: (Boolean) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {}, onStemActionsClick: () -> Unit = {}, + onForceConnect: () -> Unit = {}, ) { val device = state.device val features = device?.model?.features @@ -206,6 +222,17 @@ fun DeviceSettingsScreen( } } + // Not connected info — BLE live but no AAP connection + if (device != null && device.ble != null && !device.isAapConnected && device.address != null) { + item("not_connected_info") { + NotConnectedCard( + isNudgeAvailable = state.isNudgeAvailable, + isForceConnecting = state.isForceConnecting, + onConnect = onForceConnect, + ) + } + } + // Settings — only show when AAP is connected if (features != null && device.isAapConnected) { @@ -583,6 +610,45 @@ private fun DeviceInfoCard( } } +@Composable +private fun NotConnectedCard( + isNudgeAvailable: Boolean, + isForceConnecting: Boolean, + onConnect: () -> Unit, +) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.device_settings_not_connected_label), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.device_settings_not_connected_description), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onConnect, + enabled = !isForceConnecting, + modifier = Modifier.align(Alignment.End), + ) { + Text( + text = stringResource( + if (isNudgeAvailable) R.string.device_settings_not_connected_connect_action + else R.string.device_settings_not_connected_open_settings_action + ), + ) + } + } + } +} + @Composable private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) { Column( @@ -1157,3 +1223,33 @@ private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper { onNavigateUp = {}, ) } + +@Preview2 +@Composable +private fun NotConnectedCardNudgeAvailablePreview() = PreviewWrapper { + NotConnectedCard( + isNudgeAvailable = true, + isForceConnecting = false, + onConnect = {}, + ) +} + +@Preview2 +@Composable +private fun NotConnectedCardNudgeUnavailablePreview() = PreviewWrapper { + NotConnectedCard( + isNudgeAvailable = false, + isForceConnecting = false, + onConnect = {}, + ) +} + +@Preview2 +@Composable +private fun NotConnectedCardForceConnectingPreview() = PreviewWrapper { + NotConnectedCard( + isNudgeAvailable = true, + isForceConnecting = true, + onConnect = {}, + ) +} 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 365ea888..8f8b011d 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 @@ -2,9 +2,12 @@ package eu.darken.capod.main.ui.devicesettings import dagger.hilt.android.lifecycle.HiltViewModel import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.coroutine.DispatcherProvider +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.SingleEventFlow import eu.darken.capod.common.uix.ViewModel4 import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice @@ -18,6 +21,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.isActive @@ -30,6 +34,7 @@ class DeviceSettingsViewModel @Inject constructor( private val deviceMonitor: DeviceMonitor, private val aapManager: AapConnectionManager, private val upgradeRepo: UpgradeRepo, + private val bluetoothManager: BluetoothManager2, ) : ViewModel4(dispatcherProvider) { private val targetAddress = MutableStateFlow(null) @@ -48,13 +53,28 @@ class DeviceSettingsViewModel @Inject constructor( } } + private val isForceConnecting = MutableStateFlow(false) + + sealed interface Event { + data object OpenBluetoothSettings : Event + } + + val events = SingleEventFlow() + val state = targetAddress.flatMapLatest { address -> if (address == null) return@flatMapLatest flowOf(State(device = null)) - combine(updateTicker, deviceMonitor.devices, upgradeRepo.upgradeInfo) { _, devices, upgrade -> + combine( + updateTicker, + deviceMonitor.devices, + upgradeRepo.upgradeInfo, + isForceConnecting, + ) { _, devices, upgrade, forcing -> State( device = devices.firstOrNull { it.address == address }, now = Instant.now(), isPro = upgrade.isPro, + isNudgeAvailable = bluetoothManager.isNudgeAvailable, + isForceConnecting = forcing, ) } }.asLiveState() @@ -63,8 +83,53 @@ class DeviceSettingsViewModel @Inject constructor( val device: PodDevice?, val now: Instant = Instant.now(), val isPro: Boolean = false, + val isNudgeAvailable: Boolean = true, + val isForceConnecting: Boolean = false, ) + fun forceConnect() = launch { + if (!isForceConnecting.compareAndSet(expect = false, update = true)) { + log(TAG) { "forceConnect already in progress" } + return@launch + } + try { + val address = targetAddress.value ?: run { + events.tryEmit(Event.OpenBluetoothSettings) + return@launch + } + val bonded = try { + bluetoothManager.bondedDevices().first().firstOrNull { it.address == address } + } catch (e: Exception) { + log(TAG, WARN) { "bondedDevices() failed: ${e.message}" } + null + } + if (bonded == null) { + log(TAG, WARN) { "No bonded device for $address — opening Bluetooth settings" } + events.tryEmit(Event.OpenBluetoothSettings) + return@launch + } + if (!bluetoothManager.isNudgeAvailable) { + events.tryEmit(Event.OpenBluetoothSettings) + return@launch + } + val accepted = try { + bluetoothManager.nudgeConnection(bonded) + } catch (e: Exception) { + log(TAG, WARN) { "nudgeConnection threw: ${e.message}" } + false + } + log(TAG) { "nudgeConnection($bonded) accepted=$accepted" } + if (!accepted) { + events.tryEmit(Event.OpenBluetoothSettings) + } + // On accepted=true, AapAutoConnect.initialConnect() will pick up the new + // connectedDevices entry and trigger the AAP handshake. The card disappears + // when device.isAapConnected becomes true. + } finally { + isForceConnecting.value = false + } + } + private fun send(command: AapCommand) { val address = targetAddress.value ?: return launch { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 793b0d09..bb5f71b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -433,6 +433,10 @@ Status Last Seen First Seen + Not connected + This device is nearby but not connected to this phone. Connect to access settings and controls. + Connect + Open Bluetooth Settings Sound Controls Noise Cancellation with One AirPod 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 new file mode 100644 index 00000000..5721aa45 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModelTest.kt @@ -0,0 +1,218 @@ +package eu.darken.capod.main.ui.devicesettings + +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.upgrade.UpgradeRepo +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.AapConnectionManager +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import testhelpers.BaseTest +import testhelpers.coroutine.TestDispatcherProvider +import testhelpers.livedata.InstantExecutorExtension + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(InstantExecutorExtension::class) +class DeviceSettingsViewModelTest : BaseTest() { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val testAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF" + + private lateinit var deviceMonitor: DeviceMonitor + private lateinit var aapManager: AapConnectionManager + private lateinit var upgradeRepo: UpgradeRepo + private lateinit var bluetoothManager: BluetoothManager2 + + private lateinit var devicesFlow: MutableStateFlow> + private lateinit var upgradeInfoFlow: MutableStateFlow + + private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk { + every { this@mockk.address } returns address + } + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + devicesFlow = MutableStateFlow(emptyList()) + upgradeInfoFlow = MutableStateFlow(mockk(relaxed = true).also { + every { it.isPro } returns false + }) + + deviceMonitor = mockk().also { + every { it.devices } returns devicesFlow + } + aapManager = mockk(relaxed = true) + upgradeRepo = mockk().also { + every { it.upgradeInfo } returns upgradeInfoFlow + } + bluetoothManager = mockk(relaxed = true) { + every { isNudgeAvailable } returns true + every { bondedDevices() } returns flowOf(emptySet()) + } + } + + @AfterEach + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = DeviceSettingsViewModel( + dispatcherProvider = TestDispatcherProvider(testDispatcher), + deviceMonitor = deviceMonitor, + aapManager = aapManager, + upgradeRepo = upgradeRepo, + bluetoothManager = bluetoothManager, + ) + + @Test + fun `forceConnect happy path - bonded exists, nudge accepted, no event emitted`() = runTest(testDispatcher) { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + coEvery { bluetoothManager.nudgeConnection(bonded) } returns true + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + coVerify { bluetoothManager.nudgeConnection(bonded) } + // After completion, the in-flight flag should be reset + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect when nudge not accepted - emits OpenBluetoothSettings`() = runTest(testDispatcher) { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + coEvery { bluetoothManager.nudgeConnection(bonded) } returns false + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect when nudge unavailable - emits OpenBluetoothSettings without calling nudge`() = + runTest(testDispatcher) { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + every { bluetoothManager.isNudgeAvailable } returns false + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect when no bonded device - emits OpenBluetoothSettings`() = runTest(testDispatcher) { + every { bluetoothManager.bondedDevices() } returns flowOf(emptySet()) + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect when bondedDevices throws SecurityException - emits OpenBluetoothSettings`() = + runTest(testDispatcher) { + every { bluetoothManager.bondedDevices() } throws SecurityException("BLUETOOTH_CONNECT denied") + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + coVerify(exactly = 0) { bluetoothManager.nudgeConnection(any()) } + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect when nudgeConnection throws - emits OpenBluetoothSettings and resets in-flight`() = + runTest(testDispatcher) { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + coEvery { bluetoothManager.nudgeConnection(bonded) } throws RuntimeException("oops") + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + vm.forceConnect() + + val event = vm.events.first() + event shouldBe DeviceSettingsViewModel.Event.OpenBluetoothSettings + vm.state.first().isForceConnecting shouldBe false + } + + @Test + fun `forceConnect concurrent calls - second call is a no-op while first in flight`() = + runTest(testDispatcher) { + val bonded = mockBondedDevice(testAddress) + every { bluetoothManager.bondedDevices() } returns flowOf(setOf(bonded)) + + // Block the first nudgeConnection call until we explicitly release it. + val gate = CompletableDeferred() + coEvery { bluetoothManager.nudgeConnection(bonded) } coAnswers { gate.await() } + + val vm = createViewModel() + vm.initialize(testAddress) + vm.state.first() + + // Start the first call — it will suspend on the gate. + vm.forceConnect() + // While the first call is still in-flight, the second call should be a no-op. + vm.forceConnect() + + // Release the first call. + gate.complete(true) + + // nudgeConnection should have been invoked exactly once across both forceConnect calls. + coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) } + vm.state.first().isForceConnecting shouldBe false + } +}