mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(aap): Show 'Not connected' card when AAP is unavailable
This commit is contained in:
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+66
-1
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+218
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user