feat: scan for bluetooth devices, list in connect view

This commit is contained in:
Fritz Heiden 2025-03-25 18:54:32 +01:00
parent e3623cb128
commit 8eef70f59b
8 changed files with 360 additions and 111 deletions

View File

@ -5,8 +5,10 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.INTERNET" />

View File

@ -30,6 +30,7 @@ import androidx.compose.ui.res.painterResource
import androidx.core.app.ActivityCompat
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.CameraService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService
import com.example.tvcontroller.ui.views.CameraView
import com.example.tvcontroller.ui.views.SettingsView
@ -39,19 +40,22 @@ class MainActivity : ComponentActivity() {
private lateinit var bluetoothService: BluetoothService
private lateinit var deviceService: DeviceService
private lateinit var cameraService: CameraService
private lateinit var controllerService: ControllerService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bluetoothService = BluetoothService(this.applicationContext)
deviceService = DeviceService(this.applicationContext)
cameraService = CameraService(this.applicationContext)
controllerService = ControllerService(bluetoothService)
checkPermissions()
enableEdgeToEdge()
setContent {
TVControllerTheme {
TvControllerApp(
bluetoothService = bluetoothService,
deviceService = deviceService
deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService
)
}
}
@ -61,14 +65,18 @@ class MainActivity : ComponentActivity() {
if (!cameraService.hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0)
}
if (!bluetoothService.hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
}
}
}
@Composable
fun TvControllerApp(
navController: NavHostController = rememberNavController(),
bluetoothService: BluetoothService,
deviceService: DeviceService
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentScreen = Screen.valueOf(backStackEntry?.destination?.route ?: Screen.Camera.name)
@ -112,24 +120,23 @@ fun TvControllerApp(
)
}
}) { innerPadding ->
Column {
NavHost(
navController = navController,
startDestination = Screen.Camera.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraView()
}
composable(route = Screen.Remote.name) {
RemoteScreen()
}
composable(route = Screen.Settings.name) {
SettingsView(
deviceService = deviceService,
bluetoothService = bluetoothService
)
}
NavHost(
navController = navController,
startDestination = Screen.Camera.name,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Screen.Camera.name) {
CameraView()
}
composable(route = Screen.Remote.name) {
RemoteScreen()
}
composable(route = Screen.Settings.name) {
SettingsView(
deviceService = deviceService,
controllerService = controllerService,
bluetoothService = bluetoothService
)
}
}
}

View File

