diff --git a/app/src/main/java/com/helible/pilot/BluetoothController.kt b/app/src/main/java/com/helible/pilot/BluetoothController.kt new file mode 100644 index 0000000..fb0c640 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/BluetoothController.kt @@ -0,0 +1,234 @@ +package com.helible.pilot + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import java.io.IOException +import java.util.UUID + +sealed interface ConnectionResult { + object ConnectionEstablished: ConnectionResult + data class Error(val message: String) : ConnectionResult +} + +interface BluetoothController { + val isEnabled: StateFlow + val isLocationEnabled: StateFlow + val isConnected: StateFlow + val isScanning: StateFlow + val scannedDevices: StateFlow> + val pairedDevices: StateFlow> + val errors: SharedFlow + + fun startDiscovery() + fun cancelDiscovery() + fun connectToDevice(device: Device?): Flow + fun closeConnection() + fun onDestroy() +} + +class AndroidBluetoothController(private val context: Context) : BluetoothController { + + private val bluetoothManager by lazy { + context.getSystemService(BluetoothManager::class.java) + } + + private val bluetoothAdapter by lazy { + bluetoothManager.adapter + } + + private val locationManager: LocationManager? by lazy { + context.getSystemService(ComponentActivity.LOCATION_SERVICE) as LocationManager + } + + private val _isConnected: MutableStateFlow = MutableStateFlow(false) + override val isConnected: StateFlow + get() = _isConnected.asStateFlow() + + private val _errors = MutableSharedFlow() + override val errors: SharedFlow + get() = _errors.asSharedFlow() + + private val _isScanning: MutableStateFlow = MutableStateFlow(false) + override val isScanning: StateFlow + get() = _isScanning.asStateFlow() + + private val _isEnabled: MutableStateFlow = MutableStateFlow(false) + override val isEnabled: StateFlow + get() = _isEnabled.asStateFlow() + + private val _isLocationEnabled: MutableStateFlow = MutableStateFlow(false) + override val isLocationEnabled: StateFlow + get() = _isLocationEnabled.asStateFlow() + + private val _pairedDevices = MutableStateFlow>(emptySet()) + override val pairedDevices: StateFlow> + get() = _pairedDevices.asStateFlow() + + private val _scannedDevices: MutableStateFlow> = MutableStateFlow(emptyList()) + override val scannedDevices: StateFlow> + get() = _scannedDevices.asStateFlow() + + private var currentClientSocket: BluetoothSocket? = null + + + @SuppressLint("MissingPermission") + private val bluetoothIntentReceiver = BluetoothIntentReceiver( + onDeviceFound = {device, rssi -> + if(!hasAllPermissions()) return@BluetoothIntentReceiver + val newDevice = Device(device, rssi) + _scannedDevices.update { devices -> + if(newDevice in devices) devices else devices + newDevice + } + Log.i( + "ScanActivity", + "Found new device: ${device.name} ${device.address} $rssi" + ) + }, + onBluetoothEnabledChanged = { isEnabled -> + _isEnabled.update { _ -> isEnabled } + startDiscovery() + Log.i("ScanActivity", "Bluetooth enabled status: $isEnabled") + }, + onDiscoveryRunningChanged = { isDiscovering -> + _isScanning.update { isDiscovering } + }, + onLocationEnabledChanged = { + if(locationManager?.isLocationEnabled == true){ + _isLocationEnabled.update { true } + } else { + _isLocationEnabled.update { false } + } + } + ) + + companion object { + const val SERVICE_UUID = "af7cc14b-cffa-4a3d-b677-01b0ff0a93d7" + } + + init { + updatePairedDevices() + _isEnabled.update { bluetoothAdapter.isEnabled } + _isLocationEnabled.update { locationManager?.isLocationEnabled == true } + context.registerReceiver(bluetoothIntentReceiver, IntentFilter(BluetoothDevice.ACTION_FOUND)) + context.registerReceiver(bluetoothIntentReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) + context.registerReceiver(bluetoothIntentReceiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED)) + context.registerReceiver(bluetoothIntentReceiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) + if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + context.registerReceiver(bluetoothIntentReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) + } + } + + @SuppressLint("MissingPermission") + override fun startDiscovery() { + if(!hasAllPermissions()) { + Toast.makeText(context, "Ошибка: недостаточно разрешений", Toast.LENGTH_SHORT).show() + return + } + if(!_isEnabled.value) { + return + } + if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if(locationManager?.isLocationEnabled != true) return + } + + updatePairedDevices() + _scannedDevices.update { emptyList() } + + if(!bluetoothAdapter.isDiscovering) { + bluetoothAdapter.startDiscovery() + } + } + + @SuppressLint("MissingPermission") + override fun cancelDiscovery() { + if(!hasAllPermissions()) return + if(bluetoothAdapter.isDiscovering){ + bluetoothAdapter.cancelDiscovery() + } + } + + @SuppressLint("MissingPermission") + override fun connectToDevice(device: Device?): Flow { + if(!hasAllPermissions()){ + Toast.makeText(context, "Ошибка: нет разрешений", Toast.LENGTH_SHORT).show() + return flow {} + } + return flow { + currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord( + UUID.fromString(SERVICE_UUID) + ) + currentClientSocket?.let { socket -> + try { + socket.connect() + emit(ConnectionResult.ConnectionEstablished) + } catch (e: IOException) { + closeConnection() + emit(ConnectionResult.Error("Connection was interrupted")) + } + } + }.onCompletion { closeConnection() }.flowOn(Dispatchers.IO) + } + + override fun closeConnection() { + currentClientSocket?.close() + currentClientSocket = null + } + + override fun onDestroy() { + context.unregisterReceiver(bluetoothIntentReceiver) + closeConnection() + } + + @SuppressLint("MissingPermission") + private fun updatePairedDevices() { + if(!hasAllPermissions()) return + bluetoothAdapter?.bondedDevices.also { devices -> + if(devices != null) { + _pairedDevices.update { devices } + } + } + } + + private fun hasAllPermissions(): Boolean { + val perms = if (Build.VERSION.SDK_INT <= 30) { + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + } else { + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ) + } + perms.forEach { perm -> + if(context.checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED){ + return false + } + } + return true + } +} diff --git a/app/src/main/java/com/helible/pilot/BluetoothHelper.kt b/app/src/main/java/com/helible/pilot/BluetoothHelper.kt deleted file mode 100644 index 2c358ef..0000000 --- a/app/src/main/java/com/helible/pilot/BluetoothHelper.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.helible.pilot - -import android.annotation.SuppressLint -import android.bluetooth.BluetoothSocket -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onCompletion -import java.io.IOException -import java.util.UUID - -sealed interface ConnectionResult { - object ConnectionEstablished: ConnectionResult - data class Error(val message: String) : ConnectionResult -} - -interface BluetoothController { - val isConnected: StateFlow - val errors: SharedFlow - - fun connectToDevice(device: Device?): Flow - fun closeConnection() -} - -class AndroidBluetoothController : BluetoothController { - - private val _isConnected: MutableStateFlow = MutableStateFlow(false) - override val isConnected: StateFlow - get() = _isConnected.asStateFlow() - - private val _errors = MutableSharedFlow() - override val errors: SharedFlow - get() = _errors.asSharedFlow() - - private var currentClientSocket: BluetoothSocket? = null - - companion object { - const val SERVICE_UUID = "af7cc14b-cffa-4a3d-b677-01b0ff0a93d7" - } - - @SuppressLint("MissingPermission") - override fun connectToDevice(device: Device?): Flow { - return flow { - currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord( - UUID.fromString(SERVICE_UUID) - ) - currentClientSocket?.let { socket -> - try { - socket.connect() - emit(ConnectionResult.ConnectionEstablished) - } catch (e: IOException) { - closeConnection() - emit(ConnectionResult.Error("Connection was interrupted")) - } - } - }.onCompletion { closeConnection() }.flowOn(Dispatchers.IO) - } - - override fun closeConnection() { - currentClientSocket?.close() - currentClientSocket = null - } -} diff --git a/app/src/main/java/com/helible/pilot/BluetoothIntentReceiver.kt b/app/src/main/java/com/helible/pilot/BluetoothIntentReceiver.kt new file mode 100644 index 0000000..4946a61 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/BluetoothIntentReceiver.kt @@ -0,0 +1,59 @@ +package com.helible.pilot + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.location.LocationManager +import android.os.Build + +class BluetoothIntentReceiver( + private val onDeviceFound: (device: BluetoothDevice, rssi: Short) -> Unit, + private val onBluetoothEnabledChanged: (isBluetoothEnabled: Boolean) -> Unit, + private val onDiscoveryRunningChanged: (isDiscoveryRunning: Boolean) -> Unit, + private val onLocationEnabledChanged: () -> Unit +) : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + BluetoothDevice.ACTION_FOUND -> { + val device = if (Build.VERSION.SDK_INT >= 33) { + intent.getParcelableExtra( + BluetoothDevice.EXTRA_DEVICE, + BluetoothDevice::class.java + ) + } else { + @Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + } + + val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE) + @SuppressLint("MissingPermission") if (device?.name != null) + onDeviceFound(device, rssi) + } + + BluetoothAdapter.ACTION_STATE_CHANGED -> { + when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) { + BluetoothAdapter.STATE_ON -> { + onBluetoothEnabledChanged(true) + } + + BluetoothAdapter.STATE_OFF -> { + onBluetoothEnabledChanged(false) + } + } + } + LocationManager.PROVIDERS_CHANGED_ACTION -> { + onLocationEnabledChanged() + } + + BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { + onDiscoveryRunningChanged(false) + } + + BluetoothAdapter.ACTION_DISCOVERY_STARTED -> { + onDiscoveryRunningChanged(true) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt b/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt new file mode 100644 index 0000000..11d69d1 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt @@ -0,0 +1,143 @@ +package com.helible.pilot + +import android.bluetooth.BluetoothDevice +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +data class BluetoothUiState( + val isEnabled: Boolean = false, + val isLocationEnabled: Boolean = false, + val isDiscovering: Boolean = false, + val isConnected: Boolean = false, + val isConnecting: Boolean = false, + val errorMessage: String? = null, + val scannedDevices: List = emptyList(), + val pairedDevices: List = emptyList(), +) + +class BluetoothViewModel( + private val bluetoothController: BluetoothController +) : ViewModel() { + + private val _selectedDevice: MutableStateFlow = MutableStateFlow(null) + val selectedDevice: StateFlow + get () = _selectedDevice.asStateFlow() + + private val _state: MutableStateFlow = MutableStateFlow(BluetoothUiState()) + val state: StateFlow = combine(bluetoothController.scannedDevices, bluetoothController.pairedDevices, _state) + { scannedDevices, pairedDevices, state -> + state.copy( + scannedDevices = scannedDevices.toList(), + pairedDevices = pairedDevices.toList() + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value) + + init { + bluetoothController.isConnected.onEach { + isConnected -> _state.update { it.copy(isConnected = isConnected) } + }.launchIn(viewModelScope) + bluetoothController.errors.onEach { error -> + _state.update { + it.copy(errorMessage = error) + } + }.launchIn(viewModelScope) + bluetoothController.isScanning.onEach { result -> + _state.update {it.copy( + isDiscovering = result, + )} + }.launchIn(viewModelScope) + bluetoothController.isEnabled.onEach { result -> + _state.update {it.copy( + isEnabled = result, + )} + }.launchIn(viewModelScope) + bluetoothController.isLocationEnabled.onEach { result -> + _state.update { it.copy( + isLocationEnabled = result + ) } + }.launchIn(viewModelScope) + } + + private fun Flow.listen(): Job { + return onEach { result -> + when(result) { + ConnectionResult.ConnectionEstablished -> { + _state.update { + it.copy( + isConnected = true, + isConnecting = false, + errorMessage = null + ) + } + } + is ConnectionResult.Error -> { + _state.update { it.copy( + isConnected = false, + isConnecting = false, + errorMessage = result.message + ) } + } + } + } + .catch { throwable -> + bluetoothController.closeConnection() + _state.update { + it.copy( + isConnected = false, + isConnecting = false + ) + } + } + .launchIn(viewModelScope) + } + private var deviceConnectionJob: Job? = null + + fun connectToDevice(device: Device) { + _state.update {it.copy(isConnecting = true)} + deviceConnectionJob = bluetoothController + .connectToDevice(device) + .listen() + } + + fun disconnectFromDevice() { + deviceConnectionJob?.cancel() + bluetoothController.closeConnection() + _state.update { + it.copy( + isConnecting = false, + isConnected = false + ) + } + } + + fun selectDevice(selectedDevice: Device?) { + _selectedDevice.update { selectedDevice } + } + + fun onDestroy() { + cancelScan() + bluetoothController.onDestroy() + } + + fun startScan(){ + selectDevice(null) + bluetoothController.startDiscovery() + } + + fun cancelScan() { + bluetoothController.cancelDiscovery() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/MainActivity.kt b/app/src/main/java/com/helible/pilot/MainActivity.kt index 4fea030..f7189fc 100644 --- a/app/src/main/java/com/helible/pilot/MainActivity.kt +++ b/app/src/main/java/com/helible/pilot/MainActivity.kt @@ -1,21 +1,13 @@ package com.helible.pilot import android.Manifest -import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.location.LocationManager +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -32,36 +24,29 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.helible.pilot.components.BluetoothScannerScreen import com.helible.pilot.components.PreferencesCacheImpl import com.helible.pilot.components.SavedPreferences import com.helible.pilot.ui.theme.TestblueTheme -import java.util.concurrent.Executors -@SuppressLint("MissingPermission") + class MainActivity : ComponentActivity() { - // TODO: delegate part of Intent filters logic to BluetoothController - // TODO: move bluetooth states and stateFlow to new BluetoothViewModel // TODO: replace field bluetoothDevice in Device to deviceAddress field - // TODO: replace some mutableStates to stateFlows // TODO: share selected device via PersistentViewModel - // TODO: check permissions inside other classes (and throw an exception, if one of this isn't granted) // TODO: add stub instead of the DevicesList, if there aren't nearby devices // TODO: add Bluetooth data transfer... // TODO: add text strings to resource - val mainViewModel: MainViewModel = MainViewModel(AndroidBluetoothController()) - private val bluetoothManager: BluetoothManager by lazy { - getSystemService(BluetoothManager::class.java) - } - private val bluetoothAdapter: BluetoothAdapter? by lazy { - bluetoothManager.adapter + private val bluetoothViewModel by lazy { + BluetoothViewModel(AndroidBluetoothController(applicationContext)) } + private var permissionsViewModel = PermissionDialogViewModel() - private lateinit var permissionLauncher: ManagedActivityResultLauncher, Map> private val permissionsToRequest: Array by lazy { if (Build.VERSION.SDK_INT <= 30) { @@ -71,15 +56,11 @@ class MainActivity : ComponentActivity() { } else { arrayOf( Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.BLUETOOTH_ADMIN + Manifest.permission.BLUETOOTH_CONNECT ) } } - private val locationManager: LocationManager by lazy { - getSystemService(LOCATION_SERVICE) as LocationManager - } private val preferencesCache by lazy { PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE)) } @@ -90,12 +71,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mainViewModel.bluetoothTurnOnState.value = bluetoothAdapter?.isEnabled - mainViewModel.locationTurnOnState.value = - locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) - setContent { - this.permissionLauncher = rememberLauncherForActivityResult( + val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), onResult = { perms -> permissionsToRequest.forEach { permission -> @@ -104,18 +81,25 @@ class MainActivity : ComponentActivity() { isGranted = perms[permission] == true ) } + if(hasAllPermissions() && !bluetoothViewModel.state.value.isDiscovering) + bluetoothViewModel.startScan() } ) - val state by mainViewModel.state.collectAsState() + val bluetoothState by bluetoothViewModel.state.collectAsState() + val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState() - LaunchedEffect(key1 = state.errorMessage) { - state.errorMessage?.let { message -> + LaunchedEffect(key1 = null) { + permissionLauncher.launch(permissionsToRequest) + } + + LaunchedEffect(key1 = bluetoothState.errorMessage) { + bluetoothState.errorMessage?.let { message -> Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() } } - LaunchedEffect(key1 = state) { - if (state.isConnected) { + LaunchedEffect(key1 = bluetoothState) { + if (bluetoothState.isConnected) { Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG) .show() } @@ -133,26 +117,26 @@ class MainActivity : ComponentActivity() { RequestHardwareFeatures( activity = this, - turnOnLocation = Manifest.permission.ACCESS_FINE_LOCATION in permissionsToRequest, - bluetoothTurnOnState = mainViewModel.bluetoothTurnOnState, - locationTurnOnState = mainViewModel.locationTurnOnState + bluetoothUiState = bluetoothState ) NavHost(navController = navController, startDestination = "scanner") { composable("scanner") { BluetoothScannerScreen( - devices = mainViewModel.devices, - selectedDevice = mainViewModel.selectedDevice, - bluetoothIsDiscoveringState = mainViewModel.isBluetoothDiscoveryRunning, - bluetoothAdapter = bluetoothAdapter, + bluetoothState = bluetoothState, + selectedDevice = selectedDevice, + startScan = { bluetoothViewModel.startScan() }, + cancelScan = { bluetoothViewModel.cancelScan() }, + choiceDevice = {device -> bluetoothViewModel.selectDevice(device)}, onScreenChanged = { - bluetoothAdapter?.cancelDiscovery() + bluetoothViewModel.cancelScan() + val deviceAddress = selectedDevice?.bluetoothDevice?.address preferencesViewModel.savePreferences( SavedPreferences( - mainViewModel.selectedDevice.value?.bluetoothDevice?.address + deviceAddress ) ) - navController.navigate("flight") + navController.navigate("flight/$deviceAddress") Log.i( "ScanActivity", "Preferences: ${preferencesViewModel.preferences}" @@ -160,15 +144,21 @@ class MainActivity : ComponentActivity() { } ) } - composable("flight") { - + composable( + "flight/{device_address}", + arguments = listOf(navArgument("device_address"){type = NavType.StringType}) + ) { + backstackEntry -> LaunchedEffect(Unit) { - // TODO: refactor - val device: Device = mainViewModel.selectedDevice.value!! - mainViewModel.connectToDevice(device) + val device: Device? = selectedDevice + if(device == null){ + navController.navigate("scanner") + } else { + bluetoothViewModel.connectToDevice(device) + } } when { - state.isConnecting -> { + bluetoothState.isConnecting -> { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, @@ -181,7 +171,7 @@ class MainActivity : ComponentActivity() { else -> { Text( - text = "Device name: ${mainViewModel.selectedDevice.value?.bluetoothDevice?.name}", + text = "Device name: ${backstackEntry.arguments?.getString("device_address")}", modifier = Modifier.fillMaxSize(), textAlign = TextAlign.Center ) @@ -192,58 +182,35 @@ class MainActivity : ComponentActivity() { } } } - registerIntentFilters(this, receiver) - requestPermissions() - } - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent) { - receiveIntentChanges( - intent, - mainViewModel, - bluetoothAdapter, - locationManager - ) - } } - - private fun requestPermissions() { - Executors.newSingleThreadExecutor().execute { - Handler(Looper.getMainLooper()).post { - permissionLauncher.launch(permissionsToRequest) - } - } - } - override fun onDestroy() { - unregisterReceiver(receiver) super.onDestroy() - Log.i("ScanActivity", "ACTIVITY DESTROYED") - bluetoothAdapter?.cancelDiscovery() - try { - unregisterReceiver(receiver) - } catch (e: IllegalArgumentException) { - Log.e( - "ScanActivity", - "Receiver wasn't registered ${e.localizedMessage}\nStackTrace: ${e.stackTrace}" - ) - } + bluetoothViewModel.onDestroy() } + override fun onStart() { super.onStart() - if (bluetoothAdapter?.isDiscovering != true) - bluetoothAdapter?.startDiscovery() - Log.i("ScanActivity", "ACTIVITY STARTED") + bluetoothViewModel.startScan() } override fun onStop() { super.onStop() - bluetoothAdapter?.cancelDiscovery() - mainViewModel.devices.clear() - mainViewModel.selectedDevice.value = null - Log.i("ScanActivity", "ACTIVITY STOPPED") + if(!hasAllPermissions()) return + bluetoothViewModel.cancelScan() + bluetoothViewModel.selectDevice(null) } + + private fun hasAllPermissions(): Boolean { + permissionsToRequest.forEach { perm -> + if(checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED){ + return false + } + } + return true + } + } diff --git a/app/src/main/java/com/helible/pilot/MainViewModel.kt b/app/src/main/java/com/helible/pilot/MainViewModel.kt index 11c6ff0..60b44d9 100644 --- a/app/src/main/java/com/helible/pilot/MainViewModel.kt +++ b/app/src/main/java/com/helible/pilot/MainViewModel.kt @@ -24,89 +24,6 @@ data class Device( val rssi: Short, ) -data class BluetoothUiState( - val isConnected: Boolean = false, - val isConnecting: Boolean = false, - val errorMessage: String? = null -) - -class MainViewModel( - private val bluetoothController: BluetoothController -) : ViewModel() { - val devices: MutableList = mutableStateListOf() - val selectedDevice: MutableState = mutableStateOf(null) - val bluetoothTurnOnState: MutableState = mutableStateOf(false) - val locationTurnOnState: MutableState = mutableStateOf(null) - val isBluetoothDiscoveryRunning: MutableState = mutableStateOf(false) - - private val _bluetoothState = MutableStateFlow(BluetoothUiState()) - val state: StateFlow - get() = _bluetoothState.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _bluetoothState.value) - - init { - bluetoothController.isConnected.onEach { - isConnected -> _bluetoothState.update { it.copy(isConnected = isConnected) } - }.launchIn(viewModelScope) - bluetoothController.errors.onEach { error -> - _bluetoothState.update { - it.copy(errorMessage = error) - } - }.launchIn(viewModelScope) - } - - private fun Flow.listen(): Job { - return onEach { result -> - when(result) { - ConnectionResult.ConnectionEstablished -> { - _bluetoothState.update { - it.copy( - isConnected = true, - isConnecting = false, - errorMessage = null - ) - } - } - is ConnectionResult.Error -> { - _bluetoothState.update { it.copy( - isConnected = false, - isConnecting = false, - errorMessage = result.message - ) } - } - } - } - .catch { throwable -> - bluetoothController.closeConnection() - _bluetoothState.update { - it.copy( - isConnected = false, - isConnecting = false - ) - } - } - .launchIn(viewModelScope) - } - private var deviceConnectionJob: Job? = null - - fun connectToDevice(device: Device) { - _bluetoothState.update {it.copy(isConnecting = true)} - deviceConnectionJob = bluetoothController - .connectToDevice(device) - .listen() - } - - fun disconnectFromDevice() { - deviceConnectionJob?.cancel() - bluetoothController.closeConnection() - _bluetoothState.update { - it.copy( - isConnecting = false, - isConnected = false - ) - } - } -} - class PermissionDialogViewModel: ViewModel() { val visiblePermissionDialogQueue = mutableStateListOf() diff --git a/app/src/main/java/com/helible/pilot/PermissionsRequest.kt b/app/src/main/java/com/helible/pilot/PermissionsRequest.kt index ad22353..77cf53c 100644 --- a/app/src/main/java/com/helible/pilot/PermissionsRequest.kt +++ b/app/src/main/java/com/helible/pilot/PermissionsRequest.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings +import android.util.Log import androidx.activity.compose.ManagedActivityResultLauncher import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshots.SnapshotStateList diff --git a/app/src/main/java/com/helible/pilot/ReceiveIntentChanges.kt b/app/src/main/java/com/helible/pilot/ReceiveIntentChanges.kt deleted file mode 100644 index 5ca5702..0000000 --- a/app/src/main/java/com/helible/pilot/ReceiveIntentChanges.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.helible.pilot - -import android.annotation.SuppressLint -import android.app.Activity -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.content.BroadcastReceiver -import android.content.Intent -import android.content.IntentFilter -import android.location.LocationManager -import android.os.Build -import android.util.Log - - -fun registerIntentFilters(activity: Activity, receiver: BroadcastReceiver) { - activity.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND)) - activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)) - activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_STARTED)) - activity.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)) - if (Build.VERSION.SDK_INT <= 30) - activity.registerReceiver(receiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION)) -} - -@SuppressLint("MissingPermission") -fun receiveIntentChanges( - intent: Intent, - mainViewModel: MainViewModel, - bluetoothAdapter: BluetoothAdapter?, - locationManager: LocationManager, -) { - when (intent.action) { - BluetoothDevice.ACTION_FOUND -> { - val device = if (Build.VERSION.SDK_INT >= 33) { - intent.getParcelableExtra( - BluetoothDevice.EXTRA_DEVICE, - BluetoothDevice::class.java - ) - } else { - @Suppress("DEPRECATION") intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - } - - val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE) - if (device?.name != null) - mainViewModel.devices.add(Device(device, rssi)) - Log.i( - "ScanActivity", - "Found new device: ${device?.name} ${device?.address} $rssi" - ) - } - - BluetoothAdapter.ACTION_STATE_CHANGED -> { - val state: Int = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1) - Log.i("ScanActvity", "Bluetooth state: $state") - when (state) { - BluetoothAdapter.STATE_ON -> { - Log.i("ScanActvity", "Bluetooth turned on") - mainViewModel.bluetoothTurnOnState.value = true - mainViewModel.devices.clear() - bluetoothAdapter?.startDiscovery() - } - - BluetoothAdapter.STATE_OFF -> { - Log.i("ScanActvity", "Bluetooth turned off") - mainViewModel.bluetoothTurnOnState.value = false - } - } - } - LocationManager.PROVIDERS_CHANGED_ACTION -> { - mainViewModel.locationTurnOnState.value = locationManager.isLocationEnabled - if (mainViewModel.locationTurnOnState.value == true) { - Log.i("ScanActivity", "LOCATION IS ON") - } else if (mainViewModel.locationTurnOnState.value == false) - Log.i("ScanActivity", "LOCATION IS OFF") - } - - BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { - mainViewModel.isBluetoothDiscoveryRunning.value = false - Log.i("ScanActivity", "DISCOVERY FINISHED") - } - - BluetoothAdapter.ACTION_DISCOVERY_STARTED -> { - mainViewModel.isBluetoothDiscoveryRunning.value = true - Log.i("ScanActivity", "DISCOVERY STARTED") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt b/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt index 68dc3d2..e7436f0 100644 --- a/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt +++ b/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt @@ -4,25 +4,23 @@ import android.annotation.SuppressLint import android.app.Activity import android.bluetooth.BluetoothAdapter import android.content.Intent +import android.os.Build import android.provider.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import com.helible.pilot.components.RequiredHardwareFeatures @SuppressLint("MissingPermission") @Composable fun RequestHardwareFeatures( activity: Activity, - turnOnLocation: Boolean, - bluetoothTurnOnState: MutableState, - locationTurnOnState: MutableState + bluetoothUiState: BluetoothUiState ) { RequiredHardwareFeatures( title = "Включите Bluetooth", description = "Для работы приложения требуется Bluetooth", confirmButtonText = "Включить Bluetooth", - featureState = bluetoothTurnOnState, + featureState = bluetoothUiState.isEnabled, requestFeature = { val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) activity.startActivity(intent) @@ -30,13 +28,13 @@ fun RequestHardwareFeatures( onDismissRequest = {} ) - if (turnOnLocation) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { RequiredHardwareFeatures( title = "Пожалуйста, включите геолокацию", description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " + "требуется геолокация.", confirmButtonText = "Включить геолокацию", - featureState = locationTurnOnState, + featureState = bluetoothUiState.isLocationEnabled, requestFeature = { val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) activity.startActivity(intent) diff --git a/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt b/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt index 3a2cb4f..2a02973 100644 --- a/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt +++ b/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt @@ -1,7 +1,7 @@ package com.helible.pilot.components import android.annotation.SuppressLint -import android.bluetooth.BluetoothAdapter +import android.util.Log import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -18,22 +18,23 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.helible.pilot.BluetoothUiState import com.helible.pilot.Device @SuppressLint("MissingPermission") @Composable fun BluetoothScannerScreen( - devices: MutableList, - selectedDevice: MutableState, - bluetoothIsDiscoveringState: MutableState, - bluetoothAdapter: BluetoothAdapter?, + bluetoothState: BluetoothUiState, + selectedDevice: Device?, + startScan: () -> Unit, + cancelScan: () -> Unit, + choiceDevice: (device: Device?) -> Unit, onScreenChanged: () -> Unit, modifier: Modifier = Modifier ) { @@ -52,8 +53,9 @@ fun BluetoothScannerScreen( ) DiscoveredDevicesList( - devices = devices, + devices = bluetoothState.scannedDevices, selectedDevice = selectedDevice, + choiceDevice = choiceDevice, modifier = Modifier .constrainAs(devicesList) { top.linkTo(title.bottom) @@ -62,7 +64,7 @@ fun BluetoothScannerScreen( } ) - if (devices.isEmpty() && bluetoothIsDiscoveringState.value) { + if (bluetoothState.scannedDevices.isEmpty() && bluetoothState.isDiscovering) { Box( modifier = Modifier.fillMaxSize() ) { @@ -81,21 +83,20 @@ fun BluetoothScannerScreen( horizontalArrangement = Arrangement.Center ) { FilledIconToggleButton( - checked = bluetoothIsDiscoveringState.value, + checked = bluetoothState.isDiscovering, onCheckedChange = { - selectedDevice.value = null - if (bluetoothIsDiscoveringState.value) - bluetoothAdapter?.cancelDiscovery() - else { - devices.clear() - bluetoothAdapter?.startDiscovery() + if (bluetoothState.isDiscovering) { + cancelScan() + Log.i("ScanActivity", "Trying to start scan via button") + } else { + startScan() } }, modifier = Modifier .align(Alignment.Bottom) .padding(5.dp) ) { Icon( - if (bluetoothIsDiscoveringState.value) Icons.Filled.Close + if (bluetoothState.isDiscovering) Icons.Filled.Close else Icons.Filled.Refresh, contentDescription = null ) @@ -107,7 +108,7 @@ fun BluetoothScannerScreen( modifier = Modifier .align(Alignment.Bottom) .padding(5.dp), - enabled = selectedDevice.value != null, + enabled = selectedDevice != null, ) { Text(text = "Далее") } diff --git a/app/src/main/java/com/helible/pilot/components/DeviceItem.kt b/app/src/main/java/com/helible/pilot/components/DeviceItem.kt index 8bd8dbd..b7039a5 100644 --- a/app/src/main/java/com/helible/pilot/components/DeviceItem.kt +++ b/app/src/main/java/com/helible/pilot/components/DeviceItem.kt @@ -29,12 +29,12 @@ import com.helible.pilot.R @SuppressLint("MissingPermission") @Composable -fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState, modifier: Modifier) { +fun DeviceItem(deviceInfo: Device, selectedDevice: Device?, choiceDevice: (device: Device?) -> Unit, modifier: Modifier) { ElevatedCard( modifier=modifier.clickable { - selectedDevice.value = deviceInfo + choiceDevice(deviceInfo) }, - colors = CardDefaults.elevatedCardColors(containerColor = if (deviceInfo.bluetoothDevice == selectedDevice.value?.bluetoothDevice) + colors = CardDefaults.elevatedCardColors(containerColor = if (deviceInfo.bluetoothDevice == selectedDevice?.bluetoothDevice) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface ) @@ -42,12 +42,7 @@ fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState, modifi Row(modifier=Modifier.padding(8.dp)) { Column(verticalArrangement = Arrangement.Center) { Text(text=deviceInfo.bluetoothDevice.name, fontWeight = FontWeight.Bold, softWrap = true) - AndroidView(factory = { context -> - TextView(context).apply { - // Using old TextView for text formatting - text = HtmlCompat.fromHtml("MAC: ${deviceInfo.bluetoothDevice.address}", HtmlCompat.FROM_HTML_MODE_LEGACY) - } - }) + Text(text="MAC: ${deviceInfo.bluetoothDevice.address}", fontWeight = FontWeight.Thin) } Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) { Icon( diff --git a/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt b/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt index 263c5fb..e86e26b 100644 --- a/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt +++ b/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt @@ -5,18 +5,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.helible.pilot.Device @Composable -fun DiscoveredDevicesList(devices: MutableList, selectedDevice: MutableState, modifier: Modifier = Modifier) { +fun DiscoveredDevicesList(devices: List, selectedDevice: Device?, choiceDevice: (device: Device?) -> Unit, modifier: Modifier = Modifier) { LazyColumn(modifier = modifier) { items(devices) { device -> DeviceItem( deviceInfo = device, selectedDevice = selectedDevice, + choiceDevice = choiceDevice, modifier = Modifier .fillMaxWidth() .padding( diff --git a/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt b/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt index 3fa2bcd..03af6c6 100644 --- a/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt +++ b/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt @@ -14,11 +14,11 @@ fun RequiredHardwareFeatures( title: String, description: String, confirmButtonText: String, - featureState: MutableState, + featureState: Boolean, requestFeature: () -> Unit, onDismissRequest: () -> Unit ) { - if (featureState.value == false || featureState.value == null) { + if (!featureState) { AlertDialog( confirmButton = { Divider()