ViewModels with lifecycle integration, new Device class, code reformat

This commit is contained in:
2023-12-30 22:49:47 +07:00
parent 7436599ad3
commit d7f3bf386d
29 changed files with 743 additions and 490 deletions

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" /> <option name="version" value="1.6.10" />
</component> </component>
</project> </project>

View File

@@ -1,7 +1,7 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("kotlin-parcelize") id("com.google.devtools.ksp").version("1.6.10-1.0.4")
} }
android { android {
@@ -63,7 +63,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1") implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
implementation("androidx.navigation:navigation-compose:2.6.0") implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("com.google.code.gson:gson:2.10.1") implementation("com.squareup.moshi:moshi-kotlin:1.14.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

Binary file not shown.

View File

@@ -12,7 +12,7 @@ import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed") class TransferFailedException : IOException("Reading incoming data failed")
class BluetoothDataTransferService( class BluetoothDataTransferService(
private val socket: BluetoothSocket private val socket: BluetoothSocket,
) { ) {
fun listenForIncomingMessages(): Flow<KMessage> { fun listenForIncomingMessages(): Flow<KMessage> {
return flow { return flow {

View File

@@ -1,140 +0,0 @@
package com.helible.pilot
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
import kotlinx.coroutines.launch
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
)
}.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.TransferSucceded -> {
TODO("Telemetry not implemented")
}
is ConnectionResult.Error -> {
_state.update { it.copy(
isConnected = false,
isConnecting = false,
errorMessage = result.message
) }
}
}
}
.catch { _ ->
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()
}
fun sendMessage(message: KMessage) {
viewModelScope.launch {
bluetoothController.trySendMessage(message)
}
}
}

View File

@@ -8,7 +8,7 @@ data class KMessage(
val r2: UShort, val r2: UShort,
val r3: UShort, val r3: UShort,
val emergStop: Boolean, val emergStop: Boolean,
val alarm: Boolean val alarm: Boolean,
) )
fun KMessage.toByteArray(): ByteArray { fun KMessage.toByteArray(): ByteArray {

View File

@@ -1,105 +1,73 @@
package com.helible.pilot package com.helible.pilot
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
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.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.lifecycle.viewmodel.compose.viewModel
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.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.FlightControlScreen
import com.helible.pilot.components.SavedPreferences import com.helible.pilot.components.AppPreferences
import com.helible.pilot.components.SavedPreferencesImpl
import com.helible.pilot.permissions.PermissionsLauncher
import com.helible.pilot.permissions.PermissionsRequest
import com.helible.pilot.permissions.RequestHardwareFeatures
import com.helible.pilot.ui.theme.TestblueTheme import com.helible.pilot.ui.theme.TestblueTheme
import com.helible.pilot.viewmodels.BluetoothViewModel
import com.helible.pilot.viewmodels.BluetoothViewModelFactory
import com.helible.pilot.viewmodels.PermissionDialogViewModel
import com.helible.pilot.viewmodels.PreferencesViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// TODO: replace field bluetoothDevice in Device to deviceAddress field // TODO: device screen logic
// TODO: share selected device via PersistentViewModel // TODO: add Bluetooth telemetry...
// TODO: add stub instead of the DevicesList, if there aren't nearby devices // TODO: move text strings to resources
// TODO: add Bluetooth data transfer...
// TODO: add text strings to resource
private val bluetoothViewModel by lazy { private val preferences by lazy {
BluetoothViewModel(AndroidBluetoothController(applicationContext)) SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
}
private var permissionsViewModel = PermissionDialogViewModel()
private val permissionsToRequest: Array<String> by lazy {
if (Build.VERSION.SDK_INT <= 30) {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
}
}
private val preferencesCache by lazy {
PreferencesCacheImpl(getSharedPreferences(packageName, Context.MODE_PRIVATE))
} }
private val preferencesViewModel by lazy { private val preferencesViewModel by lazy {
PersistentViewModel(preferencesCache) PreferencesViewModel(preferences)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val permissionLauncher = rememberLauncherForActivityResult( val bluetoothViewModel =
contract = ActivityResultContracts.RequestMultiplePermissions(), viewModel<BluetoothViewModel>(factory = BluetoothViewModelFactory(applicationContext))
onResult = { perms ->
permissionsToRequest.forEach { permission -> val permissionsViewModel = viewModel<PermissionDialogViewModel>()
permissionsViewModel.onPermissionResult( val permissionLauncher = PermissionsLauncher()
permission = permission, permissionLauncher.setup(
isGranted = perms[permission] == true onPermissionResult = { perm, isGranted ->
) permissionsViewModel.onPermissionResult(perm, isGranted)
} },
if(hasAllPermissions() && !bluetoothViewModel.state.value.isDiscovering) onGranted = { bluetoothViewModel.startScan() }
bluetoothViewModel.startScan()
}
) )
val bluetoothState by bluetoothViewModel.state.collectAsState() val bluetoothState by bluetoothViewModel.state.collectAsState()
val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState() val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState()
LaunchedEffect(key1 = null) { LaunchedEffect(key1 = null) {
permissionLauncher.launch(permissionsToRequest) permissionLauncher.launch()
} }
LaunchedEffect(key1 = bluetoothState) { LaunchedEffect(key1 = bluetoothState) {
if (bluetoothState.isConnected) { if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG) Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_SHORT)
.show() .show()
bluetoothViewModel.sendMessage(KMessage(1u,2u,3u,false, false))
} }
} }
@@ -108,7 +76,6 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(key1 = bluetoothState.errorMessage) { LaunchedEffect(key1 = bluetoothState.errorMessage) {
bluetoothState.errorMessage?.let { message -> bluetoothState.errorMessage?.let { message ->
Toast.makeText(applicationContext, "Ошибка: $message", Toast.LENGTH_LONG).show() Toast.makeText(applicationContext, "Ошибка: $message", Toast.LENGTH_LONG).show()
navController.navigate("scanner")
} }
} }
@@ -118,7 +85,7 @@ class MainActivity : ComponentActivity() {
dismissCurrentDialog = { permissionsViewModel.dismissDialog() }, dismissCurrentDialog = { permissionsViewModel.dismissDialog() },
visiblePermissionDialogQueue = permissionsViewModel.visiblePermissionDialogQueue, visiblePermissionDialogQueue = permissionsViewModel.visiblePermissionDialogQueue,
activity = this, activity = this,
permissionLauncher = permissionLauncher permissionLaunch = { perms -> permissionLauncher.launch(perms) }
) )
RequestHardwareFeatures( RequestHardwareFeatures(
@@ -126,7 +93,10 @@ class MainActivity : ComponentActivity() {
bluetoothUiState = bluetoothState bluetoothUiState = bluetoothState
) )
NavHost(navController = navController, startDestination = "scanner") { NavHost(
navController = navController,
startDestination = "device"
) {
composable("scanner") { composable("scanner") {
BluetoothScannerScreen( BluetoothScannerScreen(
bluetoothState = bluetoothState, bluetoothState = bluetoothState,
@@ -136,13 +106,18 @@ class MainActivity : ComponentActivity() {
choiceDevice = { device -> bluetoothViewModel.selectDevice(device) }, choiceDevice = { device -> bluetoothViewModel.selectDevice(device) },
onScreenChanged = { onScreenChanged = {
bluetoothViewModel.cancelScan() bluetoothViewModel.cancelScan()
val deviceAddress = selectedDevice?.bluetoothDevice?.address val device = selectedDevice
if (device == null) {
preferencesViewModel.clearPreferences()
} else {
preferencesViewModel.savePreferences( preferencesViewModel.savePreferences(
SavedPreferences( AppPreferences(
deviceAddress deviceName = device.name,
deviceAddress = device.macAddress
) )
) )
navController.navigate("flight/$deviceAddress") }
navController.navigate("device")
Log.i( Log.i(
"ScanActivity", "ScanActivity",
"Preferences: ${preferencesViewModel.preferences}" "Preferences: ${preferencesViewModel.preferences}"
@@ -150,44 +125,28 @@ class MainActivity : ComponentActivity() {
} }
) )
} }
composable( composable("device")
"flight/{device_address}", {
arguments = listOf(navArgument("device_address"){type = NavType.StringType}) FlightControlScreen(
) { bluetoothUiState = bluetoothState,
backstackEntry -> getPreferences = { preferencesViewModel.preferences },
LaunchedEffect(Unit) { navigateToScanner = { navController.navigate("scanner") },
val device: Device? = selectedDevice connectToDevice = { device ->
if(device == null){ bluetoothViewModel.connectToDevice(
navController.navigate("scanner") device
} else {
bluetoothViewModel.connectToDevice(device)
}
}
BackHandler {
bluetoothViewModel.disconnectFromDevice()
Log.i("FlightScreen", "Disconnected from device")
navController.navigate("scanner")
}
when {
bluetoothState.isConnecting -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Text(text = "Подключение...", textAlign = TextAlign.Center)
}
}
else -> {
Text(
text = "Device name: ${backstackEntry.arguments?.getString("device_address")}",
modifier = Modifier.fillMaxSize(),
textAlign = TextAlign.Center
) )
} },
} sendRotorsState = { message ->
bluetoothViewModel.sendRotorsDutySpeed(
message
)
},
disconnectFromDevice = { bluetoothViewModel.disconnectFromDevice() },
sendEmergStop = { bluetoothViewModel.sendEmergStop() },
sendAlarm = { message -> bluetoothViewModel.sendAlarmState(message) },
sendR3Duty = { duty -> bluetoothViewModel.sendR3Duty(duty) }
)
if (preferencesViewModel.preferences != null) BackHandler {}
} }
} }
} }
@@ -195,33 +154,6 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onDestroy() {
super.onDestroy()
bluetoothViewModel.onDestroy()
}
override fun onStart() {
super.onStart()
bluetoothViewModel.startScan()
}
override fun onStop() {
super.onStop()
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
}
} }

View File

@@ -1,38 +0,0 @@
package com.helible.pilot
import android.bluetooth.BluetoothDevice
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import com.helible.pilot.components.SavedPreferences
import com.helible.pilot.components.SavedPreferencesCache
data class Device(
val bluetoothDevice: BluetoothDevice,
val rssi: Short,
val isPaired: Boolean = false
)
class PermissionDialogViewModel: ViewModel() {
val visiblePermissionDialogQueue = mutableStateListOf<String>()
fun dismissDialog() {
visiblePermissionDialogQueue.removeFirst()
}
fun onPermissionResult(permission: String, isGranted: Boolean) {
if(!isGranted && !visiblePermissionDialogQueue.contains(permission)){
visiblePermissionDialogQueue.add(permission)
}
}
}
class PersistentViewModel(
private val preferencesCache: SavedPreferencesCache,
) : ViewModel() {
val preferences get() = preferencesCache.getPreferences()
fun savePreferences(savedPreferences: SavedPreferences) {
preferencesCache.savePreferences(
preferences = savedPreferences
)
}
}

View File

@@ -3,7 +3,6 @@ package com.helible.pilot.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log 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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -12,7 +11,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -23,20 +21,20 @@ 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.dataclasses.BluetoothUiState
import com.helible.pilot.Device import com.helible.pilot.dataclasses.BluetoothDevice
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun BluetoothScannerScreen( fun BluetoothScannerScreen(
bluetoothState: BluetoothUiState, bluetoothState: BluetoothUiState,
selectedDevice: Device?, selectedDevice: BluetoothDevice?,
startScan: () -> Unit, startScan: () -> Unit,
cancelScan: () -> Unit, cancelScan: () -> Unit,
choiceDevice: (device: Device?) -> Unit, choiceDevice: (device: BluetoothDevice?) -> Unit,
onScreenChanged: () -> Unit, onScreenChanged: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
Surface( Surface(
modifier = modifier, modifier = modifier,

View File

@@ -1,7 +1,6 @@
package com.helible.pilot.components package com.helible.pilot.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.widget.TextView
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,23 +15,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
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.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import com.helible.pilot.dataclasses.BluetoothDevice
import androidx.core.text.HtmlCompat
import com.helible.pilot.Device
import com.helible.pilot.R import com.helible.pilot.R
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun DeviceItem( fun DeviceItem(
deviceInfo: Device, deviceInfo: BluetoothDevice,
selectedDevice: Device?, selectedDevice: BluetoothDevice?,
choiceDevice: (device: Device?) -> Unit, choiceDevice: (device: BluetoothDevice?) -> Unit,
modifier: Modifier, modifier: Modifier,
) { ) {
ElevatedCard( ElevatedCard(
@@ -40,7 +36,7 @@ fun DeviceItem(
choiceDevice(deviceInfo) choiceDevice(deviceInfo)
}, },
colors = CardDefaults.elevatedCardColors( colors = CardDefaults.elevatedCardColors(
containerColor = if (deviceInfo.bluetoothDevice == selectedDevice?.bluetoothDevice) containerColor = if (deviceInfo == selectedDevice)
MaterialTheme.colorScheme.secondaryContainer MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surface
) )
@@ -48,16 +44,16 @@ fun DeviceItem(
Row(modifier = Modifier.padding(8.dp)) { Row(modifier = Modifier.padding(8.dp)) {
Column(verticalArrangement = Arrangement.Center) { Column(verticalArrangement = Arrangement.Center) {
Text( Text(
text = deviceInfo.bluetoothDevice.name, text = deviceInfo.name,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
softWrap = true softWrap = true
) )
Text( Text(
text = "MAC: ${deviceInfo.bluetoothDevice.address}", text = "MAC: ${deviceInfo.macAddress}",
fontWeight = FontWeight.Thin fontWeight = FontWeight.Thin
) )
} }
if (!deviceInfo.isPaired) { if (deviceInfo.isScanned) {
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) { Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) {
Icon( Icon(
painterResource(id = getSignalIconForRssiValue(deviceInfo.rssi)), painterResource(id = getSignalIconForRssiValue(deviceInfo.rssi)),

View File

@@ -0,0 +1,25 @@
package com.helible.pilot.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeviceScreen() {
Scaffold(
topBar = {
Text(text = "")
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
}
}
}

View File

@@ -14,15 +14,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.helible.pilot.BluetoothUiState import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.Device import com.helible.pilot.dataclasses.BluetoothDevice
@Composable @Composable
fun DiscoveredDevicesList( fun DiscoveredDevicesList(
bluetoothState: BluetoothUiState, bluetoothState: BluetoothUiState,
selectedDevice: Device?, selectedDevice: BluetoothDevice?,
choiceDevice: (device: Device?) -> Unit, choiceDevice: (device: BluetoothDevice?) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier) { LazyColumn(modifier = modifier) {
item { item {
@@ -33,7 +33,7 @@ fun DiscoveredDevicesList(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
items(bluetoothState.pairedDevices) { device -> items(bluetoothState.pairedBluetoothDevices) { device ->
DeviceItem( DeviceItem(
deviceInfo = device, deviceInfo = device,
selectedDevice = selectedDevice, selectedDevice = selectedDevice,
@@ -45,7 +45,7 @@ fun DiscoveredDevicesList(
) )
) )
} }
if(bluetoothState.pairedDevices.isEmpty()){ if (bluetoothState.pairedBluetoothDevices.isEmpty()) {
item { item {
Text( Text(
text = "Нет элементов для отображения", text = "Нет элементов для отображения",
@@ -64,7 +64,7 @@ fun DiscoveredDevicesList(
) )
} }
items(bluetoothState.scannedDevices) { device -> items(bluetoothState.scannedBluetoothDevices) { device ->
DeviceItem( DeviceItem(
deviceInfo = device, deviceInfo = device,
selectedDevice = selectedDevice, selectedDevice = selectedDevice,
@@ -76,10 +76,13 @@ fun DiscoveredDevicesList(
) )
) )
} }
if(bluetoothState.scannedDevices.isEmpty()) { if (bluetoothState.scannedBluetoothDevices.isEmpty()) {
if (bluetoothState.isDiscovering) { if (bluetoothState.isDiscovering) {
item { item {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator() CircularProgressIndicator()
Text(text = "Поиск устройств", modifier = Modifier.padding(10.dp)) Text(text = "Поиск устройств", modifier = Modifier.padding(10.dp))
} }

View File

@@ -16,7 +16,7 @@ fun RequiredHardwareFeatures(
confirmButtonText: String, confirmButtonText: String,
featureState: Boolean, featureState: Boolean,
requestFeature: () -> Unit, requestFeature: () -> Unit,
onDismissRequest: () -> Unit onDismissRequest: () -> Unit,
) { ) {
if (!featureState) { if (!featureState) {
AlertDialog( AlertDialog(

View File

@@ -0,0 +1,130 @@
package com.helible.pilot.components
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.helible.pilot.dataclasses.AlarmStateMessage
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.RotorsSpeedMessage
import kotlin.math.roundToInt
@Composable
fun FlightControlScreen(
bluetoothUiState: BluetoothUiState,
getPreferences: () -> AppPreferences?,
navigateToScanner: () -> Unit,
connectToDevice: (String) -> Unit,
disconnectFromDevice: () -> Unit,
sendRotorsState: (RotorsSpeedMessage) -> Unit,
sendAlarm: (AlarmStateMessage) -> Unit,
sendEmergStop: () -> Unit,
sendR3Duty: (Int) -> Unit,
) {
LaunchedEffect(Unit) {
val preferences: AppPreferences? = getPreferences()
if (preferences == null) {
navigateToScanner()
} else {
connectToDevice(preferences.deviceAddress)
}
}
var rotor1Duty by remember { mutableStateOf(0f) }
var rotor2Duty by remember { mutableStateOf(0f) }
var rotor3Duty by remember { mutableStateOf(0f) }
BackHandler {
disconnectFromDevice()
Log.i("FlightScreen", "Disconnected from the device")
navigateToScanner()
}
when {
bluetoothUiState.isConnecting -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Text(text = "Подключение...", textAlign = TextAlign.Center)
}
}
else -> {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "Device name: ${getPreferences()?.deviceName ?: "(устройство отключено)"}",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Text(text = "Rotor 1 value: $rotor1Duty", textAlign = TextAlign.Center)
Slider(
value = rotor1Duty,
onValueChange = { rotor1Duty = it.roundToInt().toFloat() },
valueRange = 0f..1000f,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
Text(text = "Rotor 2 value: $rotor2Duty", textAlign = TextAlign.Center)
Slider(
value = rotor2Duty,
onValueChange = { rotor2Duty = it.roundToInt().toFloat() },
valueRange = 0f..1000f,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
Text(text = "Rotor 3 value: $rotor1Duty", textAlign = TextAlign.Center)
Slider(
value = rotor3Duty,
onValueChange = { rotor3Duty = it.roundToInt().toFloat() },
valueRange = 0f..1000f,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
)
FilledIconButton(
onClick = { sendEmergStop() },
modifier = Modifier.padding(10.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(3.dp)
)
Text(
text = "СТОП",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(3.dp)
)
}
}
}
}
}

View File

@@ -23,7 +23,7 @@ fun PermissionDialog(
onOkClick: () -> Unit, onOkClick: () -> Unit,
onContinueClick: () -> Unit, onContinueClick: () -> Unit,
onGoToAppSettingsClick: () -> Unit, onGoToAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -54,7 +54,8 @@ fun PermissionDialog(
) )
} }
}, },
dismissButton = {if(isPermanentDeclined) dismissButton = {
if (isPermanentDeclined)
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
Divider() Divider()
Text( Text(
@@ -65,13 +66,17 @@ fun PermissionDialog(
.clickable { onContinueClick() }, .clickable { onContinueClick() },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center textAlign = TextAlign.Center
)} )
}
else else
Unit}, Unit
},
text = { text = {
Text(text = permissionTextProvider.getDescription( Text(
text = permissionTextProvider.getDescription(
isPermanentDeclined = isPermanentDeclined isPermanentDeclined = isPermanentDeclined
)) )
)
}, },
modifier = modifier modifier = modifier
) )

View File

@@ -1,37 +1,41 @@
package com.helible.pilot.components package com.helible.pilot.components
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Parcelable import com.squareup.moshi.JsonAdapter
import com.google.gson.Gson import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
@JsonClass(generateAdapter = true)
data class AppPreferences(
val deviceName: String,
val deviceAddress: String,
)
@Parcelize interface SavedPreferences {
data class SavedPreferences( fun getPreferences(): AppPreferences?
val deviceAddress: String? fun savePreferences(preferences: AppPreferences)
): Parcelable
interface SavedPreferencesCache {
fun getPreferences(): SavedPreferences?
fun savePreferences(preferences: SavedPreferences)
fun clearPreferences() fun clearPreferences()
} }
class PreferencesCacheImpl(private val sharedPreferences: SharedPreferences) : SavedPreferencesCache { class SavedPreferencesImpl(private val sharedPreferences: SharedPreferences) : SavedPreferences {
override fun getPreferences(): SavedPreferences? { private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val preferencesAdapter: JsonAdapter<AppPreferences> =
moshi.adapter(AppPreferences::class.java)
override fun getPreferences(): AppPreferences? {
val json = sharedPreferences.getString("preferences", null) ?: return null val json = sharedPreferences.getString("preferences", null) ?: return null
return Gson().fromJson(json, SavedPreferences::class.java) return preferencesAdapter.fromJson(json)
} }
override fun savePreferences(preferences: SavedPreferences)
{ override fun savePreferences(preferences: AppPreferences) {
sharedPreferences.edit() sharedPreferences.edit()
.putString("preferences", Gson().toJson(preferences)) .putString("preferences", preferencesAdapter.toJson(preferences))
.apply() .apply()
} }
override fun clearPreferences() override fun clearPreferences() {
{
sharedPreferences.edit().remove("preferences").apply() sharedPreferences.edit().remove("preferences").apply()
} }
} }

View File

@@ -1,4 +1,4 @@
package com.helible.pilot package com.helible.pilot.controllers
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -14,6 +14,11 @@ import android.os.Build
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 com.helible.pilot.BluetoothDataTransferService
import com.helible.pilot.dataclasses.BluetoothDeviceDomain
import com.helible.pilot.KMessage
import com.helible.pilot.receivers.BluetoothAdapterStateReceiver
import com.helible.pilot.receivers.BluetoothStateReceiver
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -44,14 +49,14 @@ interface BluetoothController {
val isLocationEnabled: StateFlow<Boolean> val isLocationEnabled: StateFlow<Boolean>
val isConnected: StateFlow<Boolean> val isConnected: StateFlow<Boolean>
val isScanning: StateFlow<Boolean> val isScanning: StateFlow<Boolean>
val scannedDevices: StateFlow<List<Device>> val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
val pairedDevices: StateFlow<List<Device>> val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
val errors: SharedFlow<String> val errors: SharedFlow<String>
fun startDiscovery() fun startDiscovery()
fun cancelDiscovery() fun cancelDiscovery()
fun connectToDevice(device: Device?): Flow<ConnectionResult> fun connectToDevice(device: String): Flow<ConnectionResult>
suspend fun trySendMessage(message: KMessage): KMessage? suspend fun trySendMessage(message: ByteArray): Boolean
fun closeConnection() fun closeConnection()
fun onDestroy() fun onDestroy()
} }
@@ -92,12 +97,13 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
override val isLocationEnabled: StateFlow<Boolean> override val isLocationEnabled: StateFlow<Boolean>
get() = _isLocationEnabled.asStateFlow() get() = _isLocationEnabled.asStateFlow()
private val _pairedDevices = MutableStateFlow<List<Device>>(emptyList()) private val _pairedDevices = MutableStateFlow<List<BluetoothDeviceDomain>>(emptyList())
override val pairedDevices: StateFlow<List<Device>> override val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
get() = _pairedDevices.asStateFlow() get() = _pairedDevices.asStateFlow()
private val _scannedDevices: MutableStateFlow<List<Device>> = MutableStateFlow(emptyList()) private val _scannedDevices: MutableStateFlow<List<BluetoothDeviceDomain>> =
override val scannedDevices: StateFlow<List<Device>> MutableStateFlow(emptyList())
override val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
get() = _scannedDevices.asStateFlow() get() = _scannedDevices.asStateFlow()
private var currentClientSocket: BluetoothSocket? = null private var currentClientSocket: BluetoothSocket? = null
@@ -125,7 +131,8 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
private val bluetoothStateReceiver = BluetoothStateReceiver( private val bluetoothStateReceiver = BluetoothStateReceiver(
onDeviceFound = { device, rssi -> onDeviceFound = { device, rssi ->
if (!hasAllPermissions()) return@BluetoothStateReceiver if (!hasAllPermissions()) return@BluetoothStateReceiver
val newDevice = Device(device, rssi) val newDevice =
BluetoothDeviceDomain(device.name ?: "null", device.address, rssi, isScanned = true)
_scannedDevices.update { devices -> _scannedDevices.update { devices ->
if (newDevice in devices) devices else devices + newDevice if (newDevice in devices) devices else devices + newDevice
} }
@@ -206,13 +213,14 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
override fun connectToDevice(device: Device?): Flow<ConnectionResult> { override fun connectToDevice(device: String): Flow<ConnectionResult> {
if (!hasAllPermissions()) { if (!hasAllPermissions()) {
Toast.makeText(context, "Ошибка: нет разрешений", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Ошибка: нет разрешений", Toast.LENGTH_SHORT).show()
return flow {} return flow {}
} }
return flow { return flow {
currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord( currentClientSocket =
bluetoothAdapter.getRemoteDevice(device).createRfcommSocketToServiceRecord(
UUID.fromString(SERVICE_UUID) UUID.fromString(SERVICE_UUID)
) )
currentClientSocket?.let { socket -> currentClientSocket?.let { socket ->
@@ -234,15 +242,15 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO) }.onCompletion { closeConnection() }.flowOn(Dispatchers.IO)
} }
override suspend fun trySendMessage(message: KMessage): KMessage? { override suspend fun trySendMessage(message: ByteArray): Boolean {
if (!hasAllPermissions()) { if (!hasAllPermissions()) {
return null return false
} }
if (dataTransferService == null) { if (dataTransferService == null) {
return null return false
} }
dataTransferService?.sendMessage("R1250\n\r".toByteArray()) dataTransferService?.sendMessage(message)
return message return true
} }
override fun closeConnection() { override fun closeConnection() {
@@ -262,7 +270,12 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
Log.i("ScanActivity", "${bluetoothAdapter?.bondedDevices}") Log.i("ScanActivity", "${bluetoothAdapter?.bondedDevices}")
bluetoothAdapter?.bondedDevices?.onEach { device -> bluetoothAdapter?.bondedDevices?.onEach { device ->
_pairedDevices.update { _pairedDevices.update {
val currentDevice = Device(bluetoothDevice = device, rssi=0, isPaired = true) val currentDevice = BluetoothDeviceDomain(
name = device.name ?: "null",
macAddress = device.address,
rssi = 0,
isScanned = false
)
if (currentDevice in pairedDevices.value) { if (currentDevice in pairedDevices.value) {
pairedDevices.value pairedDevices.value
} else { } else {

View File

@@ -0,0 +1,10 @@
package com.helible.pilot.dataclasses
typealias BluetoothDeviceDomain = BluetoothDevice
data class BluetoothDevice(
val name: String,
val macAddress: String,
val rssi: Short,
val isScanned: Boolean = false,
)

View File

@@ -1,6 +1,5 @@
package com.helible.pilot package com.helible.pilot.dataclasses
import android.bluetooth.BluetoothDevice
data class BluetoothUiState( data class BluetoothUiState(
val isEnabled: Boolean = false, val isEnabled: Boolean = false,
val isLocationEnabled: Boolean = false, val isLocationEnabled: Boolean = false,
@@ -8,6 +7,6 @@ data class BluetoothUiState(
val isConnected: Boolean = false, val isConnected: Boolean = false,
val isConnecting: Boolean = false, val isConnecting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val scannedDevices: List<Device> = emptyList(), val scannedBluetoothDevices: List<BluetoothDevice> = emptyList(),
val pairedDevices: List<Device> = emptyList(), val pairedBluetoothDevices: List<BluetoothDevice> = emptyList(),
) )

View File

@@ -0,0 +1,12 @@
package com.helible.pilot.dataclasses
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class RotorsSpeedMessage(val r1: Short, val r2: Short, val r3: Short)
@JsonClass(generateAdapter = true)
data class EmergStopMessage(val emergStop: Boolean)
@JsonClass(generateAdapter = true)
data class AlarmStateMessage(val isAlarmOn: Boolean)

View File

@@ -0,0 +1,50 @@
package com.helible.pilot.permissions
import android.Manifest
import android.annotation.SuppressLint
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
class PermissionsLauncher {
private val permissionsToRequest: Array<String> by lazy {
if (Build.VERSION.SDK_INT <= 30) {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
)
}
}
private lateinit var launcher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
@SuppressLint("ComposableNaming")
@Composable
fun setup(
onPermissionResult: (permission: String, isGranted: Boolean) -> Unit,
onGranted: () -> Unit,
) {
launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { perms ->
permissionsToRequest.forEach { permission ->
onPermissionResult(permission, perms[permission] == true)
}
if (perms.values.all { it }) {
onGranted()
}
}
)
}
fun launch(permissions: Array<String> = permissionsToRequest) {
launcher.launch(permissions)
}
}

View File

@@ -1,4 +1,4 @@
package com.helible.pilot package com.helible.pilot.permissions
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
@@ -6,7 +6,6 @@ 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 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
import com.helible.pilot.components.BluetoothAdminPermissionTextProvider import com.helible.pilot.components.BluetoothAdminPermissionTextProvider
@@ -20,7 +19,7 @@ fun PermissionsRequest(
visiblePermissionDialogQueue: SnapshotStateList<String>, visiblePermissionDialogQueue: SnapshotStateList<String>,
dismissCurrentDialog: () -> Unit, dismissCurrentDialog: () -> Unit,
activity: Activity, activity: Activity,
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>> permissionLaunch: (permissions: Array<String>) -> Unit,
) { ) {
/* Create Dialog windows, which requests all permissions */ /* Create Dialog windows, which requests all permissions */
visiblePermissionDialogQueue.reversed() visiblePermissionDialogQueue.reversed()
@@ -52,7 +51,7 @@ fun PermissionsRequest(
}, },
onOkClick = { onOkClick = {
dismissCurrentDialog() dismissCurrentDialog()
permissionLauncher.launch(arrayOf(permission)) permissionLaunch(arrayOf(permission))
}, },
onContinueClick = { onContinueClick = {
if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) if (activity.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)

View File

@@ -1,4 +1,4 @@
package com.helible.pilot package com.helible.pilot.permissions
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@@ -8,14 +8,14 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.helible.pilot.components.RequiredHardwareFeatures import com.helible.pilot.components.RequiredHardwareFeatures
import com.helible.pilot.dataclasses.BluetoothUiState
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@Composable @Composable
fun RequestHardwareFeatures( fun RequestHardwareFeatures(
activity: Activity, activity: Activity,
bluetoothUiState: BluetoothUiState bluetoothUiState: BluetoothUiState,
) ) {
{
RequiredHardwareFeatures( RequiredHardwareFeatures(
title = "Включите Bluetooth", title = "Включите Bluetooth",
description = "Для работы приложения требуется Bluetooth", description = "Для работы приложения требуется Bluetooth",

View File

@@ -1,4 +1,4 @@
package com.helible.pilot package com.helible.pilot.receivers
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -12,7 +12,7 @@ import android.os.Build
class BluetoothAdapterStateReceiver( class BluetoothAdapterStateReceiver(
private val onBluetoothEnabledChanged: (isBluetoothEnabled: Boolean) -> Unit, private val onBluetoothEnabledChanged: (isBluetoothEnabled: Boolean) -> Unit,
private val onDiscoveryRunningChanged: (isDiscoveryRunning: Boolean) -> Unit, private val onDiscoveryRunningChanged: (isDiscoveryRunning: Boolean) -> Unit,
private val onLocationEnabledChanged: () -> Unit private val onLocationEnabledChanged: () -> Unit,
) : BroadcastReceiver() { ) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) { when (intent?.action) {
@@ -27,6 +27,7 @@ class BluetoothAdapterStateReceiver(
} }
} }
} }
LocationManager.PROVIDERS_CHANGED_ACTION -> { LocationManager.PROVIDERS_CHANGED_ACTION -> {
onLocationEnabledChanged() onLocationEnabledChanged()
} }
@@ -41,32 +42,3 @@ class BluetoothAdapterStateReceiver(
} }
} }
} }
class BluetoothStateReceiver(
private val onDeviceFound: (device: BluetoothDevice, rssi: Short) -> Unit,
private val onConnectedStateChanged: (isConnected: Boolean, BluetoothDevice) -> Unit
) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE,
BluetoothDevice::class.java
)
} else {
@Suppress("DEPRECATION") intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
when(intent?.action) {
BluetoothDevice.ACTION_FOUND -> {
val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
@SuppressLint("MissingPermission") if (device?.name != null)
onDeviceFound(device, rssi)
}
BluetoothDevice.ACTION_ACL_CONNECTED -> {
onConnectedStateChanged(true, device ?: return)
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
onConnectedStateChanged(false, device ?: return)
}
}
}
}

View File

@@ -0,0 +1,39 @@
package com.helible.pilot.receivers
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
class BluetoothStateReceiver(
private val onDeviceFound: (device: BluetoothDevice, rssi: Short) -> Unit,
private val onConnectedStateChanged: (isConnected: Boolean, BluetoothDevice) -> Unit,
) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE,
BluetoothDevice::class.java
)
} else {
@Suppress("DEPRECATION") intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
when (intent?.action) {
BluetoothDevice.ACTION_FOUND -> {
val rssi = intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE)
@SuppressLint("MissingPermission") if (device?.name != null)
onDeviceFound(device, rssi)
}
BluetoothDevice.ACTION_ACL_CONNECTED -> {
onConnectedStateChanged(true, device ?: return)
}
BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
onConnectedStateChanged(false, device ?: return)
}
}
}
}

View File

@@ -42,7 +42,7 @@ fun TestblueTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {

View File

@@ -0,0 +1,194 @@
package com.helible.pilot.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.helible.pilot.dataclasses.AlarmStateMessage
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.EmergStopMessage
import com.helible.pilot.dataclasses.RotorsSpeedMessage
import com.helible.pilot.controllers.BluetoothController
import com.helible.pilot.controllers.ConnectionResult
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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
import kotlinx.coroutines.launch
import com.helible.pilot.dataclasses.BluetoothDevice
class BluetoothViewModel(
private val bluetoothController: BluetoothController,
) : ViewModel() {
private val _selectedDevice: MutableStateFlow<BluetoothDevice?> = MutableStateFlow(null)
val selectedDevice: StateFlow<BluetoothDevice?>
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(
scannedBluetoothDevices = scannedDevices.toList(),
pairedBluetoothDevices = pairedDevices
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value)
private val moshi: Moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val rotorsStateMessegeAdapter = moshi.adapter(RotorsSpeedMessage::class.java)
private val alarmStateMessageAdapter = moshi.adapter(AlarmStateMessage::class.java)
private val emergStopMessageAdapter = moshi.adapter(EmergStopMessage::class.java)
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.TransferSucceded -> {
TODO("Telemetry not implemented")
}
is ConnectionResult.Error -> {
_state.update {
it.copy(
isConnected = false,
isConnecting = false,
errorMessage = result.message
)
}
}
}
}
.catch { _ ->
bluetoothController.closeConnection()
_state.update {
it.copy(
isConnected = false,
isConnecting = false
)
}
}
.launchIn(viewModelScope)
}
private var deviceConnectionJob: Job? = null
fun connectToDevice(device: String) {
_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: BluetoothDevice?) {
_selectedDevice.update { selectedDevice }
}
fun startScan() {
selectDevice(null)
bluetoothController.startDiscovery()
}
fun cancelScan() {
bluetoothController.cancelDiscovery()
}
override fun onCleared() {
cancelScan()
bluetoothController.onDestroy()
super.onCleared()
}
fun sendRotorsDutySpeed(rotorsState: RotorsSpeedMessage) {
viewModelScope.launch {
bluetoothController.trySendMessage(
rotorsStateMessegeAdapter.toJson(rotorsState).plus("\r").toByteArray()
)
}
}
fun sendAlarmState(alarmStateMessage: AlarmStateMessage) {
viewModelScope.launch {
bluetoothController.trySendMessage(
alarmStateMessageAdapter.toJson(alarmStateMessage).plus("\r").toByteArray()
)
}
}
fun sendEmergStop() {
viewModelScope.launch {
bluetoothController.trySendMessage(
emergStopMessageAdapter.toJson(EmergStopMessage(true)).plus("\r").toByteArray()
)
}
}
fun sendR3Duty(r3: Int) {
viewModelScope.launch {
bluetoothController.trySendMessage(
"R3$r3\n\r".toByteArray()
)
delay(30)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.helible.pilot.viewmodels
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.helible.pilot.controllers.AndroidBluetoothController
@Suppress("UNCHECKED_CAST")
class BluetoothViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return BluetoothViewModel(
bluetoothController = AndroidBluetoothController(context)
) as T
}
}

View File

@@ -0,0 +1,35 @@
package com.helible.pilot.viewmodels
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import com.helible.pilot.components.AppPreferences
import com.helible.pilot.components.SavedPreferences
class PermissionDialogViewModel : ViewModel() {
val visiblePermissionDialogQueue = mutableStateListOf<String>()
fun dismissDialog() {
visiblePermissionDialogQueue.removeFirst()
}
fun onPermissionResult(permission: String, isGranted: Boolean) {
if (!isGranted && !visiblePermissionDialogQueue.contains(permission)) {
visiblePermissionDialogQueue.add(permission)
}
}
}
class PreferencesViewModel(
private val preferencesStorage: SavedPreferences,
) : ViewModel() {
val preferences: AppPreferences? get() = preferencesStorage.getPreferences()
fun savePreferences(savedPreferences: AppPreferences) {
preferencesStorage.savePreferences(
preferences = savedPreferences
)
}
fun clearPreferences() {
preferencesStorage.clearPreferences()
}
}