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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
package com.example.tvcontroller.ui.views package com.example.tvcontroller.ui.views
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -19,12 +19,9 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.BluetoothService
import com.example.tvcontroller.services.ControllerService import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.flow.toList
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -104,18 +100,28 @@ fun SettingsView(
style = MaterialTheme.typography.headlineSmall style = MaterialTheme.typography.headlineSmall
) )
Text( Text(
text = "Controller status: " + getControllerConnectionStateString(viewModel.controllerConnectionState) + ".", text = "Controller status: " + getBluetoothConnectionStateString(viewModel.bluetoothConnectionState) + ".",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
OutlinedButton( if (viewModel.bluetoothConnectionState == BluetoothService.STATE_CONNECTED)
onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) }, OutlinedButton(
enabled = viewModel.controllerConnectionState != ControllerService.BLUETOOTH_DISABLED && viewModel.controllerConnectionState != ControllerService.CONNECTED, onClick = viewModel::disconnectBluetoothDevice,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
stringResource(id = R.string.connect_button_label) 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) modifier = Modifier.padding(start = 16.dp)
) )
pairedDevices.value.forEach { device -> pairedDevices.value.forEach { device ->
if (device == null) return
ListItem( ListItem(
headlineContent = { Text(device.getName()) }, headlineContent = { Text(device.getName()) },
supportingContent = { supportingContent = {
@ -156,7 +163,21 @@ fun SettingsView(
viewModel.connectBluetoothDevice( viewModel.connectBluetoothDevice(
device 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 @Composable
fun getControllerConnectionStateString(state: Int): String { fun getBluetoothConnectionStateString(state: String): String {
return when (state) { return when (state) {
ControllerService.BLUETOOTH_DISABLED -> stringResource(id = R.string.controller_state_bluetooth_disabled) BluetoothService.STATE_OFF -> stringResource(id = R.string.controller_state_bluetooth_disabled)
ControllerService.NOT_PAIRED -> stringResource(id = R.string.controller_state_not_paired) BluetoothService.STATE_DISCONNECTED -> stringResource(id = R.string.bluetooth_state_disconnected)
ControllerService.NOT_CONNECTED -> stringResource(id = R.string.controller_state_not_connected) BluetoothService.STATE_CONNECTING -> stringResource(id = R.string.bluetooth_state_connecting)
ControllerService.CONNECTED -> stringResource(id = R.string.controller_state_connected) BluetoothService.STATE_CONNECTED -> stringResource(id = R.string.bluetooth_state_connected)
else -> "Unknown" 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_unregistered">unregistered</string>
<string name="connection_state_registered">registered</string> <string name="connection_state_registered">registered</string>
<string name="controller_state_bluetooth_disabled">Bluetooth is turned off</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="paired_devices_label">Paired devices</string>
<string name="scanned_devices_label">Scanned devices</string> <string name="scanned_devices_label">Scanned devices</string>
<string name="connect_controller_title">Connect controller</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> </resources>