Add HRS service

This commit is contained in:
Sylwester Zieliński
2021-09-27 13:50:48 +02:00
parent 3ef57bf5fd
commit 7a171a1402
112 changed files with 1837 additions and 635 deletions

View File

@@ -51,8 +51,10 @@ dependencies {
//Hilt requires to implement every module in the main app module
//https://github.com/google/dagger/issues/2123
implementation project(":feature_csc")
implementation project(":lib_theme")
implementation project(":feature_hrs")
implementation project(':feature_scanner')
implementation project(":lib_theme")
implementation project(":lib_utils")
implementation libs.nordic.ble.common

View File

@@ -0,0 +1,54 @@
package no.nordicsemi.android.nrftoolbox
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.theme.NordicColors
@Composable
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onClick() },
colors = ButtonDefaults.buttonColors(backgroundColor = NordicColors.NordicGray4.value()),
) {
Image(
painter = painterResource(iconId),
contentDescription = stringResource(id = nameId),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White)
)
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = nameId),
modifier = Modifier.padding(16.dp),
)
}
}
}

View File

@@ -1,86 +1,105 @@
package no.nordicsemi.android.nrftoolbox
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.CSCRoute
import no.nordicsemi.android.csc.view.CscScreen
import no.nordicsemi.android.hrs.view.HRSScreen
import no.nordicsemi.android.scanner.view.BluetoothNotAvailableScreen
import no.nordicsemi.android.scanner.view.BluetoothNotEnabledScreen
import no.nordicsemi.android.scanner.view.RequestPermissionScreen
import no.nordicsemi.android.scanner.view.ScanDeviceScreen
import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult
import no.nordicsemi.android.utils.exhaustive
@Composable
fun HomeScreen() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeView(navController) }
composable("csc-route") { CSCRoute() }
val viewModel = hiltViewModel<NavigationViewModel>()
val continueAction: () -> Unit = { viewModel.finish() }
val state = viewModel.state.collectAsState().value
BackHandler { viewModel.navigateUp() }
NavHost(navController = navController, startDestination = NavDestination.HOME.id) {
composable(NavDestination.HOME.id) { HomeView { viewModel.navigate(it) } }
composable(NavDestination.CSC.id) { CscScreen { viewModel.navigateUp() } }
composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } }
composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) }
composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen() }
composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) {
BluetoothNotEnabledScreen(continueAction)
}
composable(NavDestination.DEVICE_NOT_CONNECTED.id) {
ScanDeviceScreen {
when (it) {
ScanDeviceScreenResult.SUCCESS -> viewModel.finish()
ScanDeviceScreenResult.CANCEL -> viewModel.navigateUp()
}.exhaustive
}
}
}
LaunchedEffect(state) {
navController.navigate(state.id)
}
}
@Composable
fun HomeView(navHostController: NavController) {
fun HomeView(callback: (NavDestination) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { navHostController.navigate("csc-route") }
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) }
FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) }
}
}
@Composable
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { onClick() },
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
) {
Image(
painter = painterResource(iconId),
contentDescription = stringResource(id = nameId),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White)
)
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = nameId),
modifier = Modifier.padding(16.dp),
)
private fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
val currentOnBack = rememberUpdatedState(onBack)
val backCallback = remember {
object : OnBackPressedCallback(enabled) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
}
SideEffect {
backCallback.isEnabled = enabled
}
val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
}.onBackPressedDispatcher
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, backDispatcher) {
backDispatcher.addCallback(lifecycleOwner, backCallback)
onDispose {
backCallback.remove()
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
HomeView(rememberNavController())
HomeView { }
}

View File

@@ -0,0 +1,11 @@
package no.nordicsemi.android.nrftoolbox
enum class NavDestination(val id: String) {
HOME("home-screen"),
CSC("csc-screen"),
HRS("hrs-screen"),
REQUEST_PERMISSION("request-permission"),
BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"),
BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"),
DEVICE_NOT_CONNECTED("device-not-connected"),
}

View File

@@ -0,0 +1,58 @@
package no.nordicsemi.android.nrftoolbox
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import no.nordicsemi.android.scanner.tools.NordicBleScanner
import no.nordicsemi.android.scanner.tools.PermissionHelper
import no.nordicsemi.android.scanner.tools.ScannerStatus
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.scanner.viewmodel.BluetoothPermissionState
import javax.inject.Inject
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val bleScanner: NordicBleScanner,
private val permissionHelper: PermissionHelper,
private val selectedDevice: no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
): ViewModel() {
val state= MutableStateFlow(NavDestination.HOME)
private var targetDestination = NavDestination.HOME
fun navigate(destination: NavDestination) {
targetDestination = destination
navigateToNextScreen()
}
fun navigateUp() {
targetDestination = NavDestination.HOME
state.value = NavDestination.HOME
}
fun finish() {
if (state.value != targetDestination) {
navigateToNextScreen()
}
}
private fun getBluetoothState(): BluetoothPermissionState {
return if (!permissionHelper.isRequiredPermissionGranted()) {
BluetoothPermissionState.PERMISSION_REQUIRED
} else when (bleScanner.getBluetoothStatus()) {
ScannerStatus.NOT_AVAILABLE -> BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE
ScannerStatus.DISABLED -> BluetoothPermissionState.BLUETOOTH_NOT_ENABLED
ScannerStatus.ENABLED -> selectedDevice.device?.let { BluetoothPermissionState.READY } ?: BluetoothPermissionState.DEVICE_NOT_CONNECTED
}
}
private fun navigateToNextScreen() {
state.value = when (getBluetoothState()) {
BluetoothPermissionState.PERMISSION_REQUIRED -> NavDestination.REQUEST_PERMISSION
BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE -> NavDestination.BLUETOOTH_NOT_AVAILABLE
BluetoothPermissionState.BLUETOOTH_NOT_ENABLED -> NavDestination.BLUETOOTH_NOT_ENABLED
BluetoothPermissionState.DEVICE_NOT_CONNECTED -> NavDestination.DEVICE_NOT_CONNECTED
BluetoothPermissionState.READY -> targetDestination
}
}
}

View File

@@ -0,0 +1,4 @@
<vector android:height="80dp" android:viewportHeight="1024"
android:viewportWidth="1024" android:width="80dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00B3DC" android:pathData="M863.6,210.5c-42.2,-42.2 -98.4,-65.5 -158.2,-65.5c-57.2,0 -111.7,21.6 -153.3,60.8c-16.9,16 -31.1,34.2 -42.2,54c-11.1,-19.9 -25.2,-38.1 -42.2,-54c-41.6,-39.2 -96.1,-60.8 -153.3,-60.8c-59.7,0 -115.9,23.3 -158.2,65.5c-42.2,42.2 -65.5,98.4 -65.5,158.2c0,41.7 11.5,82.3 33.4,117.6c0.3,0.5 0.6,1.1 1,1.6C200,607.1 476.7,896 488.4,908.3c5.6,5.8 13.1,8.8 20.6,8.8c0.3,0 0.6,0 0.9,0c0.3,0 0.6,0 0.9,0c7.5,0 15,-2.9 20.6,-8.8c11.8,-12.3 288.5,-301.2 363.3,-420.5c0.3,-0.5 0.7,-1 1,-1.5c21.9,-35.3 33.4,-76 33.4,-117.6C929.1,308.9 905.9,252.7 863.6,210.5zM846.9,456.9C846.8,456.9 846.8,456.9 846.9,456.9c-0.1,0.1 -0.1,0.1 -0.1,0.1c-60.1,96.2 -271.2,321.8 -336.9,391.2c-65.7,-69.4 -276.8,-295 -336.8,-391.2c0,0 0,0 0,0c0,0 0,-0.1 -0.1,-0.1c-16.5,-26.4 -25.2,-56.9 -25.2,-88.2c0,-91.9 74.8,-166.7 166.7,-166.7c87.7,0 160.7,68.5 166.3,155.9c1,15.3 13.9,27.1 29.2,26.7c15.3,0.4 28.2,-11.3 29.2,-26.7c5.6,-87.4 78.6,-155.9 166.3,-155.9c91.9,0 166.7,74.8 166.7,166.7C872.1,399.9 863.4,430.4 846.9,456.9z"/>
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,5 +1,4 @@
<resources>
<string name="app_name">nRF Toolbox</string>
<string name="csc_module">CSC</string>
<string name="hrs_module">HRS</string>
</resources>