@ -1,18 +1,22 @@
package com.example.tvcontroller
import android.bluetooth.BluetoothAdapter
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(
private val deviceService: DeviceService,
controllerService: ControllerService,
private val bluetoothService: BluetoothService
) : ViewModel() {
var serverAddress by mutableStateOf(deviceService.getServerAddress())
@ -23,16 +27,20 @@ class SettingsViewModel(
private set
var connectionState by mutableStateOf(Settings.ConnectionState.Unregistered)
private set
var bluetoothEnabled by mutableStateOf(bluetoothService.isBluetoothEnabled())
var controllerConnectionState by mutableIntStateOf(controllerService.connectionState)
private set
var activeView by mutableStateOf(MAIN_SETTINGS_VIEW)
var scannedDevices = bluetoothService.scannedDevices
var pairedDevices = bluetoothService.pairedDevices
init {
updateConnectionState()
viewModelScope.launch {
updateDeviceInfo()
}
bluetoothService.onBluetoothStateChanged {
bluetoothEnabled = it == BluetoothAdapter.STATE_ON
controllerService.onConnectionStateChanged {
controllerConnectionState = it
}
}
@ -45,6 +53,11 @@ class SettingsViewModel(
}
}
fun connectController() {
//Log.i("SettingsScreen", "Connect controller")
//controllerService.connect()
}
private fun updateConnectionState() {
connectionState = if (deviceService.getToken().isEmpty()) {
Settings.ConnectionState.Unregistered
@ -76,14 +89,26 @@ class SettingsViewModel(
registrationCode = code
}
fun startBluetoothScan() {
bluetoothService.startDiscovery()
}
fun stopBluetoothScan() {
bluetoothService.stopDiscovery()
}
companion object {
const val MAIN_SETTINGS_VIEW = 0
const val CONNECT_CONTROLLER_VIEW = 1
fun provideFactory(
deviceService: DeviceService,
bluetoothService: BluetoothService,
controllerService: ControllerService,
bluetoothService: BluetoothService
) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(deviceService, bluetoothService) as T
return SettingsViewModel(deviceService, controllerService, bluetoothService) as T
}
}
}

View File

@ -0,0 +1,27 @@
package com.example.tvcontroller.data
import android.util.Log
class BluetoothDevice(name: String?, address: String?) {
private var _name: String? = name
private var _address: String? = address
fun getName(): String {
return if (_name.isNullOrEmpty()) getAddress() else _name!!
}
fun getAddress(): String {
return if (_address == null) "" else _address!!
}
companion object {
fun fromBluetoothDevice(device: android.bluetooth.BluetoothDevice): BluetoothDevice {
try {
return BluetoothDevice(device.name, device.address)
} catch (e: SecurityException) {
Log.e("BluetoothDevice", "Error creating BluetoothDevice", e)
}
return BluetoothDevice(null, null)
}
}
}

View File

@ -6,17 +6,53 @@ import android.content.Context
import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat.getSystemService
import com.example.tvcontroller.data.BluetoothDevice
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class BluetoothService(private val context: Context) {
private var bluetoothManager: BluetoothManager =
getSystemService(context, BluetoothManager::class.java)!!;
private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
private var bluetoothStateReceiver: BroadcastReceiver? = null
private var bluetoothStateChangedCallbacks: MutableList<(Int) -> Unit> = mutableListOf()
private val _scannedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
var scannedDevices: StateFlow<List<BluetoothDevice>> = _scannedDevices.asStateFlow()
private val _pairedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow()
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) }
}
}
private var foundDeviceReceiver: BroadcastReceiver? = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(
android.bluetooth.BluetoothDevice.EXTRA_DEVICE,
android.bluetooth.BluetoothDevice::class.java
)
} else {
intent.getParcelableExtra(android.bluetooth.BluetoothDevice.EXTRA_DEVICE)
}
if (device != null) {
val newDevice = BluetoothDevice.fromBluetoothDevice(device)
Log.i("BluetoothService", "Found device: $newDevice")
_scannedDevices.update { devices -> if (newDevice in devices) devices else devices + newDevice }
}
}
}
init {
registerBluetoothStateReceiver()
updatePairedDevices()
}
fun isBluetoothEnabled(): Boolean {
@ -24,32 +60,9 @@ class BluetoothService(private val context: Context) {
}
private fun registerBluetoothStateReceiver() {
bluetoothStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
bluetoothStateChangedCallbacks.forEach { it(state) }
when (state) {
BluetoothAdapter.STATE_ON -> {
// Handle Bluetooth turned on
println("SIGNAL: Bluetooth is enabled")
}
BluetoothAdapter.STATE_OFF -> {
// Handle Bluetooth turned off
println("SIGNAL: Bluetooth is disabled")
}
BluetoothAdapter.STATE_TURNING_ON -> {
// Handle Bluetooth turning on
println("SIGNAL: Bluetooth is turning on")
}
BluetoothAdapter.STATE_TURNING_OFF -> {
// Handle Bluetooth turning off
println("SIGNAL: Bluetooth is turning off")
}
}
}
}
context.registerReceiver(bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
}
fun onBluetoothStateChanged(callback: (Int) -> Unit) {
@ -62,5 +75,69 @@ class BluetoothService(private val context: Context) {
fun cleanUp() {
context.unregisterReceiver(bluetoothStateReceiver)
context.unregisterReceiver(foundDeviceReceiver)
}
fun updatePairedDevices() {
try {
_pairedDevices.update {
bluetoothAdapter.bondedDevices.toList()
.map { device -> BluetoothDevice.fromBluetoothDevice(device) }
}
} catch (e: SecurityException) {
println("Error updating paired devices: $e")
}
}
fun startDiscovery() {
try {
if (bluetoothAdapter.isDiscovering) return
Log.i("BluetoothService", "Starting discovery")
context.registerReceiver(
foundDeviceReceiver,
IntentFilter(android.bluetooth.BluetoothDevice.ACTION_FOUND)
)
updatePairedDevices()
bluetoothAdapter.startDiscovery()
} catch (e: SecurityException) {
println("Error starting discovery: $e")
Log.e("BluetoothService", "Error starting discovery: $e")
}
}
fun stopDiscovery() {
context.unregisterReceiver(foundDeviceReceiver)
try {
bluetoothAdapter.cancelDiscovery()
} catch (e: SecurityException) {
println("Error stopping discovery: $e")
}
}
fun hasRequiredPermissions(): Boolean {
return BLUETOOTH_PERMISSIONS.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
}
fun getBluetoothState(): Int {
return bluetoothAdapter.state
}
companion object {
val BLUETOOTH_PERMISSIONS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADMIN
)
} else {
arrayOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_ADMIN
)
}
}
}

View File

@ -0,0 +1,40 @@
package com.example.tvcontroller.services
import android.bluetooth.BluetoothAdapter
class ControllerService(bluetoothService: BluetoothService) {
private var connectionStateChangedListener: MutableList<(Int) -> Unit> = mutableListOf()
var connectionState = BLUETOOTH_DISABLED
private set
init {
handleBluetoothStateChanged(bluetoothService.getBluetoothState())
bluetoothService.onBluetoothStateChanged {
handleBluetoothStateChanged(it)
}
}
fun onConnectionStateChanged(callback: (Int) -> Unit) {
connectionStateChangedListener.add(callback)
}
private fun setConnectionState(state: Int) {
connectionState = state
connectionStateChangedListener.forEach { it(state) }
}
private fun handleBluetoothStateChanged(state: Int) {
when (state) {
BluetoothAdapter.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED)
else -> setConnectionState(NOT_PAIRED)
}
}
companion object {
const val BLUETOOTH_DISABLED = 0
const val NOT_PAIRED = 1
const val NOT_CONNECTED = 2
const val CONNECTED = 3
}
}

