feature: Add new CSC Screen

This commit is contained in:
Sylwester Zieliński
2021-09-14 11:37:40 +02:00
parent 419aaf7e5b
commit c944a446ef
72 changed files with 2949 additions and 227 deletions

14
lib_scanner/build.gradle Normal file
View 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
}

View File

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

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package no.nordicsemi.android.scanner
enum class ScannerStatus {
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE
}

View File

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

View File

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

View 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>

View File

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