View File

@@ -4,7 +4,6 @@ apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_service")
implementation project(":lib_theme")
implementation project(':feature_scanner')
implementation project(":lib_utils")
implementation libs.nordic.ble.common

View File

@@ -1,12 +1,9 @@
<?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.csc">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application>

View File

@@ -1,19 +0,0 @@
package no.nordicsemi.android.csc
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.view.CscScreen
import no.nordicsemi.android.scanner.ScannerRoute
@Composable
fun CSCRoute() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "csc_screen") {
composable("csc_screen") { CscScreen(navController) }
composable("scanner-destination") { ScannerRoute(navController) }
}
}

View File

@@ -4,10 +4,10 @@ import android.bluetooth.BluetoothDevice
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
sealed class CSCServiceEvent : Parcelable
internal sealed class CSCServiceEvent : Parcelable
@Parcelize
data class OnDistanceChangedEvent(
internal data class OnDistanceChangedEvent(
val bluetoothDevice: BluetoothDevice,
val speed: Float,
val distance: Float,
@@ -15,14 +15,14 @@ data class OnDistanceChangedEvent(
) : CSCServiceEvent()
@Parcelize
data class CrankDataChanged(
internal data class CrankDataChanged(
val bluetoothDevice: BluetoothDevice,
val crankCadence: Int,
val gearRatio: Float
) : CSCServiceEvent()
@Parcelize
data class OnBatteryLevelChanged(
internal data class OnBatteryLevelChanged(
val device: BluetoothDevice,
val batteryLevel: Int
) : CSCServiceEvent()

View File

@@ -9,7 +9,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
private val _wheelSize = MutableSharedFlow<Int>(
replay = 1,

View File

@@ -35,6 +35,12 @@ import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/** Cycling Speed and Cadence service UUID. */
private val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks>(context) {
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
@@ -114,14 +120,4 @@ internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks
override fun onServicesInvalidated() {}
}
companion object {
/** Cycling Speed and Cadence service UUID. */
val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID =
UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID =
UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
}
}

View File

@@ -1,53 +0,0 @@
package no.nordicsemi.android.csc.view
internal sealed class CSCViewState {
fun ensureConnectedState(): CSCViewConnectedState {
return (this as? CSCViewConnectedState)
?: throw IllegalStateException("Wrong state. Device not connected.")
}
fun ensureDisconnectedState(): CSCViewNotConnectedState {
return (this as? CSCViewNotConnectedState)
?: throw IllegalStateException("Wrong state. Device should be connected.")
}
}
//TODO("Change to navigation")
internal data class CSCViewNotConnectedState(
val showScannerDialog: Boolean = false
) : CSCViewState()
internal data class CSCViewConnectedState(
val showDialog: Boolean = false,
val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
val speed: Float = 0f,
val cadence: Int = 0,
val distance: Float = 0f,
val totalDistance: Float = 0f,
val gearRatio: Float = 0f,
val batteryLevel: Int = 0,
val wheelSize: String = CSCSettings.DefaultWheelSize.NAME
) : CSCViewState() {
fun displaySpeed(): String {
return speed.toString()
}
fun displayCadence(): String {
return cadence.toString()
}
fun displayDistance(): String {
return distance.toString()
}
fun displayTotalDistance(): String {
return totalDistance.toString()
}
fun displayBatteryLever(): String {
return batteryLevel.toString()
}
}

View File

@@ -1,7 +1,5 @@
package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
internal sealed class CSCViewEvent
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
@@ -11,9 +9,3 @@ internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplay
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()
internal object OnDisconnectButtonClick : CSCViewEvent()
internal object OnConnectButtonClick : CSCViewEvent()
internal object OnMovedToScannerScreen : CSCViewEvent()
internal data class OnBluetoothDeviceSelected(val device: BluetoothDevice) : CSCViewEvent()

View File

@@ -0,0 +1,74 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.theme.NordicColors
@Composable
internal fun ContentView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
}
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
SettingsSection(state, onEvent)
Spacer(modifier = Modifier.height(16.dp))
SensorsReadingView(state = state)
Spacer(modifier = Modifier.height(16.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { onEvent(OnDisconnectButtonClick) }
) {
Text(text = stringResource(id = R.string.disconnect))
}
}
}
@Composable
private fun SettingsSection(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
WheelSizeView(state, onEvent)
Spacer(modifier = Modifier.height(16.dp))
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
}
}
}
@Preview
@Composable
private fun ConnectedPreview() {
ContentView(CSCViewState()) { }
}

View File

@@ -1,143 +1,52 @@
package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
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.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.service.CSCService
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.viewmodel.CscViewModel
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.android.utils.isServiceRunning
@Composable
internal fun CscScreen(navController: NavController, viewModel: CscViewModel = hiltViewModel()) {
val secondScreenResult = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<BluetoothDevice>("result")?.observeAsState()
secondScreenResult?.value?.let {
viewModel.onEvent(OnBluetoothDeviceSelected(it))
navController.currentBackStackEntry
?.savedStateHandle
?.set("result", null)
}
fun CscScreen(finishAction: () -> Unit) {
val viewModel: CscViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
CSCView(navController, state) { viewModel.onEvent(it) }
val context = LocalContext.current
LaunchedEffect(state.isScreenActive) {
if (!state.isScreenActive) {
finishAction()
}
if (context.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(context, CSCService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(context, CSCService::class.java)
context.startService(intent)
}
}
CSCView(state) { viewModel.onEvent(it) }
}
@Composable
private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
when (state) {
is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) }
is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) {
onEvent(it)
}
}.exhaustive
ContentView(state) { onEvent(it) }
}
}
@Composable
private fun NotConnectedScreen(
navController: NavController,
state: CSCViewNotConnectedState,
onEvent: (CSCViewEvent) -> Unit
) {
if (state.showScannerDialog) {
navController.navigate("scanner-destination")
onEvent(OnMovedToScannerScreen)
}
if (LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(LocalContext.current, CSCService::class.java)
LocalContext.current.stopService(intent)
}
NotConnectedView(onEvent)
LocalContext.current.stopService(Intent(LocalContext.current, CSCService::class.java))
}
@Composable
private fun NotConnectedView(
onEvent: (CSCViewEvent) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.csc_no_connection))
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { onEvent(OnConnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_connect))
}
}
}
@Composable
private fun ConnectedView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
}
if (!LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(LocalContext.current, CSCService::class.java)
LocalContext.current.startService(intent)
}
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
WheelSizeView(state, onEvent)
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
SensorsReadingView(state = state)
Button(onClick = { onEvent(OnDisconnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_disconnect))
}
}
}
@Preview
@Composable
private fun NotConnectedPreview() {
NotConnectedView { }
}
@Preview
@Composable
private fun ConnectedPreview() {
ConnectedView(CSCViewConnectedState()) { }
}

