Critical bugs were fixed

More correct permissions processing and compact code with flows
This commit is contained in:
2023-09-15 21:51:35 +07:00
parent a956bc3564
commit 80390b09ba
13 changed files with 528 additions and 369 deletions

View File

@@ -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<Boolean>
val isLocationEnabled: StateFlow<Boolean>
val isConnected: StateFlow<Boolean>
val isScanning: StateFlow<Boolean>
val scannedDevices: StateFlow<List<Device>>
val pairedDevices: StateFlow<Set<BluetoothDevice>>
val errors: SharedFlow<String>
fun startDiscovery()
fun cancelDiscovery()
fun connectToDevice(device: Device?): Flow<ConnectionResult>
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<Boolean> = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean>
get() = _isConnected.asStateFlow()
private val _errors = MutableSharedFlow<String>()
override val errors: SharedFlow<String>
get() = _errors.asSharedFlow()
private val _isScanning: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isScanning: StateFlow<Boolean>
get() = _isScanning.asStateFlow()
private val _isEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isEnabled: StateFlow<Boolean>
get() = _isEnabled.asStateFlow()
private val _isLocationEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isLocationEnabled: StateFlow<Boolean>
get() = _isLocationEnabled.asStateFlow()
private val _pairedDevices = MutableStateFlow<Set<BluetoothDevice>>(emptySet())
override val pairedDevices: StateFlow<Set<BluetoothDevice>>
get() = _pairedDevices.asStateFlow()
private val _scannedDevices: MutableStateFlow<List<Device>> = MutableStateFlow(emptyList())
override val scannedDevices: StateFlow<List<Device>>
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<ConnectionResult> {
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
}
}

View File

@@ -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<Boolean>
val errors: SharedFlow<String>
fun connectToDevice(device: Device?): Flow<ConnectionResult>
fun closeConnection()
}
class AndroidBluetoothController : BluetoothController {
private val _isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean>
get() = _isConnected.asStateFlow()
private val _errors = MutableSharedFlow<String>()
override val errors: SharedFlow<String>
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<ConnectionResult> {
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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<Device> = emptyList(),
val pairedDevices: List<BluetoothDevice> = emptyList(),
)
class BluetoothViewModel(
private val bluetoothController: BluetoothController
) : ViewModel() {
private val _selectedDevice: MutableStateFlow<Device?> = MutableStateFlow(null)
val selectedDevice: StateFlow<Device?>
get () = _selectedDevice.asStateFlow()
private val _state: MutableStateFlow<BluetoothUiState> = MutableStateFlow(BluetoothUiState())
val state: StateFlow<BluetoothUiState> = 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<ConnectionResult>.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()
}
}

View File

