feat(aap): Show 'Not connected' card when AAP is unavailable

This commit is contained in:
darken
2026-04-08 11:49:07 +02:00
committed by Matthias Urhahn
parent 2d38db5cba
commit ccd95a0981
5 changed files with 387 additions and 5 deletions
@@ -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)
@@ -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 = {},
)
}
@@ -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<BluetoothAddress?>(null)
@@ -48,13 +53,28 @@ class DeviceSettingsViewModel @Inject constructor(
}
}
private val isForceConnecting = MutableStateFlow(false)
sealed interface Event {
data object OpenBluetoothSettings : Event
}
val events = SingleEventFlow<Event>()
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 {
+4
View File
@@ -433,6 +433,10 @@
<string name="device_settings_info_status_label">Status</string>
<string name="device_settings_info_last_seen_label">Last Seen</string>
<string name="device_settings_info_first_seen_label">First Seen</string>
<string name="device_settings_not_connected_label">Not connected</string>
<string name="device_settings_not_connected_description">This device is nearby but not connected to this phone. Connect to access settings and controls.</string>
<string name="device_settings_not_connected_connect_action">Connect</string>
<string name="device_settings_not_connected_open_settings_action">Open Bluetooth Settings</string>
<string name="device_settings_category_sound_label">Sound</string>
<string name="device_settings_category_controls_label">Controls</string>
<string name="device_settings_nc_one_airpod_label">Noise Cancellation with One AirPod</string>
@@ -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<List<PodDevice>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
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<UpgradeRepo.Info>(relaxed = true).also {
every { it.isPro } returns false
})
deviceMonitor = mockk<DeviceMonitor>().also {
every { it.devices } returns devicesFlow
}
aapManager = mockk(relaxed = true)
upgradeRepo = mockk<UpgradeRepo>().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<Boolean>()
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
}
}