View File

@@ -1,18 +1,29 @@
package no.nordicsemi.android.csc.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.TabRowDefaults.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.NordicColors.NordicLightGray
import no.nordicsemi.android.theme.TestTheme
@Composable
@@ -27,13 +38,47 @@ private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) {
val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values)
Box(Modifier.padding(16.dp)) {
Column(modifier = Background.whiteRoundedCorners()) {
Text(text = "Wheel size")
Card(
modifier = Modifier.height(300.dp),
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Column {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Wheel size",
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
wheelEntries.forEachIndexed { i, entry ->
Text(text = entry, modifier = Modifier.clickable {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = entry,
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.clickable {
onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry))
})
}
)
if (i != wheelEntries.size - 1) {
Spacer(modifier = Modifier.height(4.dp))
Divider(color = NordicLightGray.value(), thickness = 1.dp/2)
}
}
}
}
}

View File

@@ -1,55 +1,52 @@
package no.nordicsemi.android.csc.view
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.view.BatteryLevelView
import no.nordicsemi.android.theme.view.KeyValueField
@Composable
internal fun SensorsReadingView(state: CSCViewConnectedState) {
Column {
Column(modifier = Background.whiteRoundedCorners()) {
internal fun SensorsReadingView(state: CSCViewState) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(
stringResource(id = R.string.scs_field_total_distance),
state.displayTotalDistance()
)
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displaySpeed())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displayGearRatio())
}
}
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Background.whiteRoundedCorners()) {
KeyValueField(stringResource(id = R.string.scs_field_battery), state.displayBatteryLever())
}
}
}
@Composable
private fun KeyValueField(key: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = key)
Text(text = value)
}
BatteryLevelView(state.batteryLevel)
}
@Preview
@Composable
private fun Preview() {
SensorsReadingView(CSCViewConnectedState())
SensorsReadingView(CSCViewState())
}

View File

@@ -2,8 +2,10 @@ package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -16,7 +18,7 @@ internal fun SpeedUnitRadioGroup(
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
@@ -36,6 +38,7 @@ internal fun SpeedUnitRadioButton(
selected = (selectedUnit == displayedUnit),
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = createSpeedUnitLabel(displayedUnit))
}
}

View File

@@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
@Composable
internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = state.wheelSize,
@@ -35,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
@Preview
@Composable
private fun WheelSizeViewPreview() {
WheelSizeView(CSCViewConnectedState()) { }
WheelSizeView(CSCViewState()) { }
}

View File

@@ -0,0 +1,58 @@
package no.nordicsemi.android.csc.viewmodel
import no.nordicsemi.android.csc.view.CSCSettings
import no.nordicsemi.android.csc.view.SpeedUnit
import java.util.*
internal data class CSCViewState(
val showDialog: Boolean = false,
val scanDevices: Boolean = false,
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
val speed: Float = 0f,
val cadence: Int = 0,
val distance: Float = 0f,
val totalDistance: Float = 0f,
val gearRatio: Float = 0f,
val batteryLevel: Int = 0,
val wheelSize: String = CSCSettings.DefaultWheelSize.NAME,
val isScreenActive: Boolean = true
) {
private val speedWithUnit = when (selectedSpeedUnit) {
SpeedUnit.M_S -> speed
SpeedUnit.KM_H -> speed * 3.6f
SpeedUnit.MPH -> speed * 2.2369f
}
fun displaySpeed(): String {
return when (selectedSpeedUnit) {
SpeedUnit.M_S -> String.format("%.1f m/s", speedWithUnit)
SpeedUnit.KM_H -> String.format("%.1f km/h", speedWithUnit)
SpeedUnit.MPH -> String.format("%.1f mph", speedWithUnit)
}
}
fun displayCadence(): String {
return String.format("%d RPM", cadence)
}
fun displayDistance(): String {
return when (selectedSpeedUnit) {
SpeedUnit.M_S -> String.format("%.0f m", distance)
SpeedUnit.KM_H -> String.format("%.0f m", distance)
SpeedUnit.MPH -> String.format("%.0f yd", distance)
}
}
fun displayTotalDistance(): String {
return when (selectedSpeedUnit) {
SpeedUnit.M_S -> String.format("%.2f km", distance)
SpeedUnit.KM_H -> String.format("%.2f km", distance)
SpeedUnit.MPH -> String.format("%.2f mile", distance)
}
}
fun displayGearRatio(): String {
return String.format(Locale.US, "%.1f", gearRatio)
}
}

View File

@@ -13,28 +13,20 @@ import no.nordicsemi.android.csc.events.CrankDataChanged
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
import no.nordicsemi.android.csc.service.CSCDataReadBroadcast
import no.nordicsemi.android.csc.view.CSCViewConnectedState
import no.nordicsemi.android.csc.view.CSCViewEvent
import no.nordicsemi.android.csc.view.CSCViewNotConnectedState
import no.nordicsemi.android.csc.view.CSCViewState
import no.nordicsemi.android.csc.view.OnBluetoothDeviceSelected
import no.nordicsemi.android.csc.view.OnConnectButtonClick
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
import no.nordicsemi.android.csc.view.OnMovedToScannerScreen
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class CscViewModel @Inject constructor(
private val localBroadcast: CSCDataReadBroadcast,
private val deviceHolder: SelectedBluetoothDeviceHolder
private val localBroadcast: CSCDataReadBroadcast
) : ViewModel() {
val state = MutableStateFlow(createInitialState())
val state = MutableStateFlow(CSCViewState())
init {
localBroadcast.events.onEach {
@@ -42,10 +34,6 @@ internal class CscViewModel @Inject constructor(
}.launchIn(viewModelScope)
}
private fun createInitialState(): CSCViewState {
return deviceHolder.device?.let { CSCViewConnectedState() } ?: CSCViewNotConnectedState()
}
private fun consumeEvent(event: CSCServiceEvent) {
val newValue = when (event) {
is CrankDataChanged -> createNewState(event)
@@ -55,21 +43,21 @@ internal class CscViewModel @Inject constructor(
state.value = newValue
}
private fun createNewState(event: CrankDataChanged): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
private fun createNewState(event: CrankDataChanged): CSCViewState {
return state.value.copy(
cadence = event.crankCadence,
gearRatio = event.gearRatio
)
}
private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
private fun createNewState(event: OnBatteryLevelChanged): CSCViewState {
return state.value.copy(
batteryLevel = event.batteryLevel
)
}
private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState {
return state.value.ensureConnectedState().copy(
private fun createNewState(event: OnDistanceChangedEvent): CSCViewState {
return state.value.copy(
speed = event.speed,
distance = event.distance,
totalDistance = event.totalDistance
@@ -82,41 +70,26 @@ internal class CscViewModel @Inject constructor(
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
is OnWheelSizeSelected -> onWheelSizeChanged(event)
OnDisconnectButtonClick -> onDisconnectButtonClick()
OnConnectButtonClick -> onConnectButtonClick()
OnMovedToScannerScreen -> onOnMovedToScannerScreen()
is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected()
}.exhaustive
}
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
state.tryEmit(state.value.ensureConnectedState().copy(selectedSpeedUnit = event.selectedSpeedUnit))
state.tryEmit(state.value.copy(selectedSpeedUnit = event.selectedSpeedUnit))
}
private fun onShowDialogEvent() {
state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true))
state.tryEmit(state.value.copy(showDialog = true))
}
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
localBroadcast.setWheelSize(event.wheelSize)
state.tryEmit(state.value.ensureConnectedState().copy(
state.tryEmit(state.value.copy(
showDialog = false,
wheelSize = event.wheelSizeDisplayInfo
))
}
private fun onDisconnectButtonClick() {
state.tryEmit(CSCViewNotConnectedState())
}
private fun onConnectButtonClick() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = true))
}
private fun onOnMovedToScannerScreen() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = false))
}
private fun onBluetoothDeviceSelected() {
state.tryEmit(CSCViewConnectedState())
state.tryEmit(state.value.copy(isScreenActive = false))
}
}

