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 manager: ServiceManager
) { ) {
try { try {
if (service.uuid == CGMS_SERVICE_UUID.toKotlinUuid()) if (service.uuid == CGMS_SERVICE_UUID.toKotlinUuid()) {
peripheral.ensureBonded() peripheral.ensureBonded()
}
manager.observeServiceInteractions(peripheral.address, service, lifecycleScope) manager.observeServiceInteractions(peripheral.address, service, lifecycleScope)
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("ObserveServices").e(e) 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 @Singleton
internal class RangingStateManager @Inject constructor( internal class RangingStateManager @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val dataProvider = LocalDataProvider(context) private val dataProvider = LocalDataProvider(context)
private val utils = RangingPermissionUtils(context, dataProvider) private val utils = RangingPermissionUtils(context, dataProvider)
@@ -66,7 +66,11 @@ internal class RangingStateManager @Inject constructor(
} }
fun isRangingPermissionDenied(): Boolean { fun isRangingPermissionDenied(): Boolean {
return utils.isRangingPermissionDenied() return try {
utils.isRangingPermissionDenied()
} catch (_: Exception) {
false
}
} }
private fun getRangingPermissionState(): RangingPermissionState { private fun getRangingPermissionState(): RangingPermissionState {

View File

@@ -30,7 +30,7 @@ internal class RangingPermissionUtils(
dataProvider.isRangingPermissionRequested && // Ranging permission was requested. dataProvider.isRangingPermissionRequested && // Ranging permission was requested.
!isRangingPermissionGranted // Ranging permission is not granted !isRangingPermissionGranted // Ranging permission is not granted
&& !context.findActivity() && !context.findActivity()
.shouldShowRequestPermissionRationale(Manifest.permission.RANGING) ?.shouldShowRequestPermissionRationale(Manifest.permission.RANGING)!!
} }
@@ -42,12 +42,16 @@ internal class RangingPermissionUtils(
* @throws IllegalStateException if no activity was found. * @throws IllegalStateException if no activity was found.
* @return the activity. * @return the activity.
*/ */
private fun Context.findActivity(): Activity { private fun Context.findActivity(): Activity? {
var context = this return try {
while (context is ContextWrapper) { var context = this
if (context is Activity) return context while (context is ContextWrapper) {
context = context.baseContext 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")) api(project(":lib_service"))
implementation(project(":profile_manager")) implementation(project(":profile_manager"))
implementation(project(":lib_storage")) implementation(project(":lib_storage"))
implementation(project(":permissions-ranging"))
implementation(libs.nordic.core) implementation(libs.nordic.core)
implementation(libs.nordic.navigation) implementation(libs.nordic.navigation)

View File

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

View File

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

View File

@@ -1,51 +1,87 @@
package no.nordicsemi.android.toolbox.profile.repository.channelSounding package no.nordicsemi.android.toolbox.profile.repository.channelSounding
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.ranging.RangingData import android.ranging.RangingData
import android.ranging.RangingDevice import android.ranging.RangingDevice
import android.ranging.RangingManager import android.ranging.RangingManager
import android.ranging.RangingPreference 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.RangingSession
import android.ranging.SensorFusionParams import android.ranging.SensorFusionParams
import android.ranging.SessionConfig import android.ranging.SessionConfig
import android.ranging.ble.cs.BleCsRangingCapabilities
import android.ranging.ble.cs.BleCsRangingParams import android.ranging.ble.cs.BleCsRangingParams
import android.ranging.raw.RawRangingDevice import android.ranging.raw.RawRangingDevice
import android.ranging.raw.RawResponderRangingConfig import android.ranging.raw.RawResponderRangingConfig
import androidx.annotation.RequiresApi 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 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 var rangingSession: RangingSession? = null
private val rangingSessionCallback = @RequiresApi(Build.VERSION_CODES.BAKLAVA) private val rangingSessionCallback = @RequiresApi(Build.VERSION_CODES.BAKLAVA)
object : RangingSession.Callback { object : RangingSession.Callback {
override fun onClosed(reason: Int) { 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) { 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() { override fun onOpened() {
Timber.d("Opened successfully.") _rangingData.value = RangingSessionAction.OnStart
} }
override fun onResults( override fun onResults(
peer: RangingDevice, peer: RangingDevice,
data: RangingData data: RangingData
) { ) {
val measurement = data.distance?.measurement val updatedList = _previousRangingDataList.value.toMutableList()
val confidence = data.distance?.confidence data.distance?.measurement?.let {
Timber.d("RangingTechnology: ${data.rangingTechnology}") updatedList.add(it.toFloat())
Timber.d( }
"Azimuth: ${data.azimuth}\televation: " + _previousRangingDataList.value = updatedList
"${data.elevation}\tpeer: ${peer.uuid} distance ${data.distance}\t" + _rangingData.value = RangingSessionAction.OnResult(
" rssi: ${data.rssi} \tmeasurement: $measurement\tconfidence: $confidence" data = data,
previousData = _previousRangingDataList.value
) )
} }
@@ -53,55 +89,42 @@ object ChannelSoundingManager {
peer: RangingDevice, peer: RangingDevice,
technology: Int technology: Int
) { ) {
Timber.d( _rangingData.value = RangingSessionAction.OnStart
"Session started with peer: ${peer.uuid}, \ntechnology: ${getTechnology(technology)}" // Cleanup previous data
) _previousRangingDataList.value = emptyList()
} }
override fun onStopped( override fun onStopped(
peer: RangingDevice, peer: RangingDevice,
technology: Int 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) @RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun addDeviceToRangingSession( 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) { if (rangingManager == null) {
// RangingManager is not supported on this device _rangingData.value = RangingSessionAction.OnError("RangingManager is not available")
return return
} }
val rangingCapabilityCallback = RangingManager.RangingCapabilitiesCallback { capabilities -> val setRangingUpdateRate = when (updateRate) {
if (capabilities.csCapabilities != null) { UpdateRate.FREQUENT -> RawRangingDevice.UPDATE_RATE_FREQUENT
capabilities.csCapabilities!!.supportedSecurityLevels UpdateRate.NORMAL -> RawRangingDevice.UPDATE_RATE_NORMAL
.find { it == 1 } UpdateRate.INFREQUENT -> RawRangingDevice.UPDATE_RATE_INFREQUENT
?.let {
Timber.d("Channel Sounding supported.")
}
} else {
Timber.d("Channel Sounding Capabilities is not supported")
}
} }
rangingManager.registerCapabilitiesCallback(
context.mainExecutor,
rangingCapabilityCallback
)
val rangingDevice = RangingDevice.Builder() val rangingDevice = RangingDevice.Builder()
.build() .build()
val csRangingParams = BleCsRangingParams.Builder(device) val csRangingParams = BleCsRangingParams
.Builder(device)
.setRangingUpdateRate(setRangingUpdateRate)
.setSecurityLevel(BleCsRangingCapabilities.CS_SECURITY_LEVEL_ONE)
.build() .build()
val rawRangingDevice = RawRangingDevice.Builder() val rawRangingDevice = RawRangingDevice.Builder()
@@ -114,7 +137,7 @@ object ChannelSoundingManager {
.build() .build()
val rangingPreference = RangingPreference.Builder( val rangingPreference = RangingPreference.Builder(
DEVICE_ROLE_RESPONDER, DEVICE_ROLE_INITIATOR,
rawRangingDeviceConfig rawRangingDeviceConfig
) )
.setSessionConfig( .setSessionConfig(
@@ -123,21 +146,88 @@ object ChannelSoundingManager {
.setAngleOfArrivalNeeded(true) .setAngleOfArrivalNeeded(true)
.setSensorFusionParams( .setSensorFusionParams(
SensorFusionParams.Builder() SensorFusionParams.Builder()
.setSensorFusionEnabled(false) .setSensorFusionEnabled(true)
.build() .build()
) )
.build() .build()
) )
.build() .build()
rangingSession = rangingManager.createRangingSession( rangingCapabilityCallback = RangingManager.RangingCapabilitiesCallback { capabilities ->
context.mainExecutor, if (capabilities.csCapabilities != null) {
rangingSessionCallback if (capabilities.csCapabilities!!.supportedSecurityLevels.contains(1)) {
) // Channel Sounding supported
rangingSession?.let { // Check if Ranging Permission is granted before starting the session
it.addDeviceToRangingSession(rawRangingDeviceConfig) if (hasRangingPermissions(context)) {
it.start(rangingPreference) 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), ; REASON_NO_PEERS_FOUND(5), ;
override fun toString(): String { override fun toString(): String {
return when (reason) { return when (this) {
REASON_UNKNOWN.reason -> "Unknown" REASON_UNKNOWN -> ""
REASON_LOCAL_REQUEST.reason -> "Local request" REASON_LOCAL_REQUEST -> "local request" // Indicates that the session was closed because AutoCloseable.close() or RangingSession.stop() was called.
REASON_NO_PEERS_FOUND.reason -> "No peers found" REASON_REMOTE_REQUEST -> "request of a remote peer" // Indicates that the session was closed at the request of a remote peer.
REASON_REMOTE_REQUEST.reason -> "Remote request" REASON_UNSUPPORTED -> "provided session parameters were not supported"
REASON_SYSTEM_POLICY.reason -> "System policy" 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_UNSUPPORTED.reason -> "Unsupported" 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.
else -> "Unknown reason"
} }
} }

View File

@@ -10,12 +10,12 @@ enum class RangingSessionFailedReason(val reason: Int) {
override fun toString(): String { override fun toString(): String {
return when (this) { return when (this) {
UNKNOWN -> "Unknown" UNKNOWN -> ""
LOCAL_REQUEST -> "Local request" // Indicates that the session was closed because AutoCloseable.close() or RangingSession.stop() was called. 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. REMOTE_REQUEST -> "request of a remote peer" // 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. UNSUPPORTED -> "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. 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 -> "No peers found" // Indicates that the session was closed because none of the specified peers were found. 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 package no.nordicsemi.android.toolbox.profile.view.channelSounding
import android.content.pm.PackageManager
import android.os.Build 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.SocialDistance 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel
import no.nordicsemi.android.permissions_ranging.RequestRangingPermission 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.SectionTitle
import no.nordicsemi.android.ui.view.TextWithAnimatedDots
import no.nordicsemi.android.ui.view.internal.LoadingView
@Composable @Composable
internal fun ChannelSoundingScreen() { internal fun ChannelSoundingScreen(isNotificationPermissionGranted: Boolean?) {
RequestRangingPermission {
Column( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && isNotificationPermissionGranted != null) {
verticalArrangement = Arrangement.spacedBy(16.dp), RequestRangingPermission {
horizontalAlignment = Alignment.CenterHorizontally, val channelSoundingViewModel = hiltViewModel<ChannelSoundingViewModel>()
modifier = Modifier val channelSoundingState by channelSoundingViewModel.channelSoundingState.collectAsStateWithLifecycle()
.fillMaxWidth() val onClickEvent: (event: ChannelSoundingEvent) -> Unit =
.fillMaxSize() { channelSoundingViewModel.onEvent(it) }
.padding(16.dp) 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( SectionTitle(
icon = Icons.Default.SocialDistance, icon = Icons.Default.SocialDistance,
title = "Channel Sounding", title = stringResource(R.string.channel_sounding),
) )
val context = LocalContext.current }
val rangingPermissionStatusMessage = Column(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { modifier = Modifier
if (ContextCompat.checkSelfPermission( .fillMaxWidth()
context, .padding(16.dp),
"android.permission.RANGING" horizontalAlignment = Alignment.CenterHorizontally,
) == PackageManager.PERMISSION_GRANTED ) {
) { TextWithAnimatedDots(
"Ranging permission is granted" text = stringResource(R.string.initiating_ranging),
} else { )
"Ranging permission is not granted" }
} }
} else { }
"Channel Sounding Service is not available on this Android version."
}
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(
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 package no.nordicsemi.android.toolbox.profile.viewmodel
import android.content.Context
import android.os.Build import android.os.Build
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.nordicsemi.android.common.navigation.Navigator import no.nordicsemi.android.common.navigation.Navigator
import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel 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.lib.utils.Profile
import no.nordicsemi.android.toolbox.profile.ProfileDestinationId import no.nordicsemi.android.toolbox.profile.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.data.ChannelSoundingServiceData 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.DeviceRepository
import no.nordicsemi.android.toolbox.profile.repository.channelSounding.ChannelSoundingManager import no.nordicsemi.android.toolbox.profile.repository.channelSounding.ChannelSoundingManager
import no.nordicsemi.kotlin.ble.core.BondState
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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 @HiltViewModel
internal class ChannelSoundingViewModel @Inject constructor( internal class ChannelSoundingViewModel @Inject constructor(
private val deviceRepository: DeviceRepository, private val deviceRepository: DeviceRepository,
@param:ApplicationContext private val context: Context,
navigator: Navigator, navigator: Navigator,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val channelSoundingManager: ChannelSoundingManager,
) : SimpleNavigationViewModel(navigator, savedStateHandle) { ) : SimpleNavigationViewModel(navigator, savedStateHandle) {
// StateFlow to hold the selected temperature unit // StateFlow to hold the selected temperature unit
private val _channelSoundingServiceState = MutableStateFlow(ChannelSoundingServiceData()) private val _channelSoundingServiceState = MutableStateFlow(ChannelSoundingServiceData())
@@ -50,7 +58,13 @@ internal class ChannelSoundingViewModel @Inject constructor(
if (peripheral.address == address) { if (peripheral.address == address) {
profiles.filter { it.profile == Profile.CHANNEL_SOUNDING } profiles.filter { it.profile == Profile.CHANNEL_SOUNDING }
.forEach { _ -> .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. * 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 { ChannelSoundingRepository.getData(address).onEach {
_channelSoundingServiceState.value = _channelSoundingServiceState.value.copy( _channelSoundingServiceState.value = _channelSoundingServiceState.value.copy(
profile = it.profile profile = it.profile
) )
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
if (Build.VERSION.SDK_INT >= 36) { if (Build.VERSION.SDK_INT >= 36) {
viewModelScope.launch { try {
try { channelSoundingManager.addDeviceToRangingSession(address, rate)
ChannelSoundingManager.addDeviceToRangingSession(context, address) channelSoundingManager.rangingData
} catch (e: Exception) { .filter { it != null }
Timber.e(" ${e.message}") .onEach {
} it?.let { data ->
_channelSoundingServiceState.value =
_channelSoundingServiceState.value.copy(
rangingSessionAction = data,
)
}
}.launchIn(viewModelScope)
} catch (e: Exception) {
Timber.e("${e.message}")
} }
} else { } else {
Timber.tag("Channel_Sounding") Timber.d("Channel Sounding is not available in this Android version.")
.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.log.timber.nRFLoggerTree
import no.nordicsemi.android.service.profile.ProfileServiceManager import no.nordicsemi.android.service.profile.ProfileServiceManager
import no.nordicsemi.android.service.profile.ServiceApi 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.ProfileDestinationId
import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.R
import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository 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 no.nordicsemi.kotlin.ble.client.android.Peripheral
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -32,6 +34,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class ProfileViewModel @Inject constructor( internal class ProfileViewModel @Inject constructor(
private val profileServiceManager: ProfileServiceManager, private val profileServiceManager: ProfileServiceManager,
private val channelSoundingManager: ChannelSoundingManager,
private val navigator: Navigator, private val navigator: Navigator,
private val deviceRepository: DeviceRepository, private val deviceRepository: DeviceRepository,
private val analytics: AppAnalytics, private val analytics: AppAnalytics,
@@ -122,6 +125,19 @@ internal class ProfileViewModel @Inject constructor(
fun onEvent(event: ConnectionEvent) { fun onEvent(event: ConnectionEvent) {
when (event) { when (event) {
ConnectionEvent.DisconnectEvent -> { 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) 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 package no.nordicsemi.android.toolbox.profile.data
import android.ranging.RangingData
import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.lib.utils.Profile
data class ChannelSoundingServiceData( data class ChannelSoundingServiceData(
override val profile: Profile = Profile.CHANNEL_SOUNDING override val profile: Profile = Profile.CHANNEL_SOUNDING,
) : ProfileServiceData() 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.BATTERY_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.BPS_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.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.CSC_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.DF_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.DF_SERVICE_UUID
import no.nordicsemi.android.toolbox.lib.utils.spec.GLS_SERVICE_UUID import no.nordicsemi.android.toolbox.lib.utils.spec.GLS_SERVICE_UUID
@@ -31,7 +32,7 @@ object ServiceManagerFactory {
RSCS_SERVICE_UUID to ::RSCSManager, RSCS_SERVICE_UUID to ::RSCSManager,
THROUGHPUT_SERVICE_UUID to ::ThroughputManager, THROUGHPUT_SERVICE_UUID to ::ThroughputManager,
UART_SERVICE_UUID to ::UARTManager, UART_SERVICE_UUID to ::UARTManager,
// CHANNEL_SOUND_SERVICE_UUID to ::ChannelSoundingManager, CHANNEL_SOUND_SERVICE_UUID to ::ChannelSoundingManager,
LBS_SERVICE_UUID to ::LBSManager, LBS_SERVICE_UUID to ::LBSManager,
// Add more service UUIDs to handler mappings as needed // Add more service UUIDs to handler mappings as needed
).mapKeys { it.key.toKotlinUuid() } ).mapKeys { it.key.toKotlinUuid() }

View File

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