feat: implement serial bt connection to device

This commit is contained in:
Fritz Heiden 2025-03-26 17:35:35 +01:00
parent 07c3ef066b
commit afc3378828
5 changed files with 81 additions and 105 deletions

View File

@ -1,6 +1,5 @@
package com.example.tvcontroller package com.example.tvcontroller
import android.content.ContentValues.TAG
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@ -36,6 +35,8 @@ import com.example.tvcontroller.ui.views.CameraView
import com.example.tvcontroller.ui.views.SettingsView import com.example.tvcontroller.ui.views.SettingsView
const val TAG = "MainActivity"
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var bluetoothService: BluetoothService private lateinit var bluetoothService: BluetoothService
private lateinit var deviceService: DeviceService private lateinit var deviceService: DeviceService
@ -62,10 +63,12 @@ class MainActivity : ComponentActivity() {
} }
private fun checkPermissions() { private fun checkPermissions() {
Log.i(TAG, "Checking permissions")
if (!cameraService.hasRequiredPermissions()) { if (!cameraService.hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0) ActivityCompat.requestPermissions(this, CameraService.CAMERAX_PERMISSIONS, 0)
} }
if (!bluetoothService.hasRequiredPermissions()) { if (!bluetoothService.hasRequiredPermissions()) {
Log.i(TAG, "Requesting Bluetooth permissions")
ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0) ActivityCompat.requestPermissions(this, BluetoothService.BLUETOOTH_PERMISSIONS, 0)
} }
} }
@ -122,7 +125,7 @@ fun TvControllerApp(
}) { innerPadding -> }) { innerPadding ->
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Camera.name, startDestination = Screen.Settings.name,
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) { ) {
composable(route = Screen.Camera.name) { composable(route = Screen.Camera.name) {

View File

@ -1,6 +1,5 @@
package com.example.tvcontroller package com.example.tvcontroller
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -8,10 +7,11 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.tvcontroller.data.BluetoothDevice
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.stateIn import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
@ -29,9 +29,7 @@ class SettingsViewModel(
private set private set
var controllerConnectionState by mutableIntStateOf(controllerService.connectionState) var controllerConnectionState by mutableIntStateOf(controllerService.connectionState)
private set private set
var activeView by mutableStateOf(MAIN_SETTINGS_VIEW)
var scannedDevices = bluetoothService.scannedDevices
var pairedDevices = bluetoothService.pairedDevices var pairedDevices = bluetoothService.pairedDevices
init { init {
@ -53,11 +51,6 @@ class SettingsViewModel(
} }
} }
fun connectController() {
//Log.i("SettingsScreen", "Connect controller")
//controllerService.connect()
}
private fun updateConnectionState() { private fun updateConnectionState() {
connectionState = if (deviceService.getToken().isEmpty()) { connectionState = if (deviceService.getToken().isEmpty()) {
Settings.ConnectionState.Unregistered Settings.ConnectionState.Unregistered
@ -77,6 +70,12 @@ class SettingsViewModel(
deviceName = integration.name deviceName = integration.name
} }
fun connectBluetoothDevice(device: BluetoothDevice) {
viewModelScope.launch(Dispatchers.IO) {
bluetoothService.connectToDevice(device)
}
}
fun onServerAddressChanged(url: String) { fun onServerAddressChanged(url: String) {
serverAddress = url serverAddress = url
} }
@ -89,14 +88,6 @@ class SettingsViewModel(
registrationCode = code registrationCode = code
} }
fun startBluetoothScan() {
bluetoothService.startDiscovery()
}
fun stopBluetoothScan() {
bluetoothService.stopDiscovery()
}
companion object { companion object {
const val MAIN_SETTINGS_VIEW = 0 const val MAIN_SETTINGS_VIEW = 0
const val CONNECT_CONTROLLER_VIEW = 1 const val CONNECT_CONTROLLER_VIEW = 1

View File

@ -1,7 +1,9 @@
package com.example.tvcontroller.services package com.example.tvcontroller.services
import android.Manifest
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
@ -15,54 +17,38 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.io.IOException
import java.util.UUID
private const val SERIAL_PORT_SERVICE_UUID = "00001101-0000-1000-8000-00805F9B34FB"
private const val TAG = "BluetoothService"
class BluetoothService(private val context: Context) { class BluetoothService(private val context: Context) {
private var bluetoothManager: BluetoothManager = private var bluetoothManager: BluetoothManager =
getSystemService(context, BluetoothManager::class.java)!!; getSystemService(context, BluetoothManager::class.java)!!
private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter private var bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
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()) private val _pairedDevices = MutableStateFlow<List<BluetoothDevice>>(emptyList())
var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow() var pairedDevices: StateFlow<List<BluetoothDevice>> = _pairedDevices.asStateFlow()
private var clientSocket: BluetoothSocket? = null
private var bluetoothStateChangedCallbacks: MutableList<(Int) -> 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 state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
bluetoothStateChangedCallbacks.forEach { it(state) } 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 { init {
registerBluetoothStateReceiver() context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
updatePairedDevices() updatePairedDevices()
} }
fun isBluetoothEnabled(): Boolean { fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter.isEnabled ?: false return bluetoothAdapter.isEnabled
}
private fun registerBluetoothStateReceiver() {
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
} }
fun onBluetoothStateChanged(callback: (Int) -> Unit) { fun onBluetoothStateChanged(callback: (Int) -> Unit) {
@ -75,7 +61,8 @@ class BluetoothService(private val context: Context) {
fun cleanUp() { fun cleanUp() {
context.unregisterReceiver(bluetoothStateReceiver) context.unregisterReceiver(bluetoothStateReceiver)
context.unregisterReceiver(foundDeviceReceiver) bluetoothStateChangedCallbacks.clear()
closeBluetoothConnection()
} }
fun updatePairedDevices() { fun updatePairedDevices() {
@ -89,29 +76,24 @@ class BluetoothService(private val context: Context) {
} }
} }
fun startDiscovery() { fun connectToDevice(device: BluetoothDevice) {
Log.i(TAG, "Initiating connection process")
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) {
Log.e(TAG, "Bluetooth permission not granted")
return
}
Log.i(TAG, "Connecting to device: $device")
try { try {
if (bluetoothAdapter.isDiscovering) return var androidBluetoothDevice = bluetoothAdapter.getRemoteDevice(device.getAddress())
Log.i(TAG, "Creating socket to device: $device")
Log.i("BluetoothService", "Starting discovery") clientSocket = androidBluetoothDevice.createRfcommSocketToServiceRecord(
context.registerReceiver( UUID.fromString(SERIAL_PORT_SERVICE_UUID)
foundDeviceReceiver,
IntentFilter(android.bluetooth.BluetoothDevice.ACTION_FOUND)
) )
updatePairedDevices() Log.i(TAG, "Connecting to socket")
bluetoothAdapter.startDiscovery() clientSocket?.connect()
} catch (e: SecurityException) { Log.i(TAG, "Connected to device: $device")
println("Error starting discovery: $e") } catch (e: IOException) {
Log.e("BluetoothService", "Error starting discovery: $e") Log.e(TAG, "Error connecting to device: $e")
}
}
fun stopDiscovery() {
context.unregisterReceiver(foundDeviceReceiver)
try {
bluetoothAdapter.cancelDiscovery()
} catch (e: SecurityException) {
println("Error stopping discovery: $e")
} }
} }
@ -125,18 +107,22 @@ class BluetoothService(private val context: Context) {
return bluetoothAdapter.state return bluetoothAdapter.state
} }
fun closeBluetoothConnection() {
clientSocket?.close()
clientSocket = null
}
companion object { companion object {
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(
android.Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADMIN
) )
} else { } else {
arrayOf( arrayOf(
android.Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_ADMIN Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_ADMIN
) )
} }
} }

View File

@ -27,14 +27,13 @@ class ControllerService(bluetoothService: BluetoothService) {
private fun handleBluetoothStateChanged(state: Int) { private fun handleBluetoothStateChanged(state: Int) {
when (state) { when (state) {
BluetoothAdapter.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED) BluetoothAdapter.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED)
else -> setConnectionState(NOT_PAIRED) else -> setConnectionState(NOT_CONNECTED)
} }
} }
companion object { companion object {
const val BLUETOOTH_DISABLED = 0 const val BLUETOOTH_DISABLED = 0
const val NOT_PAIRED = 1 const val NOT_CONNECTED = 1
const val NOT_CONNECTED = 2 const val CONNECTED = 2
const val CONNECTED = 3
} }
} }

View File

@ -1,6 +1,7 @@
package com.example.tvcontroller.ui.views package com.example.tvcontroller.ui.views
import android.annotation.SuppressLint 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
@ -18,16 +19,24 @@ 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
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.tvcontroller.R import com.example.tvcontroller.R
import com.example.tvcontroller.Settings import com.example.tvcontroller.Settings
import com.example.tvcontroller.SettingsViewModel import com.example.tvcontroller.SettingsViewModel
import com.example.tvcontroller.SettingsViewModel.Companion.CONNECT_CONTROLLER_VIEW
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
@ -45,6 +54,7 @@ fun SettingsView(
deviceService, controllerService, bluetoothService deviceService, controllerService, bluetoothService
) )
) )
val navController = rememberNavController()
@Composable @Composable
fun MainSettingsView() { fun MainSettingsView() {
@ -98,7 +108,7 @@ fun SettingsView(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
OutlinedButton( OutlinedButton(
onClick = { viewModel.activeView = SettingsViewModel.CONNECT_CONTROLLER_VIEW }, onClick = { navController.navigate(CONNECT_CONTROLLER_VIEW.toString()) },
enabled = viewModel.controllerConnectionState != ControllerService.BLUETOOTH_DISABLED && viewModel.controllerConnectionState != ControllerService.CONNECTED, enabled = viewModel.controllerConnectionState != ControllerService.BLUETOOTH_DISABLED && viewModel.controllerConnectionState != ControllerService.CONNECTED,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@ -109,17 +119,16 @@ fun SettingsView(
} }
} }
@SuppressLint("MissingPermission")
@Composable @Composable
fun ConnectControllerView() { fun ConnectControllerView() {
viewModel.startBluetoothScan()
val pairedDevices = viewModel.pairedDevices.collectAsState() val pairedDevices = viewModel.pairedDevices.collectAsState()
val scannedDevices = viewModel.scannedDevices.collectAsState()
Column { Column {
TopAppBar( TopAppBar(
title = { Text(text = stringResource(id = R.string.connect_controller_title)) }, title = { Text(text = stringResource(id = R.string.connect_controller_title)) },
navigationIcon = { navigationIcon = {
IconButton(onClick = {}) { IconButton(onClick = {
navController.navigate(MAIN_SETTINGS_VIEW.toString())
}) {
Icon( Icon(
painterResource(id = R.drawable.baseline_arrow_back_24), painterResource(id = R.drawable.baseline_arrow_back_24),
contentDescription = "Back" contentDescription = "Back"
@ -143,32 +152,20 @@ fun SettingsView(
device.getAddress() device.getAddress()
) )
}, },
modifier = Modifier.clickable(onClick = {
viewModel.connectBluetoothDevice(
device
) )
} })
Spacer(modifier = Modifier.padding(8.dp))
Text(
text = stringResource(id = R.string.scanned_devices_label),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(start = 16.dp)
)
scannedDevices.value.forEach { device ->
if (device != null) ListItem(
headlineContent = { Text(device.getName()) },
supportingContent = {
if (device.getName() != device.getAddress()) Text(
device.getAddress()
)
},
modifier = Modifier.clickable(onClick = {})
) )
} }
} }
} }
} }
when (viewModel.activeView) { NavHost(navController = navController, startDestination = MAIN_SETTINGS_VIEW.toString()) {
SettingsViewModel.MAIN_SETTINGS_VIEW -> MainSettingsView() composable(route = MAIN_SETTINGS_VIEW.toString()) { MainSettingsView() }
SettingsViewModel.CONNECT_CONTROLLER_VIEW -> ConnectControllerView() composable(route = CONNECT_CONTROLLER_VIEW.toString()) { ConnectControllerView() }
} }
} }