View File

@@ -2,16 +2,11 @@
<resources>
<string name="csc_title">Cyclic and speed cadence</string>
<string name="csc_disconnect">Disconnect</string>
<string name="csc_no_connection">No device connected</string>
<string name="csc_connect">Connect</string>
<string name="scs_field_speed">Speed</string>
<string name="scs_field_cadence">Cadence</string>
<string name="scs_field_distance">Distance</string>
<string name="scs_field_total_distance">Total Distance</string>
<string name="scs_field_gear_ratio">Gear Ratio</string>
<string name="scs_field_battery">Battery</string>
<string name="scs_field_wheel_size">Wheel size</string>

View File

@@ -1,9 +1,9 @@
package no.nordicsemi.android.csc
import androidx.annotation.FloatRange
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
@@ -12,6 +12,12 @@ import org.junit.Assert.*
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
println("red: ${colorToHex(0f)}")
println("green: ${colorToHex(169f)}")
println("blue: ${colorToHex(206f)}")
assertEquals(4, 2 + 2)
}
private fun colorToHex(@FloatRange(from = 0.0, to = 1.0) value: Float) = Integer.toHexString((0xFF * value).toInt())
}

28
feature_hrs/build.gradle Normal file
View File

@@ -0,0 +1,28 @@
apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_service")
implementation project(":lib_theme")
implementation project(":lib_utils")
implementation libs.chart
implementation libs.nordic.ble.common
implementation libs.nordic.log
implementation libs.bundles.compose
implementation libs.androidx.core
implementation libs.material
implementation libs.lifecycle.activity
implementation libs.lifecycle.service
implementation libs.compose.lifecycle
implementation libs.compose.activity
testImplementation libs.test.junit
androidTestImplementation libs.android.test.junit
androidTestImplementation libs.android.test.espresso
androidTestImplementation libs.android.test.compose.ui
debugImplementation libs.android.test.compose.tooling
}

View File

@@ -0,0 +1,24 @@
package no.nordicsemi.android.hrs
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.hrs.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.hrs">
<application>
<service android:name=".service.HRSService" />
</application>
</manifest>

View File

@@ -0,0 +1,7 @@
package no.nordicsemi.android.hrs.events
internal data class HRSAggregatedData(
val heartRates: List<Int> = emptyList(),
val batteryLevel: Int = 0,
val sensorLocation: Int = 0
)

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.ble.data.Data
object BodySensorLocationParser {
fun parse(data: Data): String {
val value = data.getIntValue(Data.FORMAT_UINT8, 0)!!
return when (value) {
6 -> "Foot"
5 -> "Ear Lobe"
4 -> "Hand"
3 -> "Finger"
2 -> "Wrist"
1 -> "Chest"
0 -> "Other"
else -> "Other"
}
}
}

View File

@@ -0,0 +1,9 @@
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.service.BluetoothDataReadBroadcast
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast<HRSAggregatedData>()

View File

