mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2025-12-23 09:24:23 +01:00
feature: Add new CSC Screen
This commit is contained in:
14
lib_scanner/build.gradle
Normal file
14
lib_scanner/build.gradle
Normal file
@@ -0,0 +1,14 @@
|
||||
apply from: rootProject.file("library.gradle")
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
dependencies {
|
||||
implementation project(":lib_theme")
|
||||
implementation project(":lib_events")
|
||||
|
||||
implementation libs.material
|
||||
implementation libs.google.permissions
|
||||
|
||||
implementation libs.bundles.compose
|
||||
implementation libs.compose.lifecycle
|
||||
implementation libs.compose.activity
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("no.nordicsemi.android.scanner.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
15
lib_scanner/src/main/AndroidManifest.xml
Normal file
15
lib_scanner/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="no.nordicsemi.android.scanner">
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
tools:ignore="CoarseFineLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,17 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object HiltModule {
|
||||
|
||||
@Provides
|
||||
fun createNordicBleScanner(): BluetoothAdapter? {
|
||||
return BluetoothAdapter.getDefaultAdapter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import no.nordicsemi.android.events.exhaustive
|
||||
|
||||
@Composable
|
||||
internal fun ListOfDevicesScreen(onDeviceSelected: (BluetoothDevice) -> Unit) {
|
||||
|
||||
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
|
||||
|
||||
val result = viewModel.scannerResult.collectAsState().value
|
||||
|
||||
when (result) {
|
||||
is DeviceListResult -> DeviceListView(result.devices, onDeviceSelected)
|
||||
is ScanningErrorResult -> ScanningErrorView()
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
private fun DeviceListView(
|
||||
devices: List<BluetoothDevice>,
|
||||
onDeviceSelected: (BluetoothDevice) -> Unit
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(stringResource(id = R.string.scanner__list_of_devices))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
itemsIndexed(devices) { _, device ->
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { onDeviceSelected(device) }
|
||||
) {
|
||||
Column {
|
||||
Text(device.name ?: stringResource(id = R.string.scanner__no_name))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = device.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanningErrorView() {
|
||||
Text(text = stringResource(id = R.string.scanner__error))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
|
||||
|
||||
val scannerResult = MutableStateFlow<ScanningResult>(DeviceListResult())
|
||||
|
||||
private var isScanning = false
|
||||
|
||||
private val scanner by lazy { bleAdapter?.bluetoothLeScanner }
|
||||
private val devices = mutableListOf<BluetoothDevice>()
|
||||
|
||||
private val scanningCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult?) {
|
||||
result?.device?.let { devices.addIfNotExist(it) }
|
||||
scannerResult.value = DeviceListResult(devices)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
scannerResult.value = ScanningErrorResult
|
||||
}
|
||||
}
|
||||
|
||||
fun getBluetoothStatus(): ScannerStatus {
|
||||
return when {
|
||||
bleAdapter == null -> ScannerStatus.NOT_AVAILABLE
|
||||
bleAdapter.isEnabled -> ScannerStatus.ENABLED
|
||||
else -> ScannerStatus.DISABLED
|
||||
}
|
||||
}
|
||||
|
||||
fun startScanning() {
|
||||
if (isScanning) {
|
||||
return
|
||||
}
|
||||
isScanning = true
|
||||
scanner?.startScan(scanningCallback)
|
||||
}
|
||||
|
||||
fun stopScanning() {
|
||||
if (!isScanning) {
|
||||
return
|
||||
}
|
||||
isScanning = false
|
||||
scanner?.stopScan(scanningCallback)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ScanningResult
|
||||
|
||||
data class DeviceListResult(val devices: List<BluetoothDevice> = emptyList()) : ScanningResult()
|
||||
|
||||
object ScanningErrorResult : ScanningResult()
|
||||
|
||||
private fun <T> MutableList<T>.addIfNotExist(value: T) {
|
||||
if (!contains(value)) {
|
||||
add(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import no.nordicsemi.android.events.exhaustive
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class NordicBleScannerViewModel @Inject constructor(
|
||||
private val bleScanner: NordicBleScanner
|
||||
) : ViewModel() {
|
||||
|
||||
val state =
|
||||
MutableStateFlow(NordicBleScannerState(scannerStatus = ScannerStatus.PERMISSION_REQUIRED))
|
||||
|
||||
val scannerResult = bleScanner.scannerResult
|
||||
|
||||
fun onEvent(event: ScannerViewEvent) {
|
||||
when (event) {
|
||||
ScannerViewEvent.PERMISSION_CHECKED -> onPermissionChecked()
|
||||
ScannerViewEvent.BLUETOOTH_ENABLED -> onBluetoothEnabled()
|
||||
ScannerViewEvent.ENABLE_SCANNING -> bleScanner.startScanning()
|
||||
ScannerViewEvent.DISABLE_SCANNING -> bleScanner.stopScanning()
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun onPermissionChecked() {
|
||||
state.value = state.value.copy(scannerStatus = bleScanner.getBluetoothStatus())
|
||||
}
|
||||
|
||||
private fun onBluetoothEnabled() {
|
||||
state.value = state.value.copy(scannerStatus = bleScanner.getBluetoothStatus())
|
||||
bleScanner.startScanning()
|
||||
}
|
||||
}
|
||||
|
||||
enum class ScannerViewEvent {
|
||||
PERMISSION_CHECKED, BLUETOOTH_ENABLED, ENABLE_SCANNING, DISABLE_SCANNING
|
||||
}
|
||||
|
||||
internal data class NordicBleScannerState(
|
||||
val scannerStatus: ScannerStatus
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import no.nordicsemi.android.events.exhaustive
|
||||
import no.nordicsemi.android.scanner.bluetooth.BluetoothNotAvailableScreen
|
||||
import no.nordicsemi.android.scanner.bluetooth.BluetoothNotEnabledScreen
|
||||
import no.nordicsemi.android.scanner.permissions.RequestPermissionScreen
|
||||
|
||||
@Composable
|
||||
fun ScannerRoute(navController: NavController) {
|
||||
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
|
||||
|
||||
val scannerStatus = viewModel.state.collectAsState().value.scannerStatus
|
||||
|
||||
Column {
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__devices_list)) })
|
||||
ScannerScreen(navController, scannerStatus) { viewModel.onEvent(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScannerScreen(
|
||||
navController: NavController,
|
||||
scannerStatus: ScannerStatus,
|
||||
onEvent: (ScannerViewEvent) -> Unit
|
||||
) {
|
||||
when (scannerStatus) {
|
||||
ScannerStatus.PERMISSION_REQUIRED -> RequestPermissionScreen { onEvent(ScannerViewEvent.PERMISSION_CHECKED) }
|
||||
ScannerStatus.NOT_AVAILABLE -> BluetoothNotAvailableScreen()
|
||||
ScannerStatus.DISABLED -> BluetoothNotEnabledScreen { onEvent(ScannerViewEvent.BLUETOOTH_ENABLED) }
|
||||
ScannerStatus.ENABLED -> {
|
||||
onEvent(ScannerViewEvent.ENABLE_SCANNING)
|
||||
ListOfDevicesScreen {
|
||||
navController.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("result", it)
|
||||
navController.popBackStack()
|
||||
onEvent(ScannerViewEvent.DISABLE_SCANNING)
|
||||
}
|
||||
}
|
||||
}.exhaustive
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
enum class ScannerStatus {
|
||||
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package no.nordicsemi.android.scanner.bluetooth
|
||||
|
||||
import android.app.Activity
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
internal fun BluetoothNotAvailableScreen() {
|
||||
Text("Bluetooth not available.")
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
||||
val contract = ActivityResultContracts.StartActivityForResult()
|
||||
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
|
||||
Column {
|
||||
Text(text = "Bluetooth not enabled.")
|
||||
Text(text = "To enable Bluetooth please open settings.")
|
||||
Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) {
|
||||
Text(text = "Bluetooth not available.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package no.nordicsemi.android.scanner.permissions
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionsRequired
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import no.nordicsemi.android.scanner.R
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
internal fun RequestPermissionScreen(finish: () -> Unit) {
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
// android.Manifest.permission.BLUETOOTH_SCAN,
|
||||
// android.Manifest.permission.BLUETOOTH_CONNECT
|
||||
))
|
||||
PermissionsRequired(
|
||||
multiplePermissionsState = permissionsState,
|
||||
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
|
||||
permissionsNotAvailableContent = { PermissionNotAvailable() }
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionNotGranted(onClick: () -> Unit) {
|
||||
val doNotShowRationale = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
if (doNotShowRationale.value) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(id = R.string.scanner__feature_not_available))
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(id = R.string.scanner__permission_rationale))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row {
|
||||
Button(onClick = { onClick() }) {
|
||||
Text(stringResource(id = R.string.scanner__button_ok))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { doNotShowRationale.value = true }) {
|
||||
Text(stringResource(id = R.string.scanner__button_nope))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionNotAvailable() {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(id = R.string.scanner__permission_denied))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { openPermissionSettings(context) }) {
|
||||
Text(stringResource(id = R.string.scanner__open_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPermissionSettings(context: Context) {
|
||||
startActivity(
|
||||
context,
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", context.packageName, null)
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PermissionNotGrantedPreview() {
|
||||
PermissionNotGranted { }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PermissionNotAvailablePreview() {
|
||||
PermissionNotAvailable()
|
||||
}
|
||||
15
lib_scanner/src/main/res/values/strings.xml
Normal file
15
lib_scanner/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="scanner__devices_list">BLE devices</string>
|
||||
|
||||
<string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string>
|
||||
<string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string>
|
||||
<string name="scanner__button_ok">OK</string>
|
||||
<string name="scanner__button_nope">Nope</string>
|
||||
<string name="scanner__open_settings">Open settings</string>
|
||||
<string name="scanner__feature_not_available">Feature not available</string>
|
||||
|
||||
<string name="scanner__list_of_devices">List of devices</string>
|
||||
<string name="scanner__error">Scanning failed due to technical reason.</string>
|
||||
<string name="scanner__no_name">Name: NONE</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
package no.nordicsemi.android.scanner
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user