Implement ChannelSounding

This commit is contained in:
himalia416
2025-09-23 15:47:37 +02:00
committed by Himali Aryal
parent 78c2ea17b7
commit 05849fa66b
23 changed files with 1456 additions and 134 deletions

View File

@@ -185,8 +185,9 @@ internal class ProfileService : NotificationService() {
manager: ServiceManager
) {
try {
if (service.uuid == CGMS_SERVICE_UUID.toKotlinUuid())
if (service.uuid == CGMS_SERVICE_UUID.toKotlinUuid()) {
peripheral.ensureBonded()
}
manager.observeServiceInteractions(peripheral.address, service, lifecycleScope)
} catch (e: Exception) {
Timber.tag("ObserveServices").e(e)

View File

@@ -0,0 +1,51 @@
package no.nordicsemi.android.ui.view.animate
import androidx.compose.animation.core.EaseInOutCubic
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SocialDistance
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview(showBackground = true)
@Composable
fun AnimatedDistance(
modifier: Modifier = Modifier,
color: Color = Color.Blue
) {
// Infinite transition for pulsing animation
val infiniteTransition = rememberInfiniteTransition()
val scaleX by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.15f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
)
)
Icon(
imageVector = Icons.Filled.SocialDistance,
contentDescription = "Distance icon",
modifier = modifier
.size(28.dp)
.graphicsLayer(
// Scale only horizontally
scaleX = scaleX,
scaleY = 1f,
),
tint = color
)
}

View File