@@ -0,0 +1,162 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Context
import android.util.Log
import androidx.annotation.IntRange
import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationDataCallback
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation
import no.nordicsemi.android.ble.data.Data
import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager
import java.util.*
/**
* HRSManager class performs BluetoothGatt operations for connection, service discovery,
* enabling notification and reading characteristics.
* All operations required to connect to device with BLE Heart Rate Service and reading
* heart rate values are performed here.
*/
class HRSManager(context: Context) : BatteryManager<HRSManagerCallbacks>(context) {
private var heartRateCharacteristic: BluetoothGattCharacteristic? = null
private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null
override fun getGattCallback(): BatteryManagerGattCallback {
return HeartRateManagerCallback()
}
/**
* BluetoothGatt callbacks for connection/disconnection, service discovery,
* receiving notification, etc.
*/
private inner class HeartRateManagerCallback : BatteryManagerGattCallback() {
override fun initialize() {
super.initialize()
readCharacteristic(bodySensorLocationCharacteristic)
.with(object : BodySensorLocationDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(
LogContract.Log.Level.APPLICATION,
"\"" + BodySensorLocationParser.parse(data) + "\" received"
)
super.onDataReceived(device, data)
}
override fun onBodySensorLocationReceived(
device: BluetoothDevice,
@BodySensorLocation sensorLocation: Int
) {
mCallbacks?.onBodySensorLocationReceived(device, sensorLocation)
}
})
.fail { device: BluetoothDevice?, status: Int ->
log(Log.WARN, "Body Sensor Location characteristic not found")
}
.enqueue()
setNotificationCallback(heartRateCharacteristic)
.with(object : HeartRateMeasurementDataCallback() {
override fun onDataReceived(device: BluetoothDevice, data: Data) {
log(
LogContract.Log.Level.APPLICATION,
"\"" + HeartRateMeasurementParser.parse(data) + "\" received"
)
super.onDataReceived(device, data)
}
override fun onHeartRateMeasurementReceived(
device: BluetoothDevice,
@IntRange(from = 0) heartRate: Int,
contactDetected: Boolean?,
@IntRange(from = 0) energyExpanded: Int?,
rrIntervals: List<Int>?
) {
mCallbacks?.onHeartRateMeasurementReceived(
device,
heartRate,
contactDetected,
energyExpanded,
rrIntervals
)
}
})
enableNotifications(heartRateCharacteristic).enqueue()
}
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
val service = gatt.getService(HR_SERVICE_UUID)
if (service != null) {
heartRateCharacteristic = service.getCharacteristic(
HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID
)
}
return heartRateCharacteristic != null
}
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
super.isOptionalServiceSupported(gatt)
val service = gatt.getService(HR_SERVICE_UUID)
if (service != null) {
bodySensorLocationCharacteristic = service.getCharacteristic(
BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID
)
}
return bodySensorLocationCharacteristic != null
}
override fun onDeviceDisconnected() {
super.onDeviceDisconnected()
bodySensorLocationCharacteristic = null
heartRateCharacteristic = null
}
override fun onServicesInvalidated() {}
}
companion object {
val HR_SERVICE_UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb")
private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb")
private var managerInstance: HRSManager? = null
/**
* Singleton implementation of HRSManager class.
*/
@Synchronized
fun getInstance(context: Context): HRSManager? {
if (managerInstance == null) {
managerInstance = HRSManager(context)
}
return managerInstance
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocationCallback
import no.nordicsemi.android.ble.common.profile.hr.HeartRateMeasurementCallback
import no.nordicsemi.android.service.BatteryManagerCallbacks
interface HRSManagerCallbacks
: BatteryManagerCallbacks, BodySensorLocationCallback, HeartRateMeasurementCallback

View File

@@ -0,0 +1,53 @@
package no.nordicsemi.android.hrs.service
import android.bluetooth.BluetoothDevice
import dagger.hilt.android.AndroidEntryPoint
import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.service.ForegroundBleService
import no.nordicsemi.android.service.LoggableBleManager
import javax.inject.Inject
@AndroidEntryPoint
internal class HRSService : ForegroundBleService<HRSManager>(), HRSManagerCallbacks {
private var data = HRSAggregatedData()
private val points = mutableListOf<Int>()
@Inject
lateinit var localBroadcast: HRSDataBroadcast
override val manager: HRSManager by lazy {
HRSManager(this).apply {
setGattCallbacks(this@HRSService)
}
}
override fun initializeManager(): LoggableBleManager<out BleManagerCallbacks> {
return manager
}
override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) {
sendNewData(data.copy(batteryLevel = batteryLevel))
}
override fun onBodySensorLocationReceived(device: BluetoothDevice, sensorLocation: Int) {
sendNewData(data.copy(sensorLocation = sensorLocation))
}
override fun onHeartRateMeasurementReceived(
device: BluetoothDevice,
heartRate: Int,
contactDetected: Boolean?,
energyExpanded: Int?,
rrIntervals: MutableList<Int>?
) {
points.add(heartRate)
sendNewData(data.copy(heartRates = points))
}
private fun sendNewData(newData: HRSAggregatedData) {
data = newData
localBroadcast.offer(newData)
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2015, Nordic Semiconductor
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
* software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package no.nordicsemi.android.hrs.service
import no.nordicsemi.android.ble.data.Data
import java.util.*
object HeartRateMeasurementParser {
private const val HEART_RATE_VALUE_FORMAT: Byte = 0x01 // 1 bit
private const val SENSOR_CONTACT_STATUS: Byte = 0x06 // 2 bits
private const val ENERGY_EXPANDED_STATUS: Byte = 0x08 // 1 bit
private const val RR_INTERVAL: Byte = 0x10 // 1 bit
fun parse(data: Data): String {
var offset = 0
val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!!
/*
* false Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm)
* true Heart Rate Value Format is set to UINT16. Units: beats per minute (bpm)
*/
val value16bit = flags and HEART_RATE_VALUE_FORMAT.toInt() > 0
/*
* 0 Sensor Contact feature is not supported in the current connection
* 1 Sensor Contact feature is not supported in the current connection
* 2 Sensor Contact feature is supported, but contact is not detected
* 3 Sensor Contact feature is supported and contact is detected
*/
val sensorContactStatus = flags and SENSOR_CONTACT_STATUS.toInt() shr 1
/*
* false Energy Expended field is not present
* true Energy Expended field is present. Units: kilo Joules
*/
val energyExpandedStatus = flags and ENERGY_EXPANDED_STATUS.toInt() > 0
/*
* false RR-Interval values are not present.
* true One or more RR-Interval values are present. Units: 1/1024 seconds
*/
val rrIntervalStatus = flags and RR_INTERVAL.toInt() > 0
// heart rate value is 8 or 16 bit long
val heartRateValue = data.getIntValue(
if (value16bit) {
Data.FORMAT_UINT16
} else {
Data.FORMAT_UINT8
},
offset++
) // bits per minute
if (value16bit) offset++
// energy expanded value is present if a flag was set
var energyExpanded = -1
if (energyExpandedStatus) energyExpanded = data.getIntValue(Data.FORMAT_UINT16, offset)!!
offset += 2
// RR-interval is set when a flag is set
val rrIntervals: MutableList<Float> = ArrayList()
if (rrIntervalStatus) {
var o = offset
while (o < data.value!!.size) {
val units = data.getIntValue(Data.FORMAT_UINT16, o)!!
rrIntervals.add(units * 1000.0f / 1024.0f) // RR interval is in [1/1024s]
o += 2
}
}
val builder = StringBuilder()
builder.append("Heart Rate Measurement: ").append(heartRateValue).append(" bpm")
when (sensorContactStatus) {
0, 1 -> builder.append(",\nSensor Contact Not Supported")
2 -> builder.append(",\nContact is NOT Detected")
3 -> builder.append(",\nContact is Detected")
}
if (energyExpandedStatus) {
builder.append(",\nEnergy Expanded: ")
.append(energyExpanded)
.append(" kJ")
}
if (rrIntervalStatus) {
builder.append(",\nRR Interval: ")
for (interval in rrIntervals) builder.append(
String.format(
Locale.US,
"%.02f ms, ",
interval
)
)
builder.setLength(builder.length - 2) // remove the ", " at the end
}
return builder.toString()
}
}

View File

@@ -0,0 +1,224 @@
package no.nordicsemi.android.hrs.view
import android.content.Context
import android.graphics.Color
import android.graphics.DashPathEffect
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.formatter.IFillFormatter
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
import com.github.mikephil.charting.utils.Utils
import no.nordicsemi.android.hrs.R
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.view.BatteryLevelView
import java.util.*
@Composable
internal fun ContentView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Box(modifier = Modifier.padding(16.dp)) {
LineChartView(state)
}
}
Spacer(modifier = Modifier.height(16.dp))
BatteryLevelView(state.batteryLevel)
Spacer(modifier = Modifier.height(16.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { onEvent(DisconnectEvent) }
) {
Text(text = stringResource(id = R.string.disconnect))
}
}
}
@Composable
fun LineChartView(state: HRSViewState) {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
factory = { createLineChartView(it, state) },
update = { updateData(state.points, it) }
)
}
fun createLineChartView(context: Context, state: HRSViewState): LineChart {
return LineChart(context).apply {
setBackgroundColor(Color.WHITE)
description.isEnabled = false
setTouchEnabled(true)
// setOnChartValueSelectedListener(this)
setDrawGridBackground(false)
isDragEnabled = true
setScaleEnabled(true)
setPinchZoom(true)
xAxis.apply {
xAxis.enableGridDashedLine(10f, 10f, 0f)
}
axisLeft.apply {
enableGridDashedLine(10f, 10f, 0f)
axisMaximum = 300f
axisMinimum = 100f
}
axisRight.isEnabled = false
//---
val entries = state.points.mapIndexed { i, v ->
Entry(i.toFloat(), v.toFloat())
}
// create a dataset and give it a type
if (data != null && data.dataSetCount > 0) {
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
set1.values = entries
set1.notifyDataSetChanged()
data!!.notifyDataChanged()
notifyDataSetChanged()
} else {
val set1 = LineDataSet(entries, "DataSet 1")
set1.setDrawIcons(false)
// draw dashed line
// draw dashed line
set1.enableDashedLine(10f, 5f, 0f)
// black lines and points
// black lines and points
set1.color = Color.BLACK
set1.setCircleColor(Color.BLACK)
// line thickness and point size
// line thickness and point size
set1.lineWidth = 1f
set1.circleRadius = 3f
// draw points as solid circles
// draw points as solid circles
set1.setDrawCircleHole(false)
// customize legend entry
// customize legend entry
set1.formLineWidth = 1f
set1.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f)
set1.formSize = 15f
// text size of values
// text size of values
set1.valueTextSize = 9f
// draw selection line as dashed
// draw selection line as dashed
set1.enableDashedHighlightLine(10f, 5f, 0f)
// set the filled area
// set the filled area
set1.setDrawFilled(true)
set1.fillFormatter = IFillFormatter { _, _ ->
axisLeft.axisMinimum
}
// set color of filled area
// set color of filled area
if (Utils.getSDKInt() >= 18) {
// drawables only supported on api level 18 and above
val drawable = ContextCompat.getDrawable(context, R.drawable.fade_red)
set1.fillDrawable = drawable
} else {
set1.fillColor = Color.BLACK
}
val dataSets = ArrayList<ILineDataSet>()
dataSets.add(set1) // add the data sets
// create a data object with the data sets
// create a data object with the data sets
val data = LineData(dataSets)
// set data
// set data
setData(data)
}
}
}
private fun updateData(points: List<Int>, chart: LineChart) {
val entries = points.mapIndexed { i, v ->
Entry(i.toFloat(), v.toFloat())
}
with(chart) {
if (data != null && data.dataSetCount > 0) {
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
set1.values = entries
set1.notifyDataSetChanged()
data!!.notifyDataChanged()
notifyDataSetChanged()
invalidate()
}
}
}
@Preview
@Composable
private fun Preview() {
ContentView(state = HRSViewState()) { }
}

