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

View File

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

View File

@ -1,7 +1,9 @@
package com.example.tvcontroller.services
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.Intent
import android.content.BroadcastReceiver
@ -15,54 +17,38 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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) {
private var bluetoothManager: BluetoothManager =
getSystemService(context, BluetoothManager::class.java)!!;
getSystemService(context, BluetoothManager::class.java)!!
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())
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() {
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()
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
updatePairedDevices()
}
fun isBluetoothEnabled(): Boolean {
return bluetoothAdapter.isEnabled ?: false
}
private fun registerBluetoothStateReceiver() {
context.registerReceiver(
bluetoothStateReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
)
return bluetoothAdapter.isEnabled
}
fun onBluetoothStateChanged(callback: (Int) -> Unit) {
@ -75,7 +61,8 @@ class BluetoothService(private val context: Context) {
fun cleanUp() {
context.unregisterReceiver(bluetoothStateReceiver)
context.unregisterReceiver(foundDeviceReceiver)
bluetoothStateChangedCallbacks.clear()
closeBluetoothConnection()
}
fun updatePairedDevices() {
@ -89,29 +76,24 @@ class BluetoothService(private val context: Context) {
}
}
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 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
}
}
fun stopDiscovery() {
context.unregisterReceiver(foundDeviceReceiver)
Log.i(TAG, "Connecting to device: $device")
try {
bluetoothAdapter.cancelDiscovery()
} catch (e: SecurityException) {
println("Error stopping discovery: $e")
var androidBluetoothDevice = bluetoothAdapter.getRemoteDevice(device.getAddress())
Log.i(TAG, "Creating socket to device: $device")
clientSocket = androidBluetoothDevice.createRfcommSocketToServiceRecord(
UUID.fromString(SERIAL_PORT_SERVICE_UUID)
)
Log.i(TAG, "Connecting to socket")
clientSocket?.connect()
Log.i(TAG, "Connected to device: $device")
} catch (e: IOException) {
Log.e(TAG, "Error connecting to device: $e")
}
}
@ -125,18 +107,22 @@ class BluetoothService(private val context: Context) {
return bluetoothAdapter.state
}
fun closeBluetoothConnection() {
clientSocket?.close()
clientSocket = null
}
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(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_CONNECT,
android.Manifest.permission.BLUETOOTH_SCAN,
android.Manifest.permission.BLUETOOTH_ADMIN
Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN
)
} else {
arrayOf(
android.Manifest.permission.BLUETOOTH,
android.Manifest.permission.BLUETOOTH_ADMIN
Manifest.permission.BLUETOOTH,
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) {
when (state) {
BluetoothAdapter.STATE_OFF -> setConnectionState(BLUETOOTH_DISABLED)
else -> setConnectionState(NOT_PAIRED)
else -> setConnectionState(NOT_CONNECTED)
}
}
companion object {
const val BLUETOOTH_DISABLED = 0
const val NOT_PAIRED = 1
const val NOT_CONNECTED = 2
const val CONNECTED = 3
const val NOT_CONNECTED = 1
const val CONNECTED = 2
}
}

View File

@ -1,6 +1,7 @@
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
@ -18,16 +19,24 @@ 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
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.Settings
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.ControllerService
import com.example.tvcontroller.services.DeviceService
@ -45,6 +54,7 @@ fun SettingsView(
deviceService, controllerService, bluetoothService
)
)
val navController = rememberNavController()
@Composable
fun MainSettingsView() {
@ -98,7 +108,7 @@ fun SettingsView(
style = MaterialTheme.typography.bodyMedium
)
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,
modifier = Modifier.fillMaxWidth()
) {
@ -109,17 +119,16 @@ fun SettingsView(
}
}
@SuppressLint("MissingPermission")
@Composable
fun ConnectControllerView() {
viewModel.startBluetoothScan()
val pairedDevices = viewModel.pairedDevices.collectAsState()
val scannedDevices = viewModel.scannedDevices.collectAsState()
Column {
TopAppBar(
title = { Text(text = stringResource(id = R.string.connect_controller_title)) },
navigationIcon = {
IconButton(onClick = {}) {
IconButton(onClick = {
navController.navigate(MAIN_SETTINGS_VIEW.toString())
}) {
Icon(
painterResource(id = R.drawable.baseline_arrow_back_24),
contentDescription = "Back"
@ -143,32 +152,20 @@ fun SettingsView(
device.getAddress()
)
},
)
}
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 = {
viewModel.connectBluetoothDevice(
device
)
},
modifier = Modifier.clickable(onClick = {})
})
)
}
}
}
}
when (viewModel.activeView) {
SettingsViewModel.MAIN_SETTINGS_VIEW -> MainSettingsView()
SettingsViewModel.CONNECT_CONTROLLER_VIEW -> ConnectControllerView()
NavHost(navController = navController, startDestination = MAIN_SETTINGS_VIEW.toString()) {
composable(route = MAIN_SETTINGS_VIEW.toString()) { MainSettingsView() }
composable(route = CONNECT_CONTROLLER_VIEW.toString()) { ConnectControllerView() }
}
}