mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2025-12-18 06:54:24 +01:00
Implement ChannelSounding
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
44
profile/src/main/res/values/channelSoundingStrings.xml
Normal file
44
profile/src/main/res/values/channelSoundingStrings.xml
Normal 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>
|
||||
@@ -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
|
||||
) : ProfileServiceData()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user