View File

@@ -0,0 +1,52 @@
package no.nordicsemi.android.hrs.view
import android.content.Intent
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.hrs.R
import no.nordicsemi.android.hrs.service.HRSService
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
import no.nordicsemi.android.utils.isServiceRunning
@Composable
fun HRSScreen(finishAction: () -> Unit) {
val viewModel: HRSViewModel = hiltViewModel()
val state = viewModel.state.collectAsState().value
val context = LocalContext.current
LaunchedEffect(state.isScreenActive) {
if (!state.isScreenActive) {
finishAction()
}
if (context.isServiceRunning(HRSService::class.java.name)) {
val intent = Intent(context, HRSService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(HRSService::class.java.name)) {
val intent = Intent(context, HRSService::class.java)
context.startService(intent)
}
}
HRSView(state) { viewModel.onEvent(it) }
}
@Composable
private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.hrs_title)) })
ContentView(state) { onEvent(it) }
}
}

View File

@@ -0,0 +1,5 @@
package no.nordicsemi.android.hrs.view
sealed class HRSScreenViewEvent
object DisconnectEvent : HRSScreenViewEvent()

View File

@@ -0,0 +1,47 @@
package no.nordicsemi.android.hrs.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import no.nordicsemi.android.hrs.events.HRSAggregatedData
import no.nordicsemi.android.hrs.service.HRSDataBroadcast
import no.nordicsemi.android.hrs.view.DisconnectEvent
import no.nordicsemi.android.hrs.view.HRSScreenViewEvent
import javax.inject.Inject
@HiltViewModel
internal class HRSViewModel @Inject constructor(
private val localBroadcast: HRSDataBroadcast
) : ViewModel() {
val state = MutableStateFlow(HRSViewState())
init {
localBroadcast.events.onEach {
withContext(Dispatchers.Main) { consumeEvent(it) }
}.launchIn(viewModelScope)
}
private fun consumeEvent(event: HRSAggregatedData) {
state.value = state.value.copy(
points = event.heartRates,
batteryLevel = event.batteryLevel,
sensorLocation = event.sensorLocation
)
}
fun onEvent(event: HRSScreenViewEvent) {
(event as? DisconnectEvent)?.let {
onDisconnectButtonClick()
}
}
private fun onDisconnectButtonClick() {
state.tryEmit(state.value.copy(isScreenActive = false))
}
}

View File

@@ -0,0 +1,8 @@
package no.nordicsemi.android.hrs.viewmodel
data class HRSViewState(
val points: List<Int> = listOf(1, 2, 3),
val batteryLevel: Int = 0,
val sensorLocation: Int = 0,
val isScreenActive: Boolean = true
)

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:startColor="#00ff0000"
android:endColor="#ffff0000" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hrs_title">HRS</string>
</resources>

View File

@@ -0,0 +1,17 @@
package no.nordicsemi.android.hrs
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)
}
}

View File

@@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":lib_utils")
implementation project(":lib_theme")
implementation project(":lib_service")
implementation libs.material
implementation libs.google.permissions

View File

@@ -7,7 +7,8 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.scanner.tools.PermissionHelper
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import javax.inject.Singleton
@Module
@@ -24,7 +25,16 @@ internal object HiltModule {
fun createSelectedBluetoothDeviceHolder(
@ApplicationContext context: Context,
bluetoothAdapter: BluetoothAdapter?
): SelectedBluetoothDeviceHolder {
return SelectedBluetoothDeviceHolder(context, bluetoothAdapter)
): no.nordicsemi.android.service.SelectedBluetoothDeviceHolder {
return no.nordicsemi.android.service.SelectedBluetoothDeviceHolder(
context,
bluetoothAdapter
)
}
@Singleton
@Provides
fun createPermissionHelper(@ApplicationContext context: Context): PermissionHelper {
return PermissionHelper(context)
}
}

View File

@@ -1,41 +0,0 @@
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.scanner.tools.ScannerStatus
import no.nordicsemi.android.scanner.view.*
import no.nordicsemi.android.scanner.viewmodel.NordicBleScannerViewModel
import no.nordicsemi.android.scanner.viewmodel.ScannerViewEvent
import no.nordicsemi.android.utils.exhaustive
@Composable
fun ScannerRoute(navController: NavController) {
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
val scannerStatus = viewModel.state.collectAsState().value
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 -> ScanDeviceScreen(navController)
}.exhaustive
}

View File

@@ -2,14 +2,10 @@ package no.nordicsemi.android.scanner.tools
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
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())
class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
fun getBluetoothStatus(): ScannerStatus {
return when {
@@ -19,15 +15,3 @@ internal class NordicBleScanner @Inject constructor(private val bleAdapter: Blue
}
}
}
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,21 @@
package no.nordicsemi.android.scanner.tools
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
class PermissionHelper(private val context: Context) {
fun isRequiredPermissionGranted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
}

View File

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

View File

@@ -5,18 +5,42 @@ import android.bluetooth.BluetoothAdapter
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.scanner.R
@Composable
internal fun BluetoothNotAvailableScreen() {
Text("Bluetooth not available.")
fun BluetoothNotAvailableScreen() {
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.scanner__bluetooth_not_available))
}
}
}
@Composable
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
fun BluetoothNotEnabledScreen(finish: () -> Unit) {
val contract = ActivityResultContracts.StartActivityForResult()
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
if (it.resultCode == Activity.RESULT_OK) {
@@ -25,10 +49,30 @@ internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
})
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.")
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
textAlign = TextAlign.Center,
text = stringResource(id = R.string.scanner__bluetooth_not_enabled)
)
Spacer(Modifier.height(16.dp))
Text(
textAlign = TextAlign.Center,
text = stringResource(id = R.string.scanner__bluetooth_open_settings_info)
)
Spacer(Modifier.height(32.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
) {
Text(text = stringResource(id = R.string.scanner__bluetooth_open_settings))
}
}
}
}

View File

@@ -0,0 +1,54 @@
package no.nordicsemi.android.scanner.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.scanner.R
@Composable
private fun NotConnectedScreen(
connect: () -> Unit
) {
NotConnectedView(connect)
}
@Composable
private fun NotConnectedView(
connect: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.csc_no_connection))
Spacer(modifier = Modifier.height(16.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { connect() }
) {
Text(text = stringResource(id = R.string.csc_connect))
}
}
}
@Preview
@Composable
private fun NotConnectedPreview() {
NotConnectedView { }
}

View File

