diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 0fc3113..4515aa3 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67a7214..120ff06 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/release/app-release.apk b/app/release/app-release.apk index 9fec070..66d511b 100644 Binary files a/app/release/app-release.apk and b/app/release/app-release.apk differ diff --git a/app/src/main/java/com/helible/pilot/BluetoothDataTransferService.kt b/app/src/main/java/com/helible/pilot/BluetoothDataTransferService.kt index 19b214a..3f8ce69 100644 --- a/app/src/main/java/com/helible/pilot/BluetoothDataTransferService.kt +++ b/app/src/main/java/com/helible/pilot/BluetoothDataTransferService.kt @@ -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 { 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) { diff --git a/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt b/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt deleted file mode 100644 index f281c8d..0000000 --- a/app/src/main/java/com/helible/pilot/BluetoothViewModel.kt +++ /dev/null @@ -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 = MutableStateFlow(null) - val selectedDevice: StateFlow - get () = _selectedDevice.asStateFlow() - - private val _state: MutableStateFlow = MutableStateFlow(BluetoothUiState()) - val state: StateFlow = combine(bluetoothController.scannedDevices, bluetoothController.pairedDevices, _state) - { scannedDevices, pairedDevices, state -> - state.copy( - scannedDevices = scannedDevices.toList(), - pairedDevices = pairedDevices - ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), _state.value) - - init { - bluetoothController.isConnected.onEach { - isConnected -> _state.update { it.copy(isConnected = isConnected) } - }.launchIn(viewModelScope) - bluetoothController.errors.onEach { error -> - _state.update { - it.copy(errorMessage = error) - } - }.launchIn(viewModelScope) - bluetoothController.isScanning.onEach { result -> - _state.update {it.copy( - isDiscovering = result, - )} - }.launchIn(viewModelScope) - bluetoothController.isEnabled.onEach { result -> - _state.update {it.copy( - isEnabled = result, - )} - }.launchIn(viewModelScope) - bluetoothController.isLocationEnabled.onEach { result -> - _state.update { it.copy( - isLocationEnabled = result - ) } - }.launchIn(viewModelScope) - } - - private fun Flow.listen(): Job { - return onEach { result -> - when(result) { - ConnectionResult.ConnectionEstablished -> { - _state.update { - it.copy( - isConnected = true, - isConnecting = false, - errorMessage = null - ) - } - } - is ConnectionResult.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) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/Kproto.kt b/app/src/main/java/com/helible/pilot/Kproto.kt index a4634c4..aec2803 100644 --- a/app/src/main/java/com/helible/pilot/Kproto.kt +++ b/app/src/main/java/com/helible/pilot/Kproto.kt @@ -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 { diff --git a/app/src/main/java/com/helible/pilot/MainActivity.kt b/app/src/main/java/com/helible/pilot/MainActivity.kt index 64a2b4a..c9c9aa2 100644 --- a/app/src/main/java/com/helible/pilot/MainActivity.kt +++ b/app/src/main/java/com/helible/pilot/MainActivity.kt @@ -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 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(factory = BluetoothViewModelFactory(applicationContext)) + + val permissionsViewModel = viewModel() + 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 - } - } diff --git a/app/src/main/java/com/helible/pilot/MainViewModel.kt b/app/src/main/java/com/helible/pilot/MainViewModel.kt deleted file mode 100644 index 6a5cfe7..0000000 --- a/app/src/main/java/com/helible/pilot/MainViewModel.kt +++ /dev/null @@ -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() - - 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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt b/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt index 8b0f6e9..2de9511 100644 --- a/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt +++ b/app/src/main/java/com/helible/pilot/components/BluetoothScannerScreen.kt @@ -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, diff --git a/app/src/main/java/com/helible/pilot/components/DeviceItem.kt b/app/src/main/java/com/helible/pilot/components/DeviceItem.kt index 4fe7f1f..531d630 100644 --- a/app/src/main/java/com/helible/pilot/components/DeviceItem.kt +++ b/app/src/main/java/com/helible/pilot/components/DeviceItem.kt @@ -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)), diff --git a/app/src/main/java/com/helible/pilot/components/DeviceScreen.kt b/app/src/main/java/com/helible/pilot/components/DeviceScreen.kt new file mode 100644 index 0000000..89cd545 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/components/DeviceScreen.kt @@ -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) + ) { + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt b/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt index b583132..4ec4592 100644 --- a/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt +++ b/app/src/main/java/com/helible/pilot/components/DiscoveredDevicesList.kt @@ -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 { diff --git a/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt b/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt index 03af6c6..176d536 100644 --- a/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt +++ b/app/src/main/java/com/helible/pilot/components/FeatureRequestDialog.kt @@ -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) } ) } } diff --git a/app/src/main/java/com/helible/pilot/components/FlightControlScreen.kt b/app/src/main/java/com/helible/pilot/components/FlightControlScreen.kt new file mode 100644 index 0000000..77c786e --- /dev/null +++ b/app/src/main/java/com/helible/pilot/components/FlightControlScreen.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/helible/pilot/components/PermissionDialog.kt b/app/src/main/java/com/helible/pilot/components/PermissionDialog.kt index dce70db..cac8857 100644 --- a/app/src/main/java/com/helible/pilot/components/PermissionDialog.kt +++ b/app/src/main/java/com/helible/pilot/components/PermissionDialog.kt @@ -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 { diff --git a/app/src/main/java/com/helible/pilot/components/SavedPreferences.kt b/app/src/main/java/com/helible/pilot/components/SavedPreferences.kt index b37bb3e..c9c6f4c 100644 --- a/app/src/main/java/com/helible/pilot/components/SavedPreferences.kt +++ b/app/src/main/java/com/helible/pilot/components/SavedPreferences.kt @@ -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 = + 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() } } \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/BluetoothController.kt b/app/src/main/java/com/helible/pilot/controllers/BluetoothController.kt similarity index 74% rename from app/src/main/java/com/helible/pilot/BluetoothController.kt rename to app/src/main/java/com/helible/pilot/controllers/BluetoothController.kt index bd0fdae..92bb7a9 100644 --- a/app/src/main/java/com/helible/pilot/BluetoothController.kt +++ b/app/src/main/java/com/helible/pilot/controllers/BluetoothController.kt @@ -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 val isConnected: StateFlow val isScanning: StateFlow - val scannedDevices: StateFlow> - val pairedDevices: StateFlow> + val scannedDevices: StateFlow> + val pairedDevices: StateFlow> val errors: SharedFlow fun startDiscovery() fun cancelDiscovery() - fun connectToDevice(device: Device?): Flow - suspend fun trySendMessage(message: KMessage): KMessage? + fun connectToDevice(device: String): Flow + 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 get() = _isLocationEnabled.asStateFlow() - private val _pairedDevices = MutableStateFlow>(emptyList()) - override val pairedDevices: StateFlow> + private val _pairedDevices = MutableStateFlow>(emptyList()) + override val pairedDevices: StateFlow> get() = _pairedDevices.asStateFlow() - private val _scannedDevices: MutableStateFlow> = MutableStateFlow(emptyList()) - override val scannedDevices: StateFlow> + private val _scannedDevices: MutableStateFlow> = + MutableStateFlow(emptyList()) + override val scannedDevices: StateFlow> 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 { - if(!hasAllPermissions()){ + override fun connectToDevice(device: String): Flow { + 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 } } diff --git a/app/src/main/java/com/helible/pilot/dataclasses/BluetoothDevice.kt b/app/src/main/java/com/helible/pilot/dataclasses/BluetoothDevice.kt new file mode 100644 index 0000000..e0c4b3a --- /dev/null +++ b/app/src/main/java/com/helible/pilot/dataclasses/BluetoothDevice.kt @@ -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, +) diff --git a/app/src/main/java/com/helible/pilot/BluetoothUiState.kt b/app/src/main/java/com/helible/pilot/dataclasses/BluetoothUiState.kt similarity index 60% rename from app/src/main/java/com/helible/pilot/BluetoothUiState.kt rename to app/src/main/java/com/helible/pilot/dataclasses/BluetoothUiState.kt index e254fc7..42d949c 100644 --- a/app/src/main/java/com/helible/pilot/BluetoothUiState.kt +++ b/app/src/main/java/com/helible/pilot/dataclasses/BluetoothUiState.kt @@ -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 = emptyList(), - val pairedDevices: List = emptyList(), + val scannedBluetoothDevices: List = emptyList(), + val pairedBluetoothDevices: List = emptyList(), ) \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/dataclasses/MessageTypes.kt b/app/src/main/java/com/helible/pilot/dataclasses/MessageTypes.kt new file mode 100644 index 0000000..58df9d3 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/dataclasses/MessageTypes.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/permissions/PermissionsLauncher.kt b/app/src/main/java/com/helible/pilot/permissions/PermissionsLauncher.kt new file mode 100644 index 0000000..c07c233 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/permissions/PermissionsLauncher.kt @@ -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 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, Map> + + @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 = permissionsToRequest) { + launcher.launch(permissions) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/PermissionsRequest.kt b/app/src/main/java/com/helible/pilot/permissions/PermissionsRequest.kt similarity index 91% rename from app/src/main/java/com/helible/pilot/PermissionsRequest.kt rename to app/src/main/java/com/helible/pilot/permissions/PermissionsRequest.kt index ad22353..c746f5a 100644 --- a/app/src/main/java/com/helible/pilot/PermissionsRequest.kt +++ b/app/src/main/java/com/helible/pilot/permissions/PermissionsRequest.kt @@ -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, dismissCurrentDialog: () -> Unit, activity: Activity, - permissionLauncher: ManagedActivityResultLauncher, Map> + permissionLaunch: (permissions: Array) -> 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) diff --git a/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt b/app/src/main/java/com/helible/pilot/permissions/RequestHardwareFeatures.kt similarity index 92% rename from app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt rename to app/src/main/java/com/helible/pilot/permissions/RequestHardwareFeatures.kt index e7436f0..834e38f 100644 --- a/app/src/main/java/com/helible/pilot/RequestHardwareFeatures.kt +++ b/app/src/main/java/com/helible/pilot/permissions/RequestHardwareFeatures.kt @@ -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", diff --git a/app/src/main/java/com/helible/pilot/BluetoothAdapterStateReceiver.kt b/app/src/main/java/com/helible/pilot/receivers/BluetoothAdapterStateReceiver.kt similarity index 51% rename from app/src/main/java/com/helible/pilot/BluetoothAdapterStateReceiver.kt rename to app/src/main/java/com/helible/pilot/receivers/BluetoothAdapterStateReceiver.kt index 27f589d..56e56e4 100644 --- a/app/src/main/java/com/helible/pilot/BluetoothAdapterStateReceiver.kt +++ b/app/src/main/java/com/helible/pilot/receivers/BluetoothAdapterStateReceiver.kt @@ -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) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/receivers/BluuetothStateReceiver.kt b/app/src/main/java/com/helible/pilot/receivers/BluuetothStateReceiver.kt new file mode 100644 index 0000000..c371605 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/receivers/BluuetothStateReceiver.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/ui/theme/Theme.kt b/app/src/main/java/com/helible/pilot/ui/theme/Theme.kt index 6ad91a4..a8ed995 100644 --- a/app/src/main/java/com/helible/pilot/ui/theme/Theme.kt +++ b/app/src/main/java/com/helible/pilot/ui/theme/Theme.kt @@ -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 -> { diff --git a/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModel.kt b/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModel.kt new file mode 100644 index 0000000..12c3355 --- /dev/null +++ b/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModel.kt @@ -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 = MutableStateFlow(null) + val selectedDevice: StateFlow + get() = _selectedDevice.asStateFlow() + + private val _state: MutableStateFlow = MutableStateFlow(BluetoothUiState()) + val state: StateFlow = + combine(bluetoothController.scannedDevices, bluetoothController.pairedDevices, _state) + { scannedDevices, pairedDevices, state -> + state.copy( + 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.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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModelFactory.kt b/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModelFactory.kt new file mode 100644 index 0000000..215845f --- /dev/null +++ b/app/src/main/java/com/helible/pilot/viewmodels/BluetoothViewModelFactory.kt @@ -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 create(modelClass: Class): T { + return BluetoothViewModel( + bluetoothController = AndroidBluetoothController(context) + ) as T + } +} diff --git a/app/src/main/java/com/helible/pilot/viewmodels/PermissionsViewModel.kt b/app/src/main/java/com/helible/pilot/viewmodels/PermissionsViewModel.kt new file mode 100644 index 0000000..9f36fda --- /dev/null +++ b/app/src/main/java/com/helible/pilot/viewmodels/PermissionsViewModel.kt @@ -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() + + 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() + } +} \ No newline at end of file