View File

@ -1,10 +1,15 @@
package com.example.tvcontroller.ui.views
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.util.Log
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@ -12,6 +17,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@ -20,77 +26,124 @@ import com.example.tvcontroller.R
import com.example.tvcontroller.Settings
import com.example.tvcontroller.SettingsViewModel
import com.example.tvcontroller.services.BluetoothService
import com.example.tvcontroller.services.ControllerService
import com.example.tvcontroller.services.DeviceService
import kotlinx.coroutines.flow.toList
@Composable
fun SettingsView(
deviceService: DeviceService,
controllerService: ControllerService,
bluetoothService: BluetoothService
) {
val viewModel =
viewModel<SettingsViewModel>(factory = SettingsViewModel.provideFactory(deviceService, bluetoothService))
val viewModel = viewModel<SettingsViewModel>(
factory = SettingsViewModel.provideFactory(
deviceService, controllerService, bluetoothService
)
)
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(id = R.string.settings_title),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.server_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
viewModel.connectionState
),
style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.serverAddress,
onValueChange = viewModel::onServerAddressChanged,
label = { Text(stringResource(id = R.string.server_address_label)) }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.deviceName,
onValueChange = viewModel::onDeviceNameChanged,
label = { Text(stringResource(id = R.string.device_name_label)) }
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.registrationCode,
onValueChange = viewModel::onRegistrationCodeChanged,
label = { Text(stringResource(id = R.string.registration_code_label)) }
)
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
@Composable
fun MainSettingsView() {
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
stringResource(id = R.string.connect_button_label)
text = stringResource(id = R.string.settings_title),
style = MaterialTheme.typography.displaySmall
)
}
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.controller_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
if (viewModel.bluetoothEnabled) {
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = "Controller settings: Bluetooth is enabled.",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "Bluetooth is disabled. Please enable it in settings.",
text = stringResource(id = R.string.server_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = stringResource(id = R.string.server_connection_label) + ": " + getConnectionStateString(
viewModel.connectionState
), style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.serverAddress,
onValueChange = viewModel::onServerAddressChanged,
label = { Text(stringResource(id = R.string.server_address_label)) })
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.deviceName,
onValueChange = viewModel::onDeviceNameChanged,
label = { Text(stringResource(id = R.string.device_name_label)) })
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = viewModel.registrationCode,
onValueChange = viewModel::onRegistrationCodeChanged,
label = { Text(stringResource(id = R.string.registration_code_label)) })
OutlinedButton(onClick = { viewModel.connect() }, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.controller_settings_heading),
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Controller status: " + getControllerConnectionStateString(viewModel.controllerConnectionState) + ".",
style = MaterialTheme.typography.bodyMedium
)
OutlinedButton(
onClick = { viewModel.activeView = SettingsViewModel.CONNECT_CONTROLLER_VIEW },
enabled = viewModel.controllerConnectionState != ControllerService.BLUETOOTH_DISABLED && viewModel.controllerConnectionState != ControllerService.CONNECTED,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(id = R.string.connect_button_label)
)
}
}
}
@SuppressLint("MissingPermission")
@Composable
fun ConnectControllerView() {
viewModel.startBluetoothScan()
val pairedDevices = viewModel.pairedDevices.collectAsState()
val scannedDevices = viewModel.scannedDevices.collectAsState()
Column(
modifier = Modifier
.padding(16.dp, 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(id = R.string.connect_controller_title),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.paired_devices_label),
style = MaterialTheme.typography.headlineSmall
)
pairedDevices.value.forEach { device ->
Text(text = device.getName())
}
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.scanned_devices_label),
style = MaterialTheme.typography.headlineSmall
)
scannedDevices.value.forEach { device ->
Text(text = device.getName())
}
}
}
when (viewModel.activeView) {
SettingsViewModel.MAIN_SETTINGS_VIEW -> MainSettingsView()
SettingsViewModel.CONNECT_CONTROLLER_VIEW -> ConnectControllerView()
}
}
@Composable
@ -100,3 +153,14 @@ fun getConnectionStateString(state: Settings.ConnectionState): String {
Settings.ConnectionState.Registered -> stringResource(id = R.string.connection_state_registered)
}
}
@Composable
fun getControllerConnectionStateString(state: Int): 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)
else -> "Unknown"
}
}

View File

@ -13,4 +13,11 @@
<string name="server_address_label">Server address</string>
<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>
</resources>