@@ -4,9 +4,18 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.*
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -14,6 +23,7 @@ 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity
@@ -24,12 +34,14 @@ import no.nordicsemi.android.scanner.R
@OptIn(ExperimentalPermissionsApi::class)
@Composable
internal fun RequestPermissionScreen(finish: () -> Unit) {
fun RequestPermissionScreen(finish: () -> Unit) {
val permissionsState = rememberMultiplePermissionsState(listOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
// android.Manifest.permission.BLUETOOTH_SCAN,
// android.Manifest.permission.BLUETOOTH_CONNECT
android.Manifest.permission.BLUETOOTH_CONNECT
))
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
PermissionsRequired(
multiplePermissionsState = permissionsState,
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
@@ -37,6 +49,7 @@ internal fun RequestPermissionScreen(finish: () -> Unit) {
) {
finish()
}
}
}
@Composable
@@ -45,7 +58,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
if (doNotShowRationale.value) {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -53,18 +68,21 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
}
} else {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(id = R.string.scanner__permission_rationale))
Spacer(modifier = Modifier.height(8.dp))
Text(textAlign = TextAlign.Center, text = stringResource(id = R.string.scanner__permission_rationale))
Spacer(modifier = Modifier.height(16.dp))
Row {
Button(onClick = { onClick() }) {
Button(modifier = Modifier.width(100.dp), onClick = { onClick() }) {
Text(stringResource(id = R.string.scanner__button_ok))
}
Spacer(Modifier.width(8.dp))
Button(onClick = { doNotShowRationale.value = true }) {
Spacer(Modifier.width(16.dp))
Button(modifier = Modifier.width(100.dp), onClick = { doNotShowRationale.value = true }) {
Text(stringResource(id = R.string.scanner__button_nope))
}
}
@@ -76,7 +94,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
private fun PermissionNotAvailable() {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {

View File

@@ -1,14 +1,11 @@
package no.nordicsemi.android.scanner.view
import android.app.Activity
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanResult
import android.companion.AssociationRequest
import android.companion.BluetoothLeDeviceFilter
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.IntentSender
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
@@ -16,11 +13,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
@Composable
fun ScanDeviceScreen(navController: NavController,) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fun ScanDeviceScreen(finish: (ScanDeviceScreenResult) -> Unit) {
val deviceManager =
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
val contract = ActivityResultContracts.StartIntentSenderForResult()
val launcher = rememberLauncherForActivityResult(contract = contract) {
val result = if (it.resultCode == Activity.RESULT_OK) {
ScanDeviceScreenResult.SUCCESS
} else {
ScanDeviceScreenResult.CANCEL
}
finish(result)
}
val hasBeenInvoked = remember { mutableStateOf(false) }
if (hasBeenInvoked.value) {
return
}
hasBeenInvoked.value = true
val deviceFilter = BluetoothLeDeviceFilter.Builder()
.build()
@@ -28,31 +42,6 @@ fun ScanDeviceScreen(navController: NavController,) {
.addDeviceFilter(deviceFilter)
.build()
val deviceManager =
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
val contract = ActivityResultContracts.StartIntentSenderForResult()
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
if (it.resultCode == Activity.RESULT_OK) {
//Sometimes result is ScanResult & sometimes BluetoothDevice
val device: BluetoothDevice = try {
it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
} catch (e: Exception) {
(it.data?.getParcelableExtra<ScanResult>(CompanionDeviceManager.EXTRA_DEVICE))!!.device
}
navController.previousBackStackEntry
?.savedStateHandle
?.set("result", device)
}
navController.popBackStack()
})
val hasBeenInvoked = remember { mutableStateOf(false) }
if (hasBeenInvoked.value) {
return
}
hasBeenInvoked.value = true
deviceManager.associate(pairingRequest,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) {
@@ -62,8 +51,10 @@ fun ScanDeviceScreen(navController: NavController,) {
override fun onFailure(error: CharSequence?) {
}
}, null)
} else {
TODO("VERSION.SDK_INT < O")
}
}, null
)
}
enum class ScanDeviceScreenResult {
SUCCESS, CANCEL
}

View File

@@ -0,0 +1,9 @@
package no.nordicsemi.android.scanner.viewmodel
enum class BluetoothPermissionState {
PERMISSION_REQUIRED,
BLUETOOTH_NOT_AVAILABLE,
BLUETOOTH_NOT_ENABLED,
DEVICE_NOT_CONNECTED,
READY
}

View File

@@ -1,37 +0,0 @@
package no.nordicsemi.android.scanner.viewmodel
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import no.nordicsemi.android.scanner.tools.NordicBleScanner
import no.nordicsemi.android.scanner.tools.ScannerStatus
import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject
@HiltViewModel
internal class NordicBleScannerViewModel @Inject constructor(
private val bleScanner: NordicBleScanner
) : ViewModel() {
val state =
MutableStateFlow(ScannerStatus.PERMISSION_REQUIRED)
fun onEvent(event: ScannerViewEvent) {
when (event) {
ScannerViewEvent.PERMISSION_CHECKED -> onPermissionChecked()
ScannerViewEvent.BLUETOOTH_ENABLED -> onBluetoothEnabled()
}.exhaustive
}
private fun onPermissionChecked() {
state.value = bleScanner.getBluetoothStatus()
}
private fun onBluetoothEnabled() {
state.value = bleScanner.getBluetoothStatus()
}
}
internal enum class ScannerViewEvent {
PERMISSION_CHECKED, BLUETOOTH_ENABLED
}

View File

@@ -4,12 +4,23 @@
<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>
<string name="csc_no_connection">No device connected</string>
<string name="csc_connect">Connect</string>
<string name="scanner__button_ok">Grant</string>
<string name="scanner__button_nope">Deny</string>
<string name="scanner__request_permission">Request permission</string>
<string name="scanner__bluetooth_not_available">Bluetooth not available.</string>
<string name="scanner__bluetooth_not_enabled">Bluetooth not enabled.</string>
<string name="scanner__bluetooth_open_settings_info">To enable Bluetooth please open settings.</string>
<string name="scanner__bluetooth_open_settings">Open settings</string>
</resources>

View File

