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

View File

@@ -1,7 +1,7 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
id("com.google.devtools.ksp").version("1.6.10-1.0.4")
}
android {
@@ -63,7 +63,7 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
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")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

Binary file not shown.

View File

@@ -12,14 +12,14 @@ import java.io.IOException
class TransferFailedException : IOException("Reading incoming data failed")
class BluetoothDataTransferService(
private val socket: BluetoothSocket
private val socket: BluetoothSocket,
) {
fun listenForIncomingMessages(): Flow<KMessage> {
return flow {
if(!socket.isConnected)
if (!socket.isConnected)
return@flow
val buffer = ByteArray(128)
while(true) {
while (true) {
val byteCount: Int = try {
socket.inputStream.read(buffer)
} catch (e: IOException) {

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 r3: UShort,
val emergStop: Boolean,
val alarm: Boolean
val alarm: Boolean,
)
fun KMessage.toByteArray(): ByteArray {

View File

@@ -1,105 +1,73 @@
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.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavType
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.helible.pilot.components.BluetoothScannerScreen
import com.helible.pilot.components.PreferencesCacheImpl
import com.helible.pilot.components.SavedPreferences
import com.helible.pilot.components.FlightControlScreen
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.viewmodels.BluetoothViewModel
import com.helible.pilot.viewmodels.BluetoothViewModelFactory
import com.helible.pilot.viewmodels.PermissionDialogViewModel
import com.helible.pilot.viewmodels.PreferencesViewModel
class MainActivity : ComponentActivity() {
// TODO: replace field bluetoothDevice in Device to deviceAddress field
// TODO: share selected device via PersistentViewModel
// TODO: add stub instead of the DevicesList, if there aren't nearby devices
// TODO: add Bluetooth data transfer...
// TODO: add text strings to resource
// TODO: device screen logic
// TODO: add Bluetooth telemetry...
// TODO: move text strings to resources
private val bluetoothViewModel by lazy {
BluetoothViewModel(AndroidBluetoothController(applicationContext))
}
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 preferences by lazy {
SavedPreferencesImpl(getSharedPreferences(packageName, MODE_PRIVATE))
}
private val preferencesViewModel by lazy {
PersistentViewModel(preferencesCache)
PreferencesViewModel(preferences)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
onResult = { perms ->
permissionsToRequest.forEach { permission ->
permissionsViewModel.onPermissionResult(
permission = permission,
isGranted = perms[permission] == true
)
}
if(hasAllPermissions() && !bluetoothViewModel.state.value.isDiscovering)
bluetoothViewModel.startScan()
}
val bluetoothViewModel =
viewModel<BluetoothViewModel>(factory = BluetoothViewModelFactory(applicationContext))
val permissionsViewModel = viewModel<PermissionDialogViewModel>()
val permissionLauncher = PermissionsLauncher()
permissionLauncher.setup(
onPermissionResult = { perm, isGranted ->
permissionsViewModel.onPermissionResult(perm, isGranted)
},
onGranted = { bluetoothViewModel.startScan() }
)
val bluetoothState by bluetoothViewModel.state.collectAsState()
val selectedDevice by bluetoothViewModel.selectedDevice.collectAsState()
LaunchedEffect(key1 = null) {
permissionLauncher.launch(permissionsToRequest)
permissionLauncher.launch()
}
LaunchedEffect(key1 = bluetoothState) {
if (bluetoothState.isConnected) {
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_LONG)
Toast.makeText(applicationContext, "Подключение завершено", Toast.LENGTH_SHORT)
.show()
bluetoothViewModel.sendMessage(KMessage(1u,2u,3u,false, false))
}
}
@@ -108,7 +76,6 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(key1 = bluetoothState.errorMessage) {
bluetoothState.errorMessage?.let { message ->
Toast.makeText(applicationContext, "Ошибка: $message", Toast.LENGTH_LONG).show()
navController.navigate("scanner")
}
}
@@ -118,7 +85,7 @@ class MainActivity : ComponentActivity() {
dismissCurrentDialog = { permissionsViewModel.dismissDialog() },
visiblePermissionDialogQueue = permissionsViewModel.visiblePermissionDialogQueue,
activity = this,
permissionLauncher = permissionLauncher
permissionLaunch = { perms -> permissionLauncher.launch(perms) }
)
RequestHardwareFeatures(
@@ -126,23 +93,31 @@ class MainActivity : ComponentActivity() {
bluetoothUiState = bluetoothState
)
NavHost(navController = navController, startDestination = "scanner") {
NavHost(
navController = navController,
startDestination = "device"
) {
composable("scanner") {
BluetoothScannerScreen(
bluetoothState = bluetoothState,
selectedDevice = selectedDevice,
startScan = { bluetoothViewModel.startScan() },
cancelScan = { bluetoothViewModel.cancelScan() },
choiceDevice = {device -> bluetoothViewModel.selectDevice(device)},
choiceDevice = { device -> bluetoothViewModel.selectDevice(device) },
onScreenChanged = {
bluetoothViewModel.cancelScan()
val deviceAddress = selectedDevice?.bluetoothDevice?.address
preferencesViewModel.savePreferences(
SavedPreferences(
deviceAddress
val device = selectedDevice
if (device == null) {
preferencesViewModel.clearPreferences()
} else {
preferencesViewModel.savePreferences(
AppPreferences(
deviceName = device.name,
deviceAddress = device.macAddress
)
)
)
navController.navigate("flight/$deviceAddress")
}
navController.navigate("device")
Log.i(
"ScanActivity",
"Preferences: ${preferencesViewModel.preferences}"
@@ -150,44 +125,28 @@ class MainActivity : ComponentActivity() {
}
)
}
composable(
"flight/{device_address}",
arguments = listOf(navArgument("device_address"){type = NavType.StringType})
) {
backstackEntry ->
LaunchedEffect(Unit) {
val device: Device? = selectedDevice
if(device == null){
navController.navigate("scanner")
} 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
composable("device")
{
FlightControlScreen(
bluetoothUiState = bluetoothState,
getPreferences = { preferencesViewModel.preferences },
navigateToScanner = { navController.navigate("scanner") },
connectToDevice = { device ->
bluetoothViewModel.connectToDevice(
device
)
}
}
},
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.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
@@ -23,20 +21,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.helible.pilot.BluetoothUiState
import com.helible.pilot.Device
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.BluetoothDevice
@SuppressLint("MissingPermission")
@Composable
fun BluetoothScannerScreen(
bluetoothState: BluetoothUiState,
selectedDevice: Device?,
selectedDevice: BluetoothDevice?,
startScan: () -> Unit,
cancelScan: () -> Unit,
choiceDevice: (device: Device?) -> Unit,
choiceDevice: (device: BluetoothDevice?) -> Unit,
onScreenChanged: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,

View File

@@ -1,7 +1,6 @@
package com.helible.pilot.components
import android.annotation.SuppressLint
import android.widget.TextView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -16,23 +15,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import com.helible.pilot.Device
import com.helible.pilot.dataclasses.BluetoothDevice
import com.helible.pilot.R
@SuppressLint("MissingPermission")
@Composable
fun DeviceItem(
deviceInfo: Device,
selectedDevice: Device?,
choiceDevice: (device: Device?) -> Unit,
deviceInfo: BluetoothDevice,
selectedDevice: BluetoothDevice?,
choiceDevice: (device: BluetoothDevice?) -> Unit,
modifier: Modifier,
) {
ElevatedCard(
@@ -40,7 +36,7 @@ fun DeviceItem(
choiceDevice(deviceInfo)
},
colors = CardDefaults.elevatedCardColors(
containerColor = if (deviceInfo.bluetoothDevice == selectedDevice?.bluetoothDevice)
containerColor = if (deviceInfo == selectedDevice)
MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface
)
@@ -48,16 +44,16 @@ fun DeviceItem(
Row(modifier = Modifier.padding(8.dp)) {
Column(verticalArrangement = Arrangement.Center) {
Text(
text = deviceInfo.bluetoothDevice.name,
text = deviceInfo.name,
fontWeight = FontWeight.Bold,
softWrap = true
)
Text(
text = "MAC: ${deviceInfo.bluetoothDevice.address}",
text = "MAC: ${deviceInfo.macAddress}",
fontWeight = FontWeight.Thin
)
}
if (!deviceInfo.isPaired) {
if (deviceInfo.isScanned) {
Box(contentAlignment = Alignment.CenterEnd, modifier = Modifier.fillMaxSize()) {
Icon(
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.style.TextAlign
import androidx.compose.ui.unit.dp
import com.helible.pilot.BluetoothUiState
import com.helible.pilot.Device
import com.helible.pilot.dataclasses.BluetoothUiState
import com.helible.pilot.dataclasses.BluetoothDevice
@Composable
fun DiscoveredDevicesList(
bluetoothState: BluetoothUiState,
selectedDevice: Device?,
choiceDevice: (device: Device?) -> Unit,
modifier: Modifier = Modifier
selectedDevice: BluetoothDevice?,
choiceDevice: (device: BluetoothDevice?) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item {
@@ -33,7 +33,7 @@ fun DiscoveredDevicesList(
modifier = Modifier.padding(10.dp)
)
}
items(bluetoothState.pairedDevices) { device ->
items(bluetoothState.pairedBluetoothDevices) { device ->
DeviceItem(
deviceInfo = device,
selectedDevice = selectedDevice,
@@ -45,7 +45,7 @@ fun DiscoveredDevicesList(
)
)
}
if(bluetoothState.pairedDevices.isEmpty()){
if (bluetoothState.pairedBluetoothDevices.isEmpty()) {
item {
Text(
text = "Нет элементов для отображения",
@@ -64,7 +64,7 @@ fun DiscoveredDevicesList(
)
}
items(bluetoothState.scannedDevices) { device ->
items(bluetoothState.scannedBluetoothDevices) { device ->
DeviceItem(
deviceInfo = device,
selectedDevice = selectedDevice,
@@ -76,12 +76,15 @@ fun DiscoveredDevicesList(
)
)
}
if(bluetoothState.scannedDevices.isEmpty()) {
if(bluetoothState.isDiscovering) {
if (bluetoothState.scannedBluetoothDevices.isEmpty()) {
if (bluetoothState.isDiscovering) {
item {
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Text(text = "Поиск устройств", modifier=Modifier.padding(10.dp))
Text(text = "Поиск устройств", modifier = Modifier.padding(10.dp))
}
}
} else {

View File

@@ -16,14 +16,14 @@ fun RequiredHardwareFeatures(
confirmButtonText: String,
featureState: Boolean,
requestFeature: () -> Unit,
onDismissRequest: () -> Unit
onDismissRequest: () -> Unit,
) {
if (!featureState) {
AlertDialog(
confirmButton = {
Divider()
TextButton(onClick = requestFeature, modifier = Modifier.fillMaxWidth()) {
Text(text=confirmButtonText)
Text(text = confirmButtonText)
}
},
onDismissRequest = onDismissRequest,
@@ -32,7 +32,7 @@ fun RequiredHardwareFeatures(
text = description
)
},
title = { Text(text = title)}
title = { Text(text = title) }
)
}
}

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,
onContinueClick: () -> Unit,
onGoToAppSettingsClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -39,39 +39,44 @@ fun PermissionDialog(
} else {
"OK"
},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (isPermanentDeclined) {
onGoToAppSettingsClick()
} else {
onOkClick()
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (isPermanentDeclined) {
onGoToAppSettingsClick()
} else {
onOkClick()
}
}
}
.padding(16.dp)
.padding(16.dp)
)
}
},
dismissButton = {if(isPermanentDeclined)
Box(modifier=Modifier.fillMaxWidth()){
dismissButton = {
if (isPermanentDeclined)
Box(modifier = Modifier.fillMaxWidth()) {
Divider()
Text(
text = "Снова проверить наличие разрешения",
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues = PaddingValues(top=10.dp))
.clickable {onContinueClick()},
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)}
text = "Снова проверить наличие разрешения",
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues = PaddingValues(top = 10.dp))
.clickable { onContinueClick() },
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
else
Unit},
Unit
},
text = {
Text(text = permissionTextProvider.getDescription(
isPermanentDeclined = isPermanentDeclined
))
Text(
text = permissionTextProvider.getDescription(
isPermanentDeclined = isPermanentDeclined
)
)
},
modifier = modifier
)
@@ -83,21 +88,21 @@ interface PermissionTextProvider {
class LocationPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к геолокации. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для определения местоположения " +
"для работы с Bluetooth на устройствах с Android 11 и ниже."
"для работы с Bluetooth на устройствах с Android 11 и ниже."
}
}
}
class BluetoothScanPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
"Похоже вы навсегда запретили приложению доступ к сканированию по Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к сканированию по Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
"Приложению необходимо разрешение для к сканированию по Bluetooth " +
"для работы с Bluetooth на устройствах с Android 11 и ниже"
@@ -107,7 +112,7 @@ class BluetoothScanPermissionTextProvider : PermissionTextProvider {
class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к подключению по Bluetooth." +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {
@@ -119,7 +124,7 @@ class BluetoothConnectPermissionTextProvider : PermissionTextProvider {
class BluetoothAdminPermissionTextProvider : PermissionTextProvider {
override fun getDescription(isPermanentDeclined: Boolean): String {
return if (isPermanentDeclined){
return if (isPermanentDeclined) {
"Похоже вы навсегда запретили приложению доступ к управлению настройками Bluetooth. " +
"Вы можете зайти в настройки, чтобы выдать это разрешение."
} else {

View File

@@ -1,37 +1,41 @@
package com.helible.pilot.components
import android.content.SharedPreferences
import android.os.Parcelable
import com.google.gson.Gson
import kotlinx.parcelize.Parcelize
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonClass
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
data class SavedPreferences(
val deviceAddress: String?
): Parcelable
interface SavedPreferencesCache {
fun getPreferences(): SavedPreferences?
fun savePreferences(preferences: SavedPreferences)
interface SavedPreferences {
fun getPreferences(): AppPreferences?
fun savePreferences(preferences: AppPreferences)
fun clearPreferences()
}
class PreferencesCacheImpl(private val sharedPreferences: SharedPreferences) : SavedPreferencesCache {
override fun getPreferences(): SavedPreferences? {
class SavedPreferencesImpl(private val sharedPreferences: SharedPreferences) : 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
return Gson().fromJson(json, SavedPreferences::class.java)
return preferencesAdapter.fromJson(json)
}
override fun savePreferences(preferences: SavedPreferences)
{
override fun savePreferences(preferences: AppPreferences) {
sharedPreferences.edit()
.putString("preferences", Gson().toJson(preferences))
.putString("preferences", preferencesAdapter.toJson(preferences))
.apply()
}
override fun clearPreferences()
{
override fun clearPreferences() {
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.annotation.SuppressLint
@@ -14,6 +14,11 @@ import android.os.Build
import android.util.Log
import android.widget.Toast
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.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -34,8 +39,8 @@ import java.io.IOException
import java.util.UUID
sealed interface ConnectionResult {
object ConnectionEstablished: ConnectionResult
data class TransferSucceded(val message: KMessage): ConnectionResult
object ConnectionEstablished : ConnectionResult
data class TransferSucceded(val message: KMessage) : ConnectionResult
data class Error(val message: String) : ConnectionResult
}
@@ -44,14 +49,14 @@ interface BluetoothController {
val isLocationEnabled: StateFlow<Boolean>
val isConnected: StateFlow<Boolean>
val isScanning: StateFlow<Boolean>
val scannedDevices: StateFlow<List<Device>>
val pairedDevices: StateFlow<List<Device>>
val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
val errors: SharedFlow<String>
fun startDiscovery()
fun cancelDiscovery()
fun connectToDevice(device: Device?): Flow<ConnectionResult>
suspend fun trySendMessage(message: KMessage): KMessage?
fun connectToDevice(device: String): Flow<ConnectionResult>
suspend fun trySendMessage(message: ByteArray): Boolean
fun closeConnection()
fun onDestroy()
}
@@ -92,12 +97,13 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
override val isLocationEnabled: StateFlow<Boolean>
get() = _isLocationEnabled.asStateFlow()
private val _pairedDevices = MutableStateFlow<List<Device>>(emptyList())
override val pairedDevices: StateFlow<List<Device>>
private val _pairedDevices = MutableStateFlow<List<BluetoothDeviceDomain>>(emptyList())
override val pairedDevices: StateFlow<List<BluetoothDeviceDomain>>
get() = _pairedDevices.asStateFlow()
private val _scannedDevices: MutableStateFlow<List<Device>> = MutableStateFlow(emptyList())
override val scannedDevices: StateFlow<List<Device>>
private val _scannedDevices: MutableStateFlow<List<BluetoothDeviceDomain>> =
MutableStateFlow(emptyList())
override val scannedDevices: StateFlow<List<BluetoothDeviceDomain>>
get() = _scannedDevices.asStateFlow()
private var currentClientSocket: BluetoothSocket? = null
@@ -113,7 +119,7 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
_isScanning.update { isDiscovering }
},
onLocationEnabledChanged = {
if(locationManager?.isLocationEnabled == true){
if (locationManager?.isLocationEnabled == true) {
_isLocationEnabled.update { true }
} else {
_isLocationEnabled.update { false }
@@ -124,10 +130,11 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
@SuppressLint("MissingPermission")
private val bluetoothStateReceiver = BluetoothStateReceiver(
onDeviceFound = { device, rssi ->
if(!hasAllPermissions()) return@BluetoothStateReceiver
val newDevice = Device(device, rssi)
if (!hasAllPermissions()) return@BluetoothStateReceiver
val newDevice =
BluetoothDeviceDomain(device.name ?: "null", device.address, rssi, isScanned = true)
_scannedDevices.update { devices ->
if(newDevice in devices) devices else devices + newDevice
if (newDevice in devices) devices else devices + newDevice
}
Log.i(
"ScanActivity",
@@ -135,7 +142,7 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
)
},
onConnectedStateChanged = { isConnected, device ->
if(bluetoothAdapter?.bondedDevices?.contains(device) == true) {
if (bluetoothAdapter?.bondedDevices?.contains(device) == true) {
_isConnected.update { isConnected }
} else {
CoroutineScope(Dispatchers.IO).launch {
@@ -160,7 +167,7 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
addAction(BluetoothAdapter.ACTION_DISCOVERY_STARTED)
addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
}
}
@@ -178,43 +185,44 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
@SuppressLint("MissingPermission")
override fun startDiscovery() {
if(!hasAllPermissions()) {
if (!hasAllPermissions()) {
Toast.makeText(context, "Ошибка: недостаточно разрешений", Toast.LENGTH_SHORT).show()
return
}
if(!_isEnabled.value) {
if (!_isEnabled.value) {
return
}
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if(locationManager?.isLocationEnabled != true) return
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (locationManager?.isLocationEnabled != true) return
}
updatePairedDevices()
_scannedDevices.update { emptyList() }
if(!bluetoothAdapter.isDiscovering) {
if (!bluetoothAdapter.isDiscovering) {
bluetoothAdapter.startDiscovery()
}
}
@SuppressLint("MissingPermission")
override fun cancelDiscovery() {
if(!hasAllPermissions()) return
if(bluetoothAdapter.isDiscovering){
if (!hasAllPermissions()) return
if (bluetoothAdapter.isDiscovering) {
bluetoothAdapter.cancelDiscovery()
}
}
@SuppressLint("MissingPermission")
override fun connectToDevice(device: Device?): Flow<ConnectionResult> {
if(!hasAllPermissions()){
override fun connectToDevice(device: String): Flow<ConnectionResult> {
if (!hasAllPermissions()) {
Toast.makeText(context, "Ошибка: нет разрешений", Toast.LENGTH_SHORT).show()
return flow {}
}
return flow {
currentClientSocket = device?.bluetoothDevice?.createRfcommSocketToServiceRecord(
UUID.fromString(SERVICE_UUID)
)
currentClientSocket =
bluetoothAdapter.getRemoteDevice(device).createRfcommSocketToServiceRecord(
UUID.fromString(SERVICE_UUID)
)
currentClientSocket?.let { socket ->
try {
socket.connect()
@@ -223,7 +231,7 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
dataTransferService = it
emitAll(
it.listenForIncomingMessages()
.map {ConnectionResult.TransferSucceded(it)}
.map { ConnectionResult.TransferSucceded(it) }
)
}
} catch (e: IOException) {
@@ -234,15 +242,15 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
}.onCompletion { closeConnection() }.flowOn(Dispatchers.IO)
}
override suspend fun trySendMessage(message: KMessage): KMessage? {
if(!hasAllPermissions()){
return null
override suspend fun trySendMessage(message: ByteArray): Boolean {
if (!hasAllPermissions()) {
return false
}
if(dataTransferService == null) {
return null
if (dataTransferService == null) {
return false
}
dataTransferService?.sendMessage("R1250\n\r".toByteArray())
return message
dataTransferService?.sendMessage(message)
return true
}
override fun closeConnection() {
@@ -258,11 +266,16 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
@SuppressLint("MissingPermission")
private fun updatePairedDevices() {
if(!hasAllPermissions()) return
if (!hasAllPermissions()) return
Log.i("ScanActivity", "${bluetoothAdapter?.bondedDevices}")
bluetoothAdapter?.bondedDevices?.onEach { device ->
_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) {
pairedDevices.value
} else {
@@ -284,7 +297,7 @@ class AndroidBluetoothController(private val context: Context) : BluetoothContro
)
}
perms.forEach { perm ->
if(context.checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED){
if (context.checkSelfPermission(perm) != PackageManager.PERMISSION_GRANTED) {
return false
}
}

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(
val isEnabled: Boolean = false,
val isLocationEnabled: Boolean = false,
@@ -8,6 +7,6 @@ data class BluetoothUiState(
val isConnected: Boolean = false,
val isConnecting: Boolean = false,
val errorMessage: String? = null,
val scannedDevices: List<Device> = emptyList(),
val pairedDevices: List<Device> = emptyList(),
val scannedBluetoothDevices: List<BluetoothDevice> = 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.app.Activity
@@ -6,7 +6,6 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import com.helible.pilot.components.BluetoothAdminPermissionTextProvider
@@ -20,7 +19,7 @@ fun PermissionsRequest(
visiblePermissionDialogQueue: SnapshotStateList<String>,
dismissCurrentDialog: () -> Unit,
activity: Activity,
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>
permissionLaunch: (permissions: Array<String>) -> Unit,
) {
/* Create Dialog windows, which requests all permissions */
visiblePermissionDialogQueue.reversed()
@@ -52,7 +51,7 @@ fun PermissionsRequest(
},
onOkClick = {
dismissCurrentDialog()
permissionLauncher.launch(arrayOf(permission))
permissionLaunch(arrayOf(permission))
},
onContinueClick = {
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.app.Activity
@@ -8,14 +8,14 @@ import android.os.Build
import android.provider.Settings
import androidx.compose.runtime.Composable
import com.helible.pilot.components.RequiredHardwareFeatures
import com.helible.pilot.dataclasses.BluetoothUiState
@SuppressLint("MissingPermission")
@Composable
fun RequestHardwareFeatures(
activity: Activity,
bluetoothUiState: BluetoothUiState
)
{
bluetoothUiState: BluetoothUiState,
) {
RequiredHardwareFeatures(
title = "Включите Bluetooth",
description = "Для работы приложения требуется Bluetooth",

View File

@@ -1,4 +1,4 @@
package com.helible.pilot
package com.helible.pilot.receivers
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@@ -12,7 +12,7 @@ import android.os.Build
class BluetoothAdapterStateReceiver(
private val onBluetoothEnabledChanged: (isBluetoothEnabled: Boolean) -> Unit,
private val onDiscoveryRunningChanged: (isDiscoveryRunning: Boolean) -> Unit,
private val onLocationEnabledChanged: () -> Unit
private val onLocationEnabledChanged: () -> Unit,
) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
@@ -27,6 +27,7 @@ class BluetoothAdapterStateReceiver(
}
}
}
LocationManager.PROVIDERS_CHANGED_ACTION -> {
onLocationEnabledChanged()
}
@@ -40,33 +41,4 @@ 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(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
content: @Composable () -> Unit,
) {
val colorScheme = when {
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()
}
}