@@ -23,7 +23,7 @@ private const val RANGING_PERMISSION_REQUEST_CODE = 1001
@Singleton
internal class RangingStateManager @Inject constructor(
@ApplicationContext private val context: Context,
@param:ApplicationContext private val context: Context,
) {
private val dataProvider = LocalDataProvider(context)
private val utils = RangingPermissionUtils(context, dataProvider)
@@ -66,7 +66,11 @@ internal class RangingStateManager @Inject constructor(
}
fun isRangingPermissionDenied(): Boolean {
return utils.isRangingPermissionDenied()
return try {
utils.isRangingPermissionDenied()
} catch (_: Exception) {
false
}
}
private fun getRangingPermissionState(): RangingPermissionState {

View File

@@ -30,7 +30,7 @@ internal class RangingPermissionUtils(
dataProvider.isRangingPermissionRequested && // Ranging permission was requested.
!isRangingPermissionGranted // Ranging permission is not granted
&& !context.findActivity()
.shouldShowRequestPermissionRationale(Manifest.permission.RANGING)
?.shouldShowRequestPermissionRationale(Manifest.permission.RANGING)!!
}
@@ -42,12 +42,16 @@ internal class RangingPermissionUtils(
* @throws IllegalStateException if no activity was found.
* @return the activity.
*/
private fun Context.findActivity(): Activity {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
private fun Context.findActivity(): Activity? {
return try {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
null // no activity found
} catch (e: Exception) {
null
}
throw IllegalStateException("no activity")
}
}

View File

@@ -15,7 +15,6 @@ dependencies {
api(project(":lib_service"))
implementation(project(":profile_manager"))
implementation(project(":lib_storage"))
implementation(project(":permissions-ranging"))
implementation(libs.nordic.core)
implementation(libs.nordic.navigation)

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RANGING" />
<uses-permission
android:name="android.permission.RANGING"
android:required="false" />
<application>
<service
android:name="no.nordicsemi.android.service.profile.ProfileService"

View File

@@ -88,7 +88,7 @@ internal fun ProfileScreen() {
) { paddingValues ->
RequireBluetooth {
RequireLocation {
RequestNotificationPermission {
RequestNotificationPermission { isNotificationPermissionGranted ->
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
@@ -102,6 +102,7 @@ internal fun ProfileScreen() {
when (val state = uiState) {
is ProfileUiState.Connected -> DeviceConnectedView(
state = state,
isNotificationPermissionGranted = isNotificationPermissionGranted,
onEvent = onEvent
)
@@ -143,6 +144,7 @@ internal fun ProfileScreen() {
@Composable
internal fun DeviceConnectedView(
state: ProfileUiState.Connected,
isNotificationPermissionGranted: Boolean?,
onEvent: (ConnectionEvent) -> Unit,
) {
// Check for missing services directly from the state object.
@@ -190,7 +192,7 @@ internal fun DeviceConnectedView(
// Display the appropriate screen for each profile.
when (serviceManager.profile) {
Profile.HTS -> HTSScreen()
Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen()
Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen(isNotificationPermissionGranted)
Profile.BPS -> BPSScreen()
Profile.CSC -> CSCScreen()
Profile.CGM -> CGMScreen()

View File

@@ -1,51 +1,87 @@
package no.nordicsemi.android.toolbox.profile.repository.channelSounding
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.ranging.RangingData
import android.ranging.RangingDevice
import android.ranging.RangingManager
import android.ranging.RangingPreference
import android.ranging.RangingPreference.DEVICE_ROLE_RESPONDER
import android.ranging.RangingPreference.DEVICE_ROLE_INITIATOR
import android.ranging.RangingSession
import android.ranging.SensorFusionParams
import android.ranging.SessionConfig
import android.ranging.ble.cs.BleCsRangingCapabilities
import android.ranging.ble.cs.BleCsRangingParams
import android.ranging.raw.RawRangingDevice
import android.ranging.raw.RawResponderRangingConfig
import androidx.annotation.RequiresApi
import no.nordicsemi.android.toolbox.profile.repository.channelSounding.RangingSessionStartTechnology.Companion.getTechnology
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import no.nordicsemi.android.toolbox.profile.data.RangingSessionAction
import no.nordicsemi.android.toolbox.profile.data.UpdateRate
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
object ChannelSoundingManager {
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@Singleton
class ChannelSoundingManager @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val rangingManager: RangingManager? =
context.getSystemService(RangingManager::class.java)
private lateinit var rangingCapabilityCallback: RangingManager.RangingCapabilitiesCallback
private val _rangingData = MutableStateFlow<RangingSessionAction?>(null)
val rangingData = _rangingData.asStateFlow()
private val _previousRangingDataList = MutableStateFlow<List<Float>>(emptyList())
private var rangingSession: RangingSession? = null
private val rangingSessionCallback = @RequiresApi(Build.VERSION_CODES.BAKLAVA)
object : RangingSession.Callback {
override fun onClosed(reason: Int) {
Timber.d("closed, reason: ${RangingSessionCloseReason.getReason(reason)}")
_rangingData.value =
RangingSessionAction.OnError(RangingSessionCloseReason.getReason(reason))
// Unregister the callback to avoid memory leaks
rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback)
// Cleanup previous data
_previousRangingDataList.value = emptyList()
}
override fun onOpenFailed(reason: Int) {
Timber.d("Failed, reason: ${RangingSessionFailedReason.getReason(reason)}")
_rangingData.value =
RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason))
// Unregister the callback to avoid memory leaks
rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback)
// Cleanup previous data
_previousRangingDataList.value = emptyList()
}
override fun onOpened() {
Timber.d("Opened successfully.")
_rangingData.value = RangingSessionAction.OnStart
}
override fun onResults(
peer: RangingDevice,
data: RangingData
) {
val measurement = data.distance?.measurement
val confidence = data.distance?.confidence
Timber.d("RangingTechnology: ${data.rangingTechnology}")
Timber.d(
"Azimuth: ${data.azimuth}\televation: " +
"${data.elevation}\tpeer: ${peer.uuid} distance ${data.distance}\t" +
" rssi: ${data.rssi} \tmeasurement: $measurement\tconfidence: $confidence"
val updatedList = _previousRangingDataList.value.toMutableList()
data.distance?.measurement?.let {
updatedList.add(it.toFloat())
}
_previousRangingDataList.value = updatedList
_rangingData.value = RangingSessionAction.OnResult(
data = data,
previousData = _previousRangingDataList.value
)
}
@@ -53,55 +89,42 @@ object ChannelSoundingManager {
peer: RangingDevice,
technology: Int
) {
Timber.d(
"Session started with peer: ${peer.uuid}, \ntechnology: ${getTechnology(technology)}"
)
_rangingData.value = RangingSessionAction.OnStart
// Cleanup previous data
_previousRangingDataList.value = emptyList()
}
override fun onStopped(
peer: RangingDevice,
technology: Int
) {
Timber.d("Session stopped with peer: ${peer.uuid}")
_rangingData.value = RangingSessionAction.OnClosed
// Cleanup previous data
_previousRangingDataList.value = emptyList()
}
}
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun addDeviceToRangingSession(
context: Context,
device: String
device: String,
updateRate: UpdateRate = UpdateRate.NORMAL
) {
val rangingManager = try {
context.getSystemService(RangingManager::class.java)
} catch (e: Exception) {
null
}
if (rangingManager == null) {
// RangingManager is not supported on this device
_rangingData.value = RangingSessionAction.OnError("RangingManager is not available")
return
}
val rangingCapabilityCallback = RangingManager.RangingCapabilitiesCallback { capabilities ->
if (capabilities.csCapabilities != null) {
capabilities.csCapabilities!!.supportedSecurityLevels
.find { it == 1 }
?.let {
Timber.d("Channel Sounding supported.")
}
} else {
Timber.d("Channel Sounding Capabilities is not supported")
}
val setRangingUpdateRate = when (updateRate) {
UpdateRate.FREQUENT -> RawRangingDevice.UPDATE_RATE_FREQUENT
UpdateRate.NORMAL -> RawRangingDevice.UPDATE_RATE_NORMAL
UpdateRate.INFREQUENT -> RawRangingDevice.UPDATE_RATE_INFREQUENT
}
rangingManager.registerCapabilitiesCallback(
context.mainExecutor,
rangingCapabilityCallback
)
val rangingDevice = RangingDevice.Builder()
.build()
val csRangingParams = BleCsRangingParams.Builder(device)
val csRangingParams = BleCsRangingParams
.Builder(device)
.setRangingUpdateRate(setRangingUpdateRate)
.setSecurityLevel(BleCsRangingCapabilities.CS_SECURITY_LEVEL_ONE)
.build()
val rawRangingDevice = RawRangingDevice.Builder()
@@ -114,7 +137,7 @@ object ChannelSoundingManager {
.build()
val rangingPreference = RangingPreference.Builder(
DEVICE_ROLE_RESPONDER,
DEVICE_ROLE_INITIATOR,
rawRangingDeviceConfig
)
.setSessionConfig(
@@ -123,21 +146,88 @@ object ChannelSoundingManager {
.setAngleOfArrivalNeeded(true)
.setSensorFusionParams(
SensorFusionParams.Builder()
.setSensorFusionEnabled(false)
.setSensorFusionEnabled(true)
.build()
)
.build()
)
.build()
rangingSession = rangingManager.createRangingSession(
context.mainExecutor,
rangingSessionCallback
)
rangingSession?.let {
it.addDeviceToRangingSession(rawRangingDeviceConfig)
it.start(rangingPreference)
rangingCapabilityCallback = RangingManager.RangingCapabilitiesCallback { capabilities ->
if (capabilities.csCapabilities != null) {
if (capabilities.csCapabilities!!.supportedSecurityLevels.contains(1)) {
// Channel Sounding supported
// Check if Ranging Permission is granted before starting the session
if (hasRangingPermissions(context)) {
rangingSession = rangingManager.createRangingSession(
context.mainExecutor,
rangingSessionCallback
)
rangingSession?.let {
try {
it.addDeviceToRangingSession(rawRangingDeviceConfig)
} catch (e: Exception) {
Timber.e("Failed to add device to ranging session: ${e.message}")
_rangingData.value = RangingSessionAction.OnClosed
} finally {
it.start(rangingPreference)
}
}
} else {
_rangingData.value =
RangingSessionAction.OnError("Missing Ranging permission")
return@RangingCapabilitiesCallback
}
} else {
_rangingData.value =
RangingSessionAction.OnError("Channel Sounding with required security level is not supported")
closeSession()
}
} else {
_rangingData.value =
RangingSessionAction.OnError("Channel Sounding Capabilities is not supported")
closeSession()
}
}
rangingManager.registerCapabilitiesCallback(
context.mainExecutor,
rangingCapabilityCallback
)
}
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun closeSession(onClosed: (suspend () -> Unit)? = null) {
try {
rangingSession?.let { session ->
session.stop()
session.close()
rangingSession = null
_rangingData.value = null
// unregister the callback
onClosed?.let {
_rangingData.value = RangingSessionAction.OnStart
// Wait for a moment to ensure the session is properly closed before invoking the callback
// Launch a coroutine to delay and call onClosed
CoroutineScope(Dispatchers.IO).launch {
delay(500)
it()
}
}
}
} catch (e: Exception) {
_rangingData.value = RangingSessionAction.OnError(e.message ?: "Unknown error")
}
}
private fun hasRangingPermissions(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.RANGING
) == PackageManager.PERMISSION_GRANTED
}
}

View File

@@ -9,14 +9,14 @@ enum class RangingSessionCloseReason(val reason: Int) {
REASON_NO_PEERS_FOUND(5), ;
override fun toString(): String {
return when (reason) {
REASON_UNKNOWN.reason -> "Unknown"
REASON_LOCAL_REQUEST.reason -> "Local request"
REASON_NO_PEERS_FOUND.reason -> "No peers found"
REASON_REMOTE_REQUEST.reason -> "Remote request"
REASON_SYSTEM_POLICY.reason -> "System policy"
REASON_UNSUPPORTED.reason -> "Unsupported"
else -> "Unknown reason"
return when (this) {
REASON_UNKNOWN -> ""
REASON_LOCAL_REQUEST -> "local request" // Indicates that the session was closed because AutoCloseable.close() or RangingSession.stop() was called.
REASON_REMOTE_REQUEST -> "request of a remote peer" // Indicates that the session was closed at the request of a remote peer.
REASON_UNSUPPORTED -> "provided session parameters were not supported"
REASON_SYSTEM_POLICY -> "local system policy forced the session to close" // Indicates that the local system policy forced the session to close, such as power management policy, airplane mode etc.
REASON_NO_PEERS_FOUND -> "none of the specified peers were found" // Indicates that the session was closed because none of the specified peers were found.
}
}

View File

@@ -10,12 +10,12 @@ enum class RangingSessionFailedReason(val reason: Int) {
override fun toString(): String {
return when (this) {
UNKNOWN -> "Unknown"
LOCAL_REQUEST -> "Local request" // Indicates that the session was closed because AutoCloseable.close() or RangingSession.stop() was called.
REMOTE_REQUEST -> "Remote request" // Indicates that the session was closed at the request of a remote peer.
UNSUPPORTED -> "Unsupported" // Indicates that the session closed because the provided session parameters were not supported.
SYSTEM_POLICY -> "System policy" // Indicates that the local system policy forced the session to close, such as power management policy, airplane mode etc.
NO_PEERS_FOUND -> "No peers found" // Indicates that the session was closed because none of the specified peers were found.
UNKNOWN -> ""
LOCAL_REQUEST -> "local request" // Indicates that the session was closed because AutoCloseable.close() or RangingSession.stop() was called.
REMOTE_REQUEST -> "request of a remote peer" // Indicates that the session was closed at the request of a remote peer.
UNSUPPORTED -> "provided session parameters were not supported"
SYSTEM_POLICY -> "local system policy forced the session to close" // Indicates that the local system policy forced the session to close, such as power management policy, airplane mode etc.
NO_PEERS_FOUND -> "none of the specified peers were found" // Indicates that the session was closed because none of the specified peers were found.
}
}

View File

@@ -1,62 +1,385 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import android.content.pm.PackageManager
import android.os.Build
import android.ranging.RangingData
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SocialDistance
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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
import no.nordicsemi.android.permissions_ranging.RequestRangingPermission
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.theme.NordicTheme
import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.data.ChannelSoundingServiceData
import no.nordicsemi.android.toolbox.profile.data.ConfidenceLevel
import no.nordicsemi.android.toolbox.profile.data.RangingSessionAction
import no.nordicsemi.android.toolbox.profile.data.RangingTechnology
import no.nordicsemi.android.toolbox.profile.data.UpdateRate
import no.nordicsemi.android.toolbox.profile.viewmodel.ChannelSoundingEvent
import no.nordicsemi.android.toolbox.profile.viewmodel.ChannelSoundingViewModel
import no.nordicsemi.android.ui.view.ScreenSection
import no.nordicsemi.android.ui.view.SectionTitle
import no.nordicsemi.android.ui.view.TextWithAnimatedDots
import no.nordicsemi.android.ui.view.internal.LoadingView
@Composable
internal fun ChannelSoundingScreen() {
RequestRangingPermission {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.padding(16.dp)
) {
internal fun ChannelSoundingScreen(isNotificationPermissionGranted: Boolean?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && isNotificationPermissionGranted != null) {
RequestRangingPermission {
val channelSoundingViewModel = hiltViewModel<ChannelSoundingViewModel>()
val channelSoundingState by channelSoundingViewModel.channelSoundingState.collectAsStateWithLifecycle()
val onClickEvent: (event: ChannelSoundingEvent) -> Unit =
{ channelSoundingViewModel.onEvent(it) }
ChannelSoundingView(channelSoundingState, onClickEvent)
}
} else {
ChannelSoundingNotSupportedView()
}
}
@Preview(showBackground = true)
@Composable
private fun ChannelSoundingNotSupportedView() {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
ScreenSection(modifier = Modifier.padding(0.dp) /* No padding */) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp),
) {
SectionTitle(
icon = Icons.Default.SocialDistance,
title = stringResource(R.string.channel_sounding),
)
Text(stringResource(R.string.channel_sounding_not_supported))
}
}
}
}
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@Composable
private fun ChannelSoundingView(
channelSoundingState: ChannelSoundingServiceData,
onClickEvent: (ChannelSoundingEvent) -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
when (val sessionData = channelSoundingState.rangingSessionAction) {
is RangingSessionAction.OnError -> {
SessionError(sessionData)
}
is RangingSessionAction.OnResult -> {
RangingContent(
channelSoundingState.updateRate,
sessionData.data,
sessionData.previousData,
onClickEvent
)
}
RangingSessionAction.OnClosed -> {
SessionClosed()
}
RangingSessionAction.OnStart -> {
InitiatingSession()
}
null -> LoadingView()
}
}
}
@Composable
private fun InitiatingSession() {
ScreenSection(modifier = Modifier.padding(0.dp) /* No padding */) {
Column(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) {
SectionTitle(
icon = Icons.Default.SocialDistance,
title = "Channel Sounding",
title = stringResource(R.string.channel_sounding),
)
val context = LocalContext.current
val rangingPermissionStatusMessage =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
if (ContextCompat.checkSelfPermission(
context,
"android.permission.RANGING"
) == PackageManager.PERMISSION_GRANTED
) {
"Ranging permission is granted"
} else {
"Ranging permission is not granted"
}
} else {
"Channel Sounding Service is not available on this Android version."
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextWithAnimatedDots(
text = stringResource(R.string.initiating_ranging),
)
}
}
}
Box(contentAlignment = Alignment.Center) {
@Composable
private fun SessionClosed() {
ScreenSection(modifier = Modifier.padding(0.dp) /* No padding */) {
Column(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) {
SectionTitle(
icon = Icons.Default.SocialDistance,
title = stringResource(R.string.channel_sounding),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(stringResource(R.string.ranging_session_stopped))
}
}
}
@Composable
private fun SessionError(sessionData: RangingSessionAction.OnError) {
ScreenSection(modifier = Modifier.padding(0.dp) /* No padding */) {
Column(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) {
SectionTitle(
icon = Icons.Default.SocialDistance,
title = stringResource(R.string.channel_sounding),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (sessionData.reason.isNotEmpty()) {
Text(
text = rangingPermissionStatusMessage
stringResource(
R.string.ranging_session_closed_with_reason,
sessionData.reason
),
modifier = Modifier.padding(8.dp)
)
} else {
Text(
stringResource(R.string.ranging_session_closed),
modifier = Modifier.padding(8.dp)
)
}
}
}
}
@Composable
private fun DistanceDashboard(measurement: Double) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.ranging_distance_m, measurement.toFloat()),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.displayLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.current_measurement),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
}
}
@Preview
@Composable
private fun DDistanceDashboard_Preview() {
NordicTheme {
DistanceDashboard(2.5)
}
}
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@Composable
private fun RangingContent(
updateRate: UpdateRate,
rangingData: RangingData,
previousMeasurements: List<Float> = emptyList(),
onClickEvent: (ChannelSoundingEvent) -> Unit,
) {
val distanceMeasurement = rangingData.distance?.measurement
val confidence = rangingData.distance?.confidence
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
distanceMeasurement?.let { measurement ->
DistanceDashboard(measurement)
}
DetailsCard(
updateRate = updateRate,
rangingTechnology = rangingData.rangingTechnology,
confidenceLevel = confidence
) { onClickEvent(ChannelSoundingEvent.RangingUpdateRate(it)) }
Spacer(modifier = Modifier.height(16.dp))
RecentMeasurementsChart(previousMeasurements)
}
}
@Preview(showBackground = true)
@Composable
private fun DetailsCard(
updateRate: UpdateRate = UpdateRate.NORMAL,
rangingTechnology: Int = RangingTechnology.BLE_CS.value,
confidenceLevel: Int? = ConfidenceLevel.CONFIDENCE_HIGH.value,
onUpdateRateSelected: (UpdateRate) -> Unit = { }
) {
// Details Section
Text(
text = stringResource(R.string.ranging_details),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 16.dp)
.alpha(0.5f)
)
OutlinedCard(
modifier = Modifier
.fillMaxWidth(),
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.ranging_technology),
style = MaterialTheme.typography.bodyMedium
)
Text(RangingTechnology.from(rangingTechnology)?.let {
stringResource(it.toUiString())
} ?: "Unknown",
style = MaterialTheme.typography.titleSmall
)
}
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.update_rate),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
UpdateRateSettings(updateRate) { onUpdateRateSelected(it) }
Spacer(modifier = Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(Modifier.width(6.dp))
Text(
stringResource(updateRate.toUiString()),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
}
}
HorizontalDivider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
stringResource(R.string.signal_strength),
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
SignalStrengthBar(confidenceLevel)
}
}
}
}
@Composable
private fun RecentMeasurementsChart(
previousMeasurements: List<Float>,
) {
// Recent Measurements
Text(
text = stringResource(R.string.ranging_previous_measurement),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 16.dp)
.alpha(0.5f)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(250.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.20f))
.padding(8.dp)
) {
RecentMeasurementChart(
previousData = previousMeasurements
)
}
}

View File

@@ -0,0 +1,40 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import androidx.annotation.StringRes
import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.data.RangingTechnology
import no.nordicsemi.android.toolbox.profile.data.UpdateRate
import no.nordicsemi.android.toolbox.profile.data.UpdateRate.FREQUENT
import no.nordicsemi.android.toolbox.profile.data.UpdateRate.INFREQUENT
import no.nordicsemi.android.toolbox.profile.data.UpdateRate.NORMAL
@StringRes
internal fun UpdateRate.toUiString(): Int {
return when (this) {
FREQUENT -> R.string.update_rate_frequent
INFREQUENT -> R.string.update_rate_infrequent
NORMAL -> R.string.update_rate_normal
}
}
@StringRes
internal fun UpdateRate.description(): Int {
return when (this) {
FREQUENT -> R.string.update_rate_frequent_des
INFREQUENT -> R.string.update_rate_infrequent_des
NORMAL -> R.string.update_rate_normal_des
}
}
@StringRes
internal fun RangingTechnology.toUiString(): Int {
return when (this) {
RangingTechnology.BLE_CS -> R.string.ranging_tech_ble_cs
RangingTechnology.BLE_RSSI -> R.string.ranging_tech_ble_rssi
RangingTechnology.UWB -> R.string.ranging_tech_uwb
RangingTechnology.WIFI_NAN_RTT -> R.string.ranging_tech_wifi_nan_rtt
RangingTechnology.WIFI_STA_RTT -> R.string.ranging_tech_wifi_sta_rtt
}
}

View File

@@ -0,0 +1,125 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import no.nordicsemi.android.ui.view.createLinearTransition
import java.util.Locale
@Composable
internal fun RangingChartView(measurement: Float) {
val duration = 1000
val isInAccessibilityMode = rememberSaveable { mutableStateOf(false) }
val transition = createLinearTransition(isInAccessibilityMode.value, duration)
val rangeMax =
if (measurement < 5) 5
else if (measurement < 10) 10
else if (measurement < 20) 20
else if (measurement < 50) 50
else if (measurement < 100) 100
else if (measurement < 200) 200
else if (measurement < 500) 500
else 1000
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
val chartWidth = maxWidth
val min = 0
val max = rangeMax
val diff = max - min
val step = diff / 4f
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// Chart itself
Canvas(
modifier = Modifier
.height(transition.height.value)
.fillMaxWidth()
.border(
transition.border.value,
transition.color.value,
RoundedCornerShape(transition.radius.value)
)
.combinedClickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = { isInAccessibilityMode.value = !isInAccessibilityMode.value }
)
) {
// background bar
drawRoundRect(
color = transition.inactiveColor.value,
size = Size(chartWidth.toPx(), transition.height.value.toPx()),
cornerRadius = CornerRadius(
transition.radius.value.toPx(),
transition.radius.value.toPx()
)
)
// progress bar
val progressWidth = when {
measurement <= min -> 0f
measurement >= max -> 1f
else -> (measurement - min) / (max - min).toFloat()
}
drawRoundRect(
color = transition.color.value,
size = Size(progressWidth * size.width, transition.height.value.toPx()),
cornerRadius = CornerRadius(
transition.radius.value.toPx(),
transition.radius.value.toPx()
)
)
}
Spacer(Modifier.height(4.dp))
// Labels row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
for (i in 0..4) {
val labelValue = min + i * step
Text(
text = String.format(Locale.US, "%d m", labelValue.toInt()),
fontSize = 12.sp
)
}
}
}
}
}
@Preview
@Composable
private fun RangingChartViewPreview() {
RangingChartView(25f)
}