@@ -1,21 +1,13 @@
package com.helible.pilot package com.helible.pilot
import android.Manifest 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.Context
import android.content.Intent import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -32,36 +24,29 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.helible.pilot.components.BluetoothScannerScreen import com.helible.pilot.components.BluetoothScannerScreen
import com.helible.pilot.components.PreferencesCacheImpl import com.helible.pilot.components.PreferencesCacheImpl
import com.helible.pilot.components.SavedPreferences import com.helible.pilot.components.SavedPreferences
import com.helible.pilot.ui.theme.TestblueTheme import com.helible.pilot.ui.theme.TestblueTheme
import java.util.concurrent.Executors
@SuppressLint("MissingPermission")
class MainActivity : ComponentActivity() { 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 field bluetoothDevice in Device to deviceAddress field
// TODO: replace some mutableStates to stateFlows
// TODO: share selected device via PersistentViewModel // 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 stub instead of the DevicesList, if there aren't nearby devices
// TODO: add Bluetooth data transfer... // TODO: add Bluetooth data transfer...
// TODO: add text strings to resource // TODO: add text strings to resource
val mainViewModel: MainViewModel = MainViewModel(AndroidBluetoothController())
private val bluetoothManager: BluetoothManager by lazy { private val bluetoothViewModel by lazy {
getSystemService(BluetoothManager::class.java) BluetoothViewModel(AndroidBluetoothController(applicationContext))
}
private val bluetoothAdapter: BluetoothAdapter? by lazy {
bluetoothManager.adapter
} }
private var permissionsViewModel = PermissionDialogViewModel() private var permissionsViewModel = PermissionDialogViewModel()
private lateinit var permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
private val permissionsToRequest: Array<String> by lazy { private val permissionsToRequest: Array<String> by lazy {
if (Build.VERSION.SDK_INT <= 30) { if (Build.VERSION.SDK_INT <= 30) {
@@ -71,15 +56,11 @@ class MainActivity : ComponentActivity() {
} else { } else {
arrayOf( arrayOf(
Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_CONNECT
Manifest.permission.BLUETOOTH_ADMIN
) )
} }
} }
private val locationManager: LocationManager by lazy {
getSystemService(LOCATION_SERVICE) as LocationManager
}
private val preferencesCache by lazy { private val preferencesCache by lazy {
PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE)) PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE))
} }
@@ -90,12 +71,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
mainViewModel.bluetoothTurnOnState.value = bluetoothAdapter?.isEnabled
mainViewModel.locationTurnOnState.value =
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
setContent { setContent {
this.permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(), contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { perms -> onResult = { perms ->
permissionsToRequest.forEach { permission -> permissionsToRequest.forEach { permission ->
@@ -104,18 +81,25 @@ class MainActivity : ComponentActivity() {
isGranted = perms[permission] == true 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) { LaunchedEffect(key1 = null) {
state.errorMessage?.let { message -> permissionLauncher.launch(permissionsToRequest)
}
LaunchedEffect(key1 = bluetoothState.errorMessage) {
bluetoothState.errorMessage?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
} }
} }
LaunchedEffect(key1 = state) { LaunchedEffect(key1 = bluetoothState) {
if (state.isConnected) { if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG) Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG)
.show() .show()
} }
@@ -133,26 +117,26 @@ class MainActivity : ComponentActivity() {
RequestHardwareFeatures( RequestHardwareFeatures(
activity = this, activity = this,
turnOnLocation = Manifest.permission.ACCESS_FINE_LOCATION in permissionsToRequest, bluetoothUiState = bluetoothState
bluetoothTurnOnState = mainViewModel.bluetoothTurnOnState,
locationTurnOnState = mainViewModel.locationTurnOnState
) )
NavHost(navController = navController, startDestination = "scanner") { NavHost(navController = navController, startDestination = "scanner") {
composable("scanner") { composable("scanner") {
BluetoothScannerScreen( BluetoothScannerScreen(
devices = mainViewModel.devices, bluetoothState = bluetoothState,
selectedDevice = mainViewModel.selectedDevice, selectedDevice = selectedDevice,
bluetoothIsDiscoveringState = mainViewModel.isBluetoothDiscoveryRunning, startScan = { bluetoothViewModel.startScan() },
bluetoothAdapter = bluetoothAdapter, cancelScan = { bluetoothViewModel.cancelScan() },
choiceDevice = {device -> bluetoothViewModel.selectDevice(device)},
onScreenChanged = { onScreenChanged = {
bluetoothAdapter?.cancelDiscovery() bluetoothViewModel.cancelScan()
val deviceAddress = selectedDevice?.bluetoothDevice?.address
preferencesViewModel.savePreferences( preferencesViewModel.savePreferences(
SavedPreferences( SavedPreferences(
mainViewModel.selectedDevice.value?.bluetoothDevice?.address deviceAddress
) )
) )
navController.navigate("flight") navController.navigate("flight/$deviceAddress")
Log.i( Log.i(
"ScanActivity", "ScanActivity",
"Preferences: ${preferencesViewModel.preferences}" "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) { LaunchedEffect(Unit) {
// TODO: refactor val device: Device? = selectedDevice
val device: Device = mainViewModel.selectedDevice.value!! if(device == null){
mainViewModel.connectToDevice(device) navController.navigate("scanner")
} else {
bluetoothViewModel.connectToDevice(device)
}
} }
when { when {
state.isConnecting -> { bluetoothState.isConnecting -> {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -181,7 +171,7 @@ class MainActivity : ComponentActivity() {
else -> { else -> {
Text( Text(
text = "Device name: ${mainViewModel.selectedDevice.value?.bluetoothDevice?.name}", text = "Device name: ${backstackEntry.arguments?.getString("device_address")}",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
textAlign = TextAlign.Center 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() { override fun onDestroy() {
unregisterReceiver(receiver)
super.onDestroy() super.onDestroy()
Log.i("ScanActivity", "ACTIVITY DESTROYED") bluetoothViewModel.onDestroy()
bluetoothAdapter?.cancelDiscovery()
try {
unregisterReceiver(receiver)
} catch (e: IllegalArgumentException) {
Log.e(
"ScanActivity",
"Receiver wasn't registered ${e.localizedMessage}\nStackTrace: ${e.stackTrace}"
)
}
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
if (bluetoothAdapter?.isDiscovering != true) bluetoothViewModel.startScan()
bluetoothAdapter?.startDiscovery()
Log.i("ScanActivity", "ACTIVITY STARTED")
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
bluetoothAdapter?.cancelDiscovery() if(!hasAllPermissions()) return
mainViewModel.devices.clear() bluetoothViewModel.cancelScan()
mainViewModel.selectedDevice.value = null bluetoothViewModel.selectDevice(null)
Log.i("ScanActivity", "ACTIVITY STOPPED")
} }
private fun hasAllPermissions(): Boolean {
permissionsToRequest.forEach { perm ->
if(checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED){
return false
}
}
return true
}
} }

View File

@@ -24,89 +24,6 @@ data class Device(
val rssi: Short, 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<Device> = mutableStateListOf()
val selectedDevice: MutableState<Device?> = mutableStateOf(null)
val bluetoothTurnOnState: MutableState<Boolean?> = mutableStateOf(false)
val locationTurnOnState: MutableState<Boolean?> = mutableStateOf(null)
val isBluetoothDiscoveryRunning: MutableState<Boolean> = mutableStateOf(false)
private val _bluetoothState = MutableStateFlow(BluetoothUiState())
val state: StateFlow<BluetoothUiState>
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<ConnectionResult>.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() { class PermissionDialogViewModel: ViewModel() {
val visiblePermissionDialogQueue = mutableStateListOf<String>() val visiblePermissionDialogQueue = mutableStateListOf<String>()

View File

@@ -6,6 +6,7 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList

View File

@@ -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")
}
}
}

View File

@@ -4,25 +4,23 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.content.Intent import android.content.Intent
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import com.helible.pilot.components.RequiredHardwareFeatures import com.helible.pilot.components.RequiredHardwareFeatures
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun RequestHardwareFeatures( fun RequestHardwareFeatures(
activity: Activity, activity: Activity,
turnOnLocation: Boolean, bluetoothUiState: BluetoothUiState
bluetoothTurnOnState: MutableState<Boolean?>,
locationTurnOnState: MutableState<Boolean?>
) )
{ {
RequiredHardwareFeatures( RequiredHardwareFeatures(
title = "Включите Bluetooth", title = "Включите Bluetooth",
description = "Для работы приложения требуется Bluetooth", description = "Для работы приложения требуется Bluetooth",
confirmButtonText = "Включить Bluetooth", confirmButtonText = "Включить Bluetooth",
featureState = bluetoothTurnOnState, featureState = bluetoothUiState.isEnabled,
requestFeature = { requestFeature = {
val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
activity.startActivity(intent) activity.startActivity(intent)
@@ -30,13 +28,13 @@ fun RequestHardwareFeatures(
onDismissRequest = {} onDismissRequest = {}
) )
if (turnOnLocation) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
RequiredHardwareFeatures( RequiredHardwareFeatures(
title = "Пожалуйста, включите геолокацию", title = "Пожалуйста, включите геолокацию",
description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " + description = "Для работы с Bluetooth на устройствах с Android 11 и более ранних версиях, " +
"требуется геолокация.", "требуется геолокация.",
confirmButtonText = "Включить геолокацию", confirmButtonText = "Включить геолокацию",
featureState = locationTurnOnState, featureState = bluetoothUiState.isLocationEnabled,
requestFeature = { requestFeature = {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
activity.startActivity(intent) activity.startActivity(intent)

View File

@@ -1,7 +1,7 @@
package com.helible.pilot.components package com.helible.pilot.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.util.Log
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,22 +18,23 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.Dimension
import com.helible.pilot.BluetoothUiState
import com.helible.pilot.Device import com.helible.pilot.Device
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun BluetoothScannerScreen( fun BluetoothScannerScreen(
devices: MutableList<Device>, bluetoothState: BluetoothUiState,
selectedDevice: MutableState<Device?>, selectedDevice: Device?,
bluetoothIsDiscoveringState: MutableState<Boolean>, startScan: () -> Unit,
bluetoothAdapter: BluetoothAdapter?, cancelScan: () -> Unit,
choiceDevice: (device: Device?) -> Unit,
onScreenChanged: () -> Unit, onScreenChanged: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@@ -52,8 +53,9 @@ fun BluetoothScannerScreen(
) )
DiscoveredDevicesList( DiscoveredDevicesList(
devices = devices, devices = bluetoothState.scannedDevices,
selectedDevice = selectedDevice, selectedDevice = selectedDevice,
choiceDevice = choiceDevice,
modifier = Modifier modifier = Modifier
.constrainAs(devicesList) { .constrainAs(devicesList) {
top.linkTo(title.bottom) top.linkTo(title.bottom)
@@ -62,7 +64,7 @@ fun BluetoothScannerScreen(
} }
) )
if (devices.isEmpty() && bluetoothIsDiscoveringState.value) { if (bluetoothState.scannedDevices.isEmpty() && bluetoothState.isDiscovering) {
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
@@ -81,21 +83,20 @@ fun BluetoothScannerScreen(
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
FilledIconToggleButton( FilledIconToggleButton(
checked = bluetoothIsDiscoveringState.value, checked = bluetoothState.isDiscovering,
onCheckedChange = { onCheckedChange = {
selectedDevice.value = null if (bluetoothState.isDiscovering) {
if (bluetoothIsDiscoveringState.value) cancelScan()
bluetoothAdapter?.cancelDiscovery() Log.i("ScanActivity", "Trying to start scan via button")
else { } else {
devices.clear() startScan()
bluetoothAdapter?.startDiscovery()
} }
}, modifier = Modifier }, modifier = Modifier
.align(Alignment.Bottom) .align(Alignment.Bottom)
.padding(5.dp) .padding(5.dp)
) { ) {
Icon( Icon(
if (bluetoothIsDiscoveringState.value) Icons.Filled.Close if (bluetoothState.isDiscovering) Icons.Filled.Close
else Icons.Filled.Refresh, else Icons.Filled.Refresh,
contentDescription = null contentDescription = null
) )
@@ -107,7 +108,7 @@ fun BluetoothScannerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.Bottom) .align(Alignment.Bottom)
.padding(5.dp), .padding(5.dp),
enabled = selectedDevice.value != null, enabled = selectedDevice != null,
) { ) {
Text(text = "Далее") Text(text = "Далее")
} }

View File

@@ -29,12 +29,12 @@ import com.helible.pilot.R
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState<Device?>, modifier: Modifier) { fun DeviceItem(deviceInfo: Device, selectedDevice: Device?, choiceDevice: (device: Device?) -> Unit, modifier: Modifier) {
ElevatedCard( ElevatedCard(
modifier=modifier.clickable { 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 MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surface
) )
@@ -42,12 +42,7 @@ fun DeviceItem(deviceInfo: Device, selectedDevice: MutableState<Device?>, modifi
Row(modifier=Modifier.padding(8.dp)) { Row(modifier=Modifier.padding(8.dp)) {
Column(verticalArrangement = Arrangement.Center) { Column(verticalArrangement = Arrangement.Center) {
Text(text=deviceInfo.bluetoothDevice.name, fontWeight = FontWeight.Bold, softWrap = true) Text(text=deviceInfo.bluetoothDevice.name, fontWeight = FontWeight.Bold, softWrap = true)
AndroidView(factory = { context -> Text(text="MAC: ${deviceInfo.bluetoothDevice.address}", fontWeight = FontWeight.Thin)
TextView(context).apply {
// Using old TextView for text formatting
text = HtmlCompat.fromHtml("<b>MAC</b>: ${deviceInfo.bluetoothDevice.address}", HtmlCompat.FROM_HTML_MODE_LEGACY)
}
})
} }
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) { Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) {
Icon( Icon(

View File

@@ -5,18 +5,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.helible.pilot.Device import com.helible.pilot.Device
@Composable @Composable
fun DiscoveredDevicesList(devices: MutableList<Device>, selectedDevice: MutableState<Device?>, modifier: Modifier = Modifier) { fun DiscoveredDevicesList(devices: List<Device>, selectedDevice: Device?, choiceDevice: (device: Device?) -> Unit, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) { LazyColumn(modifier = modifier) {
items(devices) { device -> items(devices) { device ->
DeviceItem( DeviceItem(
deviceInfo = device, deviceInfo = device,
selectedDevice = selectedDevice, selectedDevice = selectedDevice,
choiceDevice = choiceDevice,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(

View File

@@ -14,11 +14,11 @@ fun RequiredHardwareFeatures(
title: String, title: String,
description: String, description: String,
confirmButtonText: String, confirmButtonText: String,
featureState: MutableState<Boolean?>, featureState: Boolean,
requestFeature: () -> Unit, requestFeature: () -> Unit,
onDismissRequest: () -> Unit onDismissRequest: () -> Unit
) { ) {
if (featureState.value == false || featureState.value == null) { if (!featureState) {
AlertDialog( AlertDialog(
confirmButton = { confirmButton = {
Divider() Divider()