feat: reflect bt connection state in ui

This commit is contained in:
Fritz Heiden 2025-03-26 18:51:57 +01:00
parent afc3378828
commit a74d0ddef5
6 changed files with 109 additions and 42 deletions

View File

@ -27,7 +27,9 @@ class SettingsViewModel(
private set
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
private set
var controllerConnectionState by mutableIntStateOf(controllerService.connectionState)
var bluetoothConnectionState by mutableStateOf(bluetoothService.state)
private set
var currentBluetoothDevice by mutableStateOf<BluetoothDevice?>(bluetoothService.currentDevice)
private set
var pairedDevices = bluetoothService.pairedDevices
@ -37,8 +39,9 @@ class SettingsViewModel(
viewModelScope.launch {
updateDeviceInfo()
}
controllerService.onConnectionStateChanged {
controllerConnectionState = it
bluetoothService.onBluetoothStateChanged {
currentBluetoothDevice = bluetoothService.currentDevice
bluetoothConnectionState = it
}
}
@ -76,6 +79,12 @@ class SettingsViewModel(
}
}
fun disconnectBluetoothDevice() {
viewModelScope.launch(Dispatchers.IO) {
bluetoothService.closeBluetoothConnection()
}
}
fun onServerAddressChanged(url: String) {
serverAddress = url
}
@ -89,8 +98,8 @@ class SettingsViewModel(
}
companion object {
const val MAIN_SETTINGS_VIEW = 0
const val CONNECT_CONTROLLER_VIEW = 1
const val MAIN_SETTINGS_VIEW = "main_settings_view"
const val CONNECT_CONTROLLER_VIEW = "connect_controller_view"
fun provideFactory(
deviceService: DeviceService,

View File

@ -11,6 +11,7 @@ import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat.getSystemService
import com.example.tvcontroller.data.BluetoothDevice
import kotlinx.coroutines.flow.MutableStateFlow
@ -32,30 +33,52 @@ class BluetoothService(private val context: Context) {
var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow()
private var clientSocket: BluetoothSocket? = null
private var bluetoothStateChangedCallbacks: MutableList<(Int) -> Unit> = mutableListOf()
private var bluetoothStateChangedCallbacks: MutableList<(String) -> Unit> = mutableListOf()
private var bluetoothStateReceiver: BroadcastReceiver? = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
bluetoothStateChangedCallbacks.forEach { it(state) }
val btState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
Log.i(TAG, "Bluetooth state changed to: $btState")
state = parseBluetoothState(btState)
}
}
var currentDevice: BluetoothDevice? = null
private set
var state: String = STATE_OFF
private set(value) {
if (value != STATE_CONNECTED && value != STATE_CONNECTING) currentDevice = null
Log.i(TAG, "Bluetooth state changed to: $value")
bluetoothStateChangedCallbacks.forEach { it(value) }
field = value
}
init {
state = parseBluetoothState(bluetoothAdapter.state)
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
updatePairedDevices()
}
private fun parseBluetoothState(state: Int): String {
return when (state) {
BluetoothAdapter.STATE_OFF -> STATE_OFF
BluetoothAdapter.STATE_TURNING_OFF -> STATE_OFF
BluetoothAdapter.STATE_TURNING_ON -> STATE_OFF
BluetoothAdapter.STATE_ON -> STATE_DISCONNECTED
else -> STATE_DISCONNECTED
}
}
fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter.isEnabled
}
fun onBluetoothStateChanged(callback: (Int) -> Unit) {
fun onBluetoothStateChanged(callback: (String) -> Unit) {
bluetoothStateChangedCallbacks.add(callback)
}
fun offBluetoothStateChanged(callback: (Int) -> Unit) {
fun offBluetoothStateChanged(callback: (String) -> Unit) {
bluetoothStateChangedCallbacks.remove(callback)
}
@ -77,9 +100,12 @@ class BluetoothService(private val context: Context) {
}
fun connectToDevice(device: BluetoothDevice) {
currentDevice = device
state = STATE_CONNECTING
Log.i(TAG, "Initiating connection process")
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
Log.e(TAG, "Bluetooth permission not granted")
state = STATE_DISCONNECTED
return
}
Log.i(TAG, "Connecting to device: $device")
@ -91,8 +117,11 @@ class BluetoothService(private val context: Context) {
)
Log.i(TAG, "Connecting to socket")
clientSocket?.connect()
currentDevice = device
state = STATE_CONNECTED
Log.i(TAG, "Connected to device: $device")
} catch (e: IOException) {
state = STATE_DISCONNECTED
Log.e(TAG, "Error connecting to device: $e")
}
}
@ -103,16 +132,18 @@ class BluetoothService(private val context: Context) {
}
}
fun getBluetoothState(): Int {
return bluetoothAdapter.state
}
fun closeBluetoothConnection() {
clientSocket?.close()
clientSocket = null
currentDevice = null
state = STATE_DISCONNECTED
}
companion object {
const val STATE_OFF = "off"
const val STATE_DISCONNECTED = "disconnected"
const val STATE_CONNECTING = "connecting"
const val STATE_CONNECTED = "connected"
val BLUETOOTH_PERMISSIONS = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN

View File

@ -9,7 +9,7 @@ class ControllerService(bluetoothService: BluetoothService) {
private set
init {
handleBluetoothStateChanged(bluetoothService.getBluetoothState())
handleBluetoothStateChanged(bluetoothService.state)
bluetoothService.onBluetoothStateChanged {
handleBluetoothStateChanged(it)
}
@ -24,9 +24,9 @@ class ControllerService(bluetoothService: BluetoothService) {
connectionStateChangedListener.forEach { it(state) }
}
private fun handleBluetoothStateChanged(state: Int) {
private fun handleBluetoothStateChanged(state: String) {
when (state) {
BluetoothAdapter.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED)
BluetoothService.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED)
else -> setConnectionState(NOT_CONNECTED)
}
}

View File

@ -1,15 +1,15 @@
package com.example.tvcontroller.ui.views
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -19,12 +19,9 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -40,7 +37,6 @@ import com.example.tvcontroller.SettingsViewModel.Companion.MAIN_SETTINGS_VIEW
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.flow.toList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -104,18 +100,28 @@ fun SettingsView(
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Controller status: " + getControllerConnectionStateString(viewModel.controllerConnectionState) + ".",
text = "Controller status: " + getBluetoothConnectionStateString(viewModel.bluetoothConnectionState) + ".",
style = MaterialTheme.typography.bodyMedium
)
OutlinedButton(
onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) },
enabled = viewModel.controllerConnectionState != ControllerService.BLUETOOTH_DISABLED && viewModel.controllerConnectionState != ControllerService.CONNECTED,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED)
OutlinedButton(
onClick = viewModel::disconnectBluetoothDevice,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(id = R.string.disconnect_button_label)
)
}
else
OutlinedButton(
onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) },
enabled = viewModel.bluetoothConnectionState != BluetoothService.STATE_OFF,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
}
}
@ -145,6 +151,7 @@ fun SettingsView(
modifier = Modifier.padding(start = 16.dp)
)
pairedDevices.value.forEach { device ->
if (device == null) return
ListItem(
headlineContent = { Text(device.getName()) },
supportingContent = {
@ -156,7 +163,21 @@ fun SettingsView(
viewModel.connectBluetoothDevice(
device
)
})
}),
trailingContent = {
if (device == viewModel.currentBluetoothDevice)
if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED)
Icon(
painterResource(id = R.drawable.baseline_check_24),
contentDescription = "state"
)
else
CircularProgressIndicator(
modifier = Modifier.width(16.dp),
color = MaterialTheme.colorScheme.secondary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
)
}
}
@ -178,12 +199,12 @@ fun getConnectionStateString(state: Settings.ConnectionState): String {
}
@Composable
fun getControllerConnectionStateString(state: Int): String {
fun getBluetoothConnectionStateString(state: String): String {
return when (state) {
ControllerService.BLUETOOTH_DISABLED -> stringResource(id = R.string.controller_state_bluetooth_disabled)
ControllerService.NOT_PAIRED -> stringResource(id = R.string.controller_state_not_paired)
ControllerService.NOT_CONNECTED -> stringResource(id = R.string.controller_state_not_connected)
ControllerService.CONNECTED -> stringResource(id = R.string.controller_state_connected)
BluetoothService.STATE_OFF -> stringResource(id = R.string.controller_state_bluetooth_disabled)
BluetoothService.STATE_DISCONNECTED -> stringResource(id = R.string.bluetooth_state_disconnected)
BluetoothService.STATE_CONNECTING -> stringResource(id = R.string.bluetooth_state_connecting)
BluetoothService.STATE_CONNECTED -> stringResource(id = R.string.bluetooth_state_connected)
else -> "Unknown"
}
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>

View File

@ -14,10 +14,11 @@
<string name="connection_state_unregistered">unregistered</string>
<string name="connection_state_registered">registered</string>
<string name="controller_state_bluetooth_disabled">Bluetooth is turned off</string>
<string name="controller_state_not_paired">Not paired</string>
<string name="controller_state_not_connected">Not connected</string>
<string name="controller_state_connected">Connected</string>
<string name="paired_devices_label">Paired devices</string>
<string name="scanned_devices_label">Scanned devices</string>
<string name="connect_controller_title">Connect controller</string>
<string name="bluetooth_state_connecting">connecting</string>
<string name="bluetooth_state_connected">connected</string>
<string name="bluetooth_state_disconnected">disconnected</string>
<string name="disconnect_button_label">Disconnect</string>
</resources>