View File

@@ -0,0 +1,190 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import android.content.Context
import android.graphics.Color
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.toColorInt
import com.github.mikephil.charting.charts.LineChart
import com.github.mikephil.charting.components.Legend
import com.github.mikephil.charting.components.XAxis
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.interfaces.datasets.ILineDataSet
import no.nordicsemi.android.common.theme.NordicTheme
private const val X_AXIS_ELEMENTS_COUNT = 40.0f
private val customBlue = "#00A9CE".toColorInt()
@Composable
internal fun RecentMeasurementChart(previousData: List<Float>) {
val items = previousData.takeLast(X_AXIS_ELEMENTS_COUNT.toInt()).reversed()
val isSystemInDarkTheme = isSystemInDarkTheme()
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
factory = { createLineChartView(isSystemInDarkTheme, it, items) },
update = { updateData(items, it) }
)
}
internal fun createLineChartView(
isDarkTheme: Boolean,
context: Context,
points: List<Float>
): LineChart {
return LineChart(context).apply {
description.isEnabled = false
legend.isEnabled = true
setTouchEnabled(false)
setDrawGridBackground(false)
isDragEnabled = false
setScaleEnabled(false)
setPinchZoom(false)
if (isDarkTheme) {
setBackgroundColor(Color.TRANSPARENT)
xAxis.gridColor = Color.WHITE
xAxis.textColor = Color.WHITE
axisLeft.gridColor = Color.WHITE
axisLeft.textColor = Color.WHITE
} else {
setBackgroundColor(Color.WHITE)
xAxis.gridColor = Color.BLACK
xAxis.textColor = Color.BLACK
axisLeft.gridColor = Color.BLACK
axisLeft.textColor = Color.BLACK
}
xAxis.apply {
xAxis.enableGridDashedLine(10f, 10f, 0f)
axisMinimum = -X_AXIS_ELEMENTS_COUNT
axisMaximum = 0f
setAvoidFirstLastClipping(true)
position = XAxis.XAxisPosition.BOTTOM
setDrawLabels(false) // Hide X-axis labels
setDrawGridLines(false) // Hide vertical grid lines
}
axisLeft.apply {
enableGridDashedLine(10f, 10f, 0f)
}
axisRight.isEnabled = false
val entries = points.mapIndexed { i, v ->
Entry(-i.toFloat(), v)
}.reversed()
legend.apply {
isEnabled = true
textColor = customBlue
form = Legend.LegendForm.LINE
horizontalAlignment = Legend.LegendHorizontalAlignment.CENTER
verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM
}
// 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, "Recent Measurements")
set1.setDrawIcons(false)
set1.setDrawValues(false)
// draw dashed line
set1.enableDashedLine(0f, 0f, 0f)
// blue lines and points
set1.color = customBlue
set1.setDrawCircles(false)
// line thickness and point size
set1.lineWidth = 3f
// set1.circleRadius = 3f
// draw points as solid circles
set1.setDrawCircleHole(false)
// customize legend entry
set1.formLineWidth = 1f
set1.formLineWidth = 2f
set1.formSize = 15f
// text size of values
set1.valueTextSize = 9f
// draw selection line as dashed
// set1.enableDashedHighlightLine(10f, 5f, 0f)
val dataSets = ArrayList<ILineDataSet>()
dataSets.add(set1) // add the data sets
// create a data object with the data sets
val data = LineData(dataSets)
// set data
setData(data)
}
}
}
private fun updateData(points: List<Float>, chart: LineChart) {
val entries = points.mapIndexed { i, v ->
Entry(-i.toFloat(), v)
}.reversed()
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(showBackground = true)
@Composable
private fun LineChartView_Preview() {
NordicTheme {
RecentMeasurementChart(
previousData = listOf(
3.2f,
4.5f,
2.8f,
5.0f,
3.6f,
4.1f,
3.9f,
4.8f,
2.5f,
3.3f,
4.0f,
3.7f,
4.2f,
3.0f
)
)
}
}

View File

@@ -0,0 +1,57 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
@Composable
internal fun RequestRangingPermission(content: @Composable (Boolean?) -> Unit) {
val context = LocalContext.current
val isPendingPermissionGranted = remember { mutableStateOf<Boolean?>(null) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
isPendingPermissionGranted.value = isGranted
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.RANGING
) != PackageManager.PERMISSION_GRANTED
) {
// Permission is denied and not requestable
LaunchedEffect(Unit) {
launcher.launch(Manifest.permission.RANGING)
}
isPendingPermissionGranted.value?.let {
// If pending permission is granted, request again
LaunchedEffect(it) {
launcher.launch(Manifest.permission.RANGING)
}
content(it)
} ?: Column {
Text("Requesting notification permission...")
}
return
} else {
content(true)
}
} else {
//requested
content(null)
}
}