@@ -2,7 +2,7 @@ apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize'
dependencies {
implementation project(":feature_scanner")
implementation project(":lib_theme")
implementation libs.nordic.ble.common
implementation libs.nordic.log

View File

@@ -2,4 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.service">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
</manifest>

View File

@@ -42,7 +42,6 @@ import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.ble.utils.ILogger
import no.nordicsemi.android.log.ILogSession
import no.nordicsemi.android.log.Logger
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
import javax.inject.Inject
@AndroidEntryPoint
@@ -68,7 +67,7 @@ abstract class BleProfileService : LifecycleService(), BleManagerCallbacks {
* @return bluetooth device
*/
protected val bluetoothDevice: BluetoothDevice by lazy {
bluetoothDeviceHolder.device ?: throw UnsupportedOperationException(
bluetoothDeviceHolder.device ?: throw IllegalArgumentException(
"No device address at EXTRA_DEVICE_ADDRESS key"
)
}

View File

@@ -22,16 +22,29 @@
package no.nordicsemi.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
private const val CHANNEL_ID = "FOREGROUND_BLE_SERVICE"
abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbacks>> : BleProfileService() {
protected abstract val manager: T
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val result = super.onStartCommand(intent, flags, startId)
startForegroundService()
return result
}
override fun onDestroy() {
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
cancelNotification()
stopForegroundService()
super.onDestroy()
}
@@ -87,24 +100,30 @@ abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbac
* @param defaults
*/
private fun createNotification(messageResId: Int, defaults: Int): Notification {
TODO()
// final Intent parentIntent = new Intent(this, FeaturesActivity.class);
// parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// final Intent targetIntent = new Intent(this, CSCActivity.class);
//
// final Intent disconnect = new Intent(ACTION_DISCONNECT);
// final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT);
//
// // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed
// final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[]{parentIntent, targetIntent}, PendingIntent.FLAG_UPDATE_CURRENT);
// final NotificationCompat.Builder builder = new NotificationCompat.Builder(this, ToolboxApplication.CONNECTED_DEVICE_CHANNEL);
// builder.setContentIntent(pendingIntent);
// builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName()));
// builder.setSmallIcon(R.drawable.ic_stat_notify_csc);
// builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true);
// builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction));
//
// return builder.build();
createNotificationChannel(CHANNEL_ID)
val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(messageResId, manager.bluetoothDevice?.name ?: "Device"))
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.build()
}
private fun createNotificationChannel(channelName: String) {
val channel = NotificationChannel(
channelName,
getString(R.string.channel_connected_devices_title),
NotificationManager.IMPORTANCE_LOW
)
channel.description = getString(R.string.channel_connected_devices_description)
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
/**

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.scanner.tools
package no.nordicsemi.android.service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice

View File

@@ -4,4 +4,6 @@
<string name="csc_bonding">Bonding with the device&#8230;</string>
<string name="csc_bonded">The device is now bonded.</string>
<string name="csc_notification_connected_message">%s is connected.</string>
<string name="channel_connected_devices_title">Background connections</string>
<string name="channel_connected_devices_description">Shows a notification when a device is connected in background.</string>
</resources>

View File

@@ -1,24 +0,0 @@
package no.nordicsemi.android.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
object Background {
@Composable
fun whiteRoundedCorners(): Modifier {
return Modifier
.background(Color(0xffffffff))
.padding(16.dp)
.verticalScroll(rememberScrollState())
.clip(RoundedCornerShape(10.dp))
}
}

View File

@@ -1,26 +1,67 @@
package no.nordicsemi.android.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
object NordicColors {
val Primary = Color(0xFF00A9CE)
val PrimaryLight = Color(0xFF5fdbff)
val PrimaryDark = Color(0xFF007a9d)
val Secondary = Color(0xFF0077c8)
val SecondaryLight = Color(0xFF57c0e2)
val SecondaryDark = Color(0xFF004c97)
val Text = Color(0xFF00A9CE)
val AlmostWhite = Color(0xFFDADADA)
val NordicBlue = Color(0xFF00A9CE)
val NordicBlueDark = Color(0xFF0090B0)
val NordicSky = Color(0xFF6AD1E3)
val NordicBlueLate = Color(0xFF0033A0)
val NordicLake = Color(0xFF0077C8)
val NordicLightGray = Color(0xFFD9E1E2)
val NordicMediumGray = Color(0xFF768692)
val NordicDarkGray = Color(0xFF333F48)
val NordicGrass = Color(0xFFD0DF00)
val NordicSun = Color(0xFFFFCD00)
val NordicRed = Color(0xFFEE2F4E)
val NordicFall = Color(0xFFF58220)
val NordicLake = Color(0xFF008CD2)
val NordicDarkGray = ThemedColor(Color(0xFF333F48), Color(0xFFCCCBC8))
// val NordicGray4 = ThemedColor(Color(0xFFD1D1D6), Color(0xFF3A3A3C))
val NordicGray4 = ThemedColor(Color.White, Color(0xFF3A3A3C))
val NordicGray5 = ThemedColor(Color(0xFFE5E5EA), Color(0xFF2C2C2E))
val NordicLightGray = NeutralColor(Color(0xFF929CA2))
val NordicMediumGray = NeutralColor(Color(0xFF929CA2))
val NordicFall = ThemedColor(Color(0xFFF99535), Color(0xFFFF9F0A))
val NordicGreen = ThemedColor(Color(0xFF3ED052), Color(0xFF32D74B))
val NordicOrange = ThemedColor(Color(0xFFDF9B16), Color(0xFFFF9F0A))
val NordicRed = ThemedColor(Color(0xFFD03E51), Color(0xFFFF453A))
val NordicSky = NeutralColor(Color(0xFF6AD1E3))
val NordicYellow = ThemedColor(Color(0xFFF9EE35), Color(0xFFFFD60A))
val TableViewBackground = NeutralColor(Color(0xFFF2F2F6))
val TableViewSeparator = NeutralColor(Color(0xFFD2D2D6))
val Primary = ThemedColor(Color(0xFF00A9CE), Color(0xFF212121))
val PrimaryVariant = ThemedColor(Color(0xFF008CD2), Color.Black)
val Secondary = ThemedColor(Color(0xFF00A9CE), Color(0xFF008CD2))
val SecondaryVariant = ThemedColor(Color(0xFF008CD2), Color(0xFF008CD2))
val OnPrimary = ThemedColor(Color.White, Color.White)
val OnSecondary = ThemedColor(Color.White, Color.White)
val OnBackground = ThemedColor(Color.Black, Color.White)
val OnSurface = ThemedColor(Color.Black, Color.White)
val Background = ThemedColor(Color(0xFFDADADA), Color.Black)
val Surface = ThemedColor(Color(0xFFDADADA), Color.Black)
}
sealed class NordicColor {
@Composable
abstract fun value(): Color
}
data class ThemedColor(val light: Color, val dark: Color): NordicColor() {
@Composable
override fun value(): Color {
return if (isSystemInDarkTheme()) {
dark
} else {
light
}
}
}
data class NeutralColor(val color: Color): NordicColor() {
@Composable
override fun value(): Color {
return color
}
}

View File

@@ -5,41 +5,40 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
//TODO
private val DarkColorPalette = darkColors(
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
background = Color.White,
surface = Color.White,
)
private val LightColorPalette = lightColors(
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
background = Color.White,
surface = Color.White,
)
@Composable
fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val darkColorPalette = darkColors(
primary = NordicColors.Primary.value(),
primaryVariant = NordicColors.PrimaryVariant.value(),
secondary = NordicColors.Secondary.value(),
secondaryVariant = NordicColors.SecondaryVariant.value(),
onSecondary = NordicColors.OnSecondary.value(),
onPrimary = NordicColors.OnPrimary.value(),
onBackground = NordicColors.OnBackground.value(),
onSurface = NordicColors.OnSurface.value(),
background = NordicColors.Background.value(),
surface = NordicColors.Surface.value(),
)
val lightColorPalette = lightColors(
primary = NordicColors.Primary.value(),
primaryVariant = NordicColors.PrimaryVariant.value(),
secondary = NordicColors.Secondary.value(),
secondaryVariant = NordicColors.SecondaryVariant.value(),
onSecondary = NordicColors.OnSecondary.value(),
onPrimary = NordicColors.OnPrimary.value(),
onBackground = NordicColors.OnBackground.value(),
onSurface = NordicColors.OnSurface.value(),
background = NordicColors.Background.value(),
surface = NordicColors.Surface.value(),
)
val colors = if (darkTheme) {
DarkColorPalette
darkColorPalette
} else {
LightColorPalette
lightColorPalette
}
MaterialTheme(

View File

@@ -0,0 +1,28 @@
package no.nordicsemi.android.theme.view
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.R
@Composable
fun BatteryLevelView(batteryLevel: Int) {
Card(
backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Box(modifier = Modifier.padding(16.dp)) {
KeyValueField(
stringResource(id = R.string.field_battery),
"$batteryLevel%"
)
}
}
}

View File

@@ -0,0 +1,23 @@
package no.nordicsemi.android.theme.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.nordicsemi.android.theme.NordicColors
@Composable
fun KeyValueField(key: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = key)
Text(
color = NordicColors.NordicDarkGray.value(),
text = value
)
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2017, Nordic Semiconductor
~ All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
~
~ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
~
~ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
~ documentation and/or other materials provided with the distribution.
~
~ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
~ software without specific prior written permission.
~
~ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
~ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
~ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
~ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
~ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
~ USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/actionBarColor"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2017, Nordic Semiconductor
~ All rights reserved.
~
~ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
~
~ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
~
~ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
~ documentation and/or other materials provided with the distribution.
~
~ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this
~ software without specific prior written permission.
~
~ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
~ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
~ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
~ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
~ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
~ USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/actionBarColor"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Some files were not shown because too many files have changed in this diff Show More