diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd71aa1..57a7af3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,8 +5,10 @@ + + diff --git a/app/src/main/java/com/example/tvcontroller/MainActivity.kt b/app/src/main/java/com/example/tvcontroller/MainActivity.kt index a9b9183..06ba3e0 100644 --- a/app/src/main/java/com/example/tvcontroller/MainActivity.kt +++ b/app/src/main/java/com/example/tvcontroller/MainActivity.kt @@ -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 + ) } } } diff --git a/app/src/main/java/com/example/tvcontroller/SettingsViewModel.kt b/app/src/main/java/com/example/tvcontroller/SettingsViewModel.kt index 2d9bfa9..e67f7a5 100644 --- a/app/src/main/java/com/example/tvcontroller/SettingsViewModel.kt +++ b/app/src/main/java/com/example/tvcontroller/SettingsViewModel.kt @@ -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 create(modelClass: Class): T { - return SettingsViewModel(deviceService, bluetoothService) as T + return SettingsViewModel(deviceService, controllerService, bluetoothService) as T } } } diff --git a/app/src/main/java/com/example/tvcontroller/data/BluetoothDevice.kt b/app/src/main/java/com/example/tvcontroller/data/BluetoothDevice.kt new file mode 100644 index 0000000..4943680 --- /dev/null +++ b/app/src/main/java/com/example/tvcontroller/data/BluetoothDevice.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tvcontroller/services/BluetoothService.kt b/app/src/main/java/com/example/tvcontroller/services/BluetoothService.kt index 6fe8601..094db04 100644 --- a/app/src/main/java/com/example/tvcontroller/services/BluetoothService.kt +++ b/app/src/main/java/com/example/tvcontroller/services/BluetoothService.kt @@ -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>(emptyList()) + var scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + private val _pairedDevices = MutableStateFlow>(emptyList()) + var pairedDevices: StateFlow> = _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 + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/tvcontroller/services/ControllerService.kt b/app/src/main/java/com/example/tvcontroller/services/ControllerService.kt new file mode 100644 index 0000000..a3c45d1 --- /dev/null +++ b/app/src/main/java/com/example/tvcontroller/services/ControllerService.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tvcontroller/ui/views/SettingsView.kt b/app/src/main/java/com/example/tvcontroller/ui/views/SettingsView.kt index a64c9a8..b9ab6be 100644 --- a/app/src/main/java/com/example/tvcontroller/ui/views/SettingsView.kt +++ b/app/src/main/java/com/example/tvcontroller/ui/views/SettingsView.kt @@ -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(factory = SettingsViewModel.provideFactory(deviceService, bluetoothService)) + val viewModel = viewModel( + 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" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c4600f..70f2331 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,4 +13,11 @@ Server address unregistered registered + Bluetooth is turned off + Not paired + Not connected + Connected + Paired devices + Scanned devices + Connect controller \ No newline at end of file