View File

@@ -0,0 +1,76 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.common.theme.nordicFall
import no.nordicsemi.android.common.theme.nordicGreen
import no.nordicsemi.android.common.theme.nordicRed
import no.nordicsemi.android.toolbox.profile.data.ConfidenceLevel
@Preview(showBackground = true)
@Composable
internal fun SignalStrengthBar(confidenceLevel: Int? = ConfidenceLevel.CONFIDENCE_HIGH.value) {
val (signalColor, strengthFraction) = when (confidenceLevel) {
ConfidenceLevel.CONFIDENCE_HIGH.value -> Pair(nordicGreen, 1.0f)
ConfidenceLevel.CONFIDENCE_MEDIUM.value -> Pair(nordicFall, 0.66f)
ConfidenceLevel.CONFIDENCE_LOW.value -> Pair(nordicRed, 0.33f)
else -> Pair(MaterialTheme.colorScheme.primary, 0.0f)
}
val infiniteTransition = rememberInfiniteTransition(label = "signal-loading")
val offsetX by infiniteTransition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "offsetX"
)
val brush = Brush.linearGradient(
colors = listOf(
signalColor.copy(alpha = 0.75f),
signalColor,
signalColor.copy(alpha = 0.75f)
),
start = Offset(offsetX * 200f, 0f),
end = Offset((offsetX + 1f) * 200f, 0f)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.fillMaxWidth(strengthFraction)
.fillMaxHeight()
.clip(RoundedCornerShape(50))
.background(brush)
)
}
}

View File

@@ -0,0 +1,195 @@
package no.nordicsemi.android.toolbox.profile.view.channelSounding
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.data.UpdateRate
@Composable
internal fun UpdateRateSettings(
selectedItem: UpdateRate,
onItemSelected: (UpdateRate) -> Unit
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
Box {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clip(CircleShape)
.clickable { isExpanded = !isExpanded }
)
// Show AlertDialog when isExpanded is true
AnimatedVisibility(isExpanded) {
// Your AlertDialog content here
UpdateRateDialog(
selectedUpdateRate = selectedItem,
onConfirmation = onItemSelected,
onDismiss = { isExpanded = false }
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun UpdateRateSettingsPreview() {
UpdateRateSettings(UpdateRate.NORMAL) { }
}
@Composable
internal fun UpdateRateDialog(
selectedUpdateRate: UpdateRate,
onDismiss: () -> Unit,
onConfirmation: (UpdateRate) -> Unit,
) {
val updateOptions = UpdateRate.entries
val (selectedOption, onOptionSelected) = remember { mutableStateOf(selectedUpdateRate) }
AlertDialog(
title = {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.change_update_rate),
style = MaterialTheme.typography.titleMedium
)
Text(
stringResource(R.string.select_new_update_rate),
style = MaterialTheme.typography.bodySmall
)
}
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(Modifier.selectableGroup()) {
updateOptions.forEach { text ->
OutlinedCard(
modifier = Modifier.padding(8.dp)
) {
Row(
Modifier
.fillMaxWidth()
.selectable(
selected = (text == selectedOption),
onClick = { onOptionSelected(text) },
role = Role.RadioButton
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(text.toUiString()),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringResource(text.description()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
RadioButton(
selected = (text == selectedOption),
onClick = null // null recommended for accessibility with screen readers
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.WarningAmber,
contentDescription = null,
tint = MaterialTheme.colorScheme.scrim
)
Text(text = stringResource(R.string.update_rate_change_warning))
}
}
},
onDismissRequest = { onDismiss() },
confirmButton = {
Button(
onClick = {
onConfirmation(selectedOption)
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
) {
Text(stringResource(R.string.update_rate_confirm))
}
},
dismissButton = {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, // looks "muted"
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Text(stringResource(R.string.update_rate_cancel))
}
}
)
}
@Preview(showBackground = true)
@Composable
private fun UpdateRateDialogPreview() {
UpdateRateDialog(
selectedUpdateRate = UpdateRate.NORMAL,
onConfirmation = {},
onDismiss = {}
)
}

View File

@@ -1,33 +1,41 @@
package no.nordicsemi.android.toolbox.profile.viewmodel
import android.content.Context
import android.os.Build
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel
import no.nordicsemi.android.toolbox.profile.manager.repository.ChannelSoundingRepository
import no.nordicsemi.android.toolbox.lib.utils.Profile
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.data.ChannelSoundingServiceData
import no.nordicsemi.android.toolbox.profile.data.UpdateRate
import no.nordicsemi.android.toolbox.profile.manager.repository.ChannelSoundingRepository
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository
import no.nordicsemi.android.toolbox.profile.repository.channelSounding.ChannelSoundingManager
import no.nordicsemi.kotlin.ble.core.BondState
import timber.log.Timber
import javax.inject.Inject
// Channel Sounding Profile Events
internal sealed interface ChannelSoundingEvent {
data class RangingUpdateRate(val frequency: UpdateRate) : ChannelSoundingEvent
data class UpdateInterval(val interval: Int) : ChannelSoundingEvent
}
@HiltViewModel
internal class ChannelSoundingViewModel @Inject constructor(
private val deviceRepository: DeviceRepository,
@param:ApplicationContext private val context: Context,
navigator: Navigator,
savedStateHandle: SavedStateHandle,
private val channelSoundingManager: ChannelSoundingManager,
) : SimpleNavigationViewModel(navigator, savedStateHandle) {
// StateFlow to hold the selected temperature unit
private val _channelSoundingServiceState = MutableStateFlow(ChannelSoundingServiceData())
@@ -50,7 +58,13 @@ internal class ChannelSoundingViewModel @Inject constructor(
if (peripheral.address == address) {
profiles.filter { it.profile == Profile.CHANNEL_SOUNDING }
.forEach { _ ->
startChannelSounding(peripheral.address)
launch {
peripheral.bondState
.filter { it == BondState.BONDED }
.first()
// Wait until the device is bonded before starting channel sounding
startChannelSounding(peripheral.address)
}
}
}
}
@@ -60,23 +74,69 @@ internal class ChannelSoundingViewModel @Inject constructor(
/**
* Starts the Channel Sounding service and observes channel sounding profile data changes.
*/
private fun startChannelSounding(address: String) {
private fun startChannelSounding(address: String, rate: UpdateRate = UpdateRate.NORMAL) {
ChannelSoundingRepository.getData(address).onEach {
_channelSoundingServiceState.value = _channelSoundingServiceState.value.copy(
profile = it.profile
)
}.launchIn(viewModelScope)
if (Build.VERSION.SDK_INT >= 36) {
viewModelScope.launch {
try {
ChannelSoundingManager.addDeviceToRangingSession(context, address)
} catch (e: Exception) {
Timber.e(" ${e.message}")
}
try {
channelSoundingManager.addDeviceToRangingSession(address, rate)
channelSoundingManager.rangingData
.filter { it != null }
.onEach {
it?.let { data ->
_channelSoundingServiceState.value =
_channelSoundingServiceState.value.copy(
rangingSessionAction = data,
)
}
}.launchIn(viewModelScope)
} catch (e: Exception) {
Timber.e("${e.message}")
}
} else {
Timber.tag("Channel_Sounding")
.d("Channel Sounding is not available in this Android version.")
Timber.d("Channel Sounding is not available in this Android version.")
}
}
/**
* Handles events related to the Channel Sounding profile.
*/
fun onEvent(event: ChannelSoundingEvent) {
when (event) {
is ChannelSoundingEvent.RangingUpdateRate -> {
// Stop the current session and start a new one with the updated rate
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
try {
viewModelScope.launch {
if (_channelSoundingServiceState.value.updateRate != event.frequency) {
channelSoundingManager.closeSession {
channelSoundingManager.addDeviceToRangingSession(
address,
event.frequency
)
}
}
}
} catch (e: Exception) {
Timber.e("Error closing session: ${e.message}")
}
}
// Update the update rate in the state
_channelSoundingServiceState.value = _channelSoundingServiceState.value.copy(
updateRate = event.frequency
)
}
is ChannelSoundingEvent.UpdateInterval -> {
// Update the interval in the state
_channelSoundingServiceState.value = _channelSoundingServiceState.value.copy(
interval = event.interval
)
}
}
}

View File

@@ -22,9 +22,11 @@ import no.nordicsemi.android.log.LogSession
import no.nordicsemi.android.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.profile.ProfileServiceManager
import no.nordicsemi.android.service.profile.ServiceApi
import no.nordicsemi.android.toolbox.lib.utils.Profile
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository
import no.nordicsemi.android.toolbox.profile.repository.channelSounding.ChannelSoundingManager
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import timber.log.Timber
import javax.inject.Inject
@@ -32,6 +34,7 @@ import javax.inject.Inject
@HiltViewModel
internal class ProfileViewModel @Inject constructor(
private val profileServiceManager: ProfileServiceManager,
private val channelSoundingManager: ChannelSoundingManager,
private val navigator: Navigator,
private val deviceRepository: DeviceRepository,
private val analytics: AppAnalytics,
@@ -122,6 +125,19 @@ internal class ProfileViewModel @Inject constructor(
fun onEvent(event: ConnectionEvent) {
when (event) {
ConnectionEvent.DisconnectEvent -> {
// if the profile is channel sounding then we need to stop the ranging session before disconnecting.
if (_uiState.value is ProfileUiState.Connected) {
val state = _uiState.value as ProfileUiState.Connected
if (state.deviceData.services.any { it.profile == Profile.CHANNEL_SOUNDING }) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.BAKLAVA) {
try {
channelSoundingManager.closeSession()
} catch (e: Exception) {
Timber.e(" ${e.message}")
}
}
}
}
serviceApi?.disconnect(address)
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="channel_sounding">Channel Sounding</string>
<string name="channel_sounding_not_supported">Channel Sounding is not supported on this Android version.</string>
<string name="initiating_ranging">Initiating ranging</string>
<string name="ranging_session_closed_with_reason"> Ranging session closed because of %s.</string>
<string name="ranging_session_closed">Ranging session closed.</string>
<string name="ranging_session_stopped">Ranging session stopped</string>
<string name="ranging_distance">Distance</string>
<string name="current_measurement">Current measurement</string>
<string name="ranging_distance_m">%.3f m</string>
<string name="signal_strength">Signal strength</string>
<string name="ranging_technology">Technology</string>
<string name="ranging_azimuth_measurement">Azimuth angle</string>
<string name="ranging_azimuth_measurement_deg">%.2f°</string>
<string name="ranging_elevation_measurement">Elevation angle</string>
<string name="update_rate">Update rate</string>
<string name="update_rate_frequent">Frequent</string>
<string name="update_rate_frequent_des">Updates every 100 milliseconds.</string>
<string name="update_rate_normal">Normal</string>
<string name="update_rate_normal_des">Updates every 200 milliseconds.</string>
<string name="update_rate_infrequent">Infrequent</string>
<string name="update_rate_infrequent_des">Updates every 5 seconds.</string>
<string name="ranging_tech_ble_cs">Bluetooth LE Channel Sounding</string>
<string name="ranging_tech_wifi_nan_rtt">Wi-Fi NAN RTT</string>
<string name="ranging_tech_wifi_sta_rtt">Wi-Fi STA RTT</string>
<string name="ranging_tech_uwb">UWB</string>
<string name="ranging_tech_ble_rssi">Bluetooth LE RSSI</string>
<string name="change_update_rate">Change Update Rate</string>
<string name="select_new_update_rate">Select a new ranging update frequency.</string>
<string name="update_rate_change_warning">Selecting a new rate will cancel the current session and start a new one.</string>
<string name="ranging_previous_measurement">Recent measurements</string>
<string name="ranging_details">Details</string>
<string name="update_rate_confirm">Confirm</string>
<string name="update_rate_cancel">Cancel</string>
</resources>

View File

@@ -1,7 +1,50 @@
package no.nordicsemi.android.toolbox.profile.data
import android.ranging.RangingData
import no.nordicsemi.android.toolbox.lib.utils.Profile
data class ChannelSoundingServiceData(
override val profile: Profile = Profile.CHANNEL_SOUNDING
override val profile: Profile = Profile.CHANNEL_SOUNDING,
val rangingSessionAction: RangingSessionAction? = null,
val updateRate: UpdateRate = UpdateRate.NORMAL,
val interval: Int = 1000,
) : ProfileServiceData()
sealed interface RangingSessionAction {
object OnStart : RangingSessionAction
data class OnResult(
val data: RangingData,
val previousData: List<Float> = emptyList()
) : RangingSessionAction
data class OnError(val reason: String) : RangingSessionAction
object OnClosed : RangingSessionAction
}
enum class UpdateRate {
NORMAL,
FREQUENT,
INFREQUENT;
}
enum class ConfidenceLevel(val value: Int) {
CONFIDENCE_HIGH(2),
CONFIDENCE_MEDIUM(1),
CONFIDENCE_LOW(0);
companion object {
fun from(value: Int): ConfidenceLevel? = entries.find { it.value == value }
}
}
enum class RangingTechnology(val value: Int) {
BLE_CS(1),
BLE_RSSI(3),
UWB(0),
WIFI_NAN_RTT(2),
WIFI_STA_RTT(4), ;
companion object {
fun from(value: Int): RangingTechnology? = entries.find { it.value == value }
}
}

View File

@@ -3,6 +3,7 @@ package no.nordicsemi.android.toolbox.profile.manager
import no.nordicsemi.android.toolbox.lib.utils.spec.BATTERY_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.BPS_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.CGMS_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.CHANNEL_SOUND_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.CSC_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.DF_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.GLS_SERVICE_UUID
@@ -31,7 +32,7 @@ object ServiceManagerFactory {
RSCS_SERVICE_UUID to ::RSCSManager,
THROUGHPUT_SERVICE_UUID to ::ThroughputManager,
UART_SERVICE_UUID to ::UARTManager,
// CHANNEL_SOUND_SERVICE_UUID to ::ChannelSoundingManager,
CHANNEL_SOUND_SERVICE_UUID to ::ChannelSoundingManager,
LBS_SERVICE_UUID to ::LBSManager,
// Add more service UUIDs to handler mappings as needed
).mapKeys { it.key.toKotlinUuid() }

View File

@@ -67,11 +67,10 @@ include(":lib_utils")
include(":profile")
include(":profile_data")
include(":profile_manager")
include(":permissions-ranging")
//if (file("../Android-Common-Libraries").exists()) {
// includeBuild("../Android-Common-Libraries")
//}
if (file("../Android-Common-Libraries").exists()) {
includeBuild("../Android-Common-Libraries")
}
//
//if (file("../Kotlin-BLE-Library").exists()) {
// includeBuild("../Kotlin-BLE-Library")