diff --git a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt index fa3d7b89..98014979 100644 --- a/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt +++ b/lib_service/src/main/java/no/nordicsemi/android/service/profile/ProfileService.kt @@ -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) diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/animate/AnimatedDistance.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/animate/AnimatedDistance.kt new file mode 100644 index 00000000..01832733 --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/animate/AnimatedDistance.kt @@ -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 + ) +} \ No newline at end of file diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt index 0ef4c454..8380a2ee 100644 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt +++ b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/repository/RangingStateManager.kt @@ -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 { diff --git a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt index e3f0ccab..231e7351 100644 --- a/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt +++ b/permissions-ranging/src/main/java/no/nordicsemi/android/permissions_ranging/utils/RangingPermissionUtils.kt @@ -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") } } \ No newline at end of file diff --git a/profile/build.gradle.kts b/profile/build.gradle.kts index 149b03ae..1401bdff 100644 --- a/profile/build.gradle.kts +++ b/profile/build.gradle.kts @@ -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) diff --git a/profile/src/main/AndroidManifest.xml b/profile/src/main/AndroidManifest.xml index 607518e4..2e8bc60a 100644 --- a/profile/src/main/AndroidManifest.xml +++ b/profile/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + 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() diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/ChannelSoundingManager.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/ChannelSoundingManager.kt index 9765e97f..9a9a0e8c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/ChannelSoundingManager.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/ChannelSoundingManager.kt @@ -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(null) + val rangingData = _rangingData.asStateFlow() + private val _previousRangingDataList = MutableStateFlow>(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 } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionCloseReason.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionCloseReason.kt index b2abeee9..aec2de18 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionCloseReason.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionCloseReason.kt @@ -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. + } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionFailedReason.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionFailedReason.kt index 71a1f0a3..d3bdf80e 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionFailedReason.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/repository/channelSounding/RangingSessionFailedReason.kt @@ -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. } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt index 1f4948cb..d07b17c4 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt @@ -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() + 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 = 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, +) { + // 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 + ) + } +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingUiMapper.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingUiMapper.kt new file mode 100644 index 00000000..d70da561 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingUiMapper.kt @@ -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 + } + +} + diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RangingChartView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RangingChartView.kt new file mode 100644 index 00000000..0600c356 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RangingChartView.kt @@ -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) +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RecentMeasurementChart.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RecentMeasurementChart.kt new file mode 100644 index 00000000..37765a40 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RecentMeasurementChart.kt @@ -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) { + 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 +): 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() + 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, 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 + ) + ) + } +} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RequestRangingPermission.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RequestRangingPermission.kt new file mode 100644 index 00000000..ddfe8717 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/RequestRangingPermission.kt @@ -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(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) + } +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/SignalStrengthBar.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/SignalStrengthBar.kt new file mode 100644 index 00000000..4ca9fea5 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/SignalStrengthBar.kt @@ -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) + ) + } + +} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/UpdateRateSettings.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/UpdateRateSettings.kt new file mode 100644 index 00000000..a525ba13 --- /dev/null +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/UpdateRateSettings.kt @@ -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 = {} + ) +} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ChannelSoundingViewModel.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ChannelSoundingViewModel.kt index 3c82e192..aa72ded7 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ChannelSoundingViewModel.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ChannelSoundingViewModel.kt @@ -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 + ) + } } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt index aff79eca..d0f50f7c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/ProfileViewModel.kt @@ -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) } diff --git a/profile/src/main/res/values/channelSoundingStrings.xml b/profile/src/main/res/values/channelSoundingStrings.xml new file mode 100644 index 00000000..09e0c59d --- /dev/null +++ b/profile/src/main/res/values/channelSoundingStrings.xml @@ -0,0 +1,44 @@ + + + Channel Sounding + + Channel Sounding is not supported on this Android version. + Initiating ranging + Ranging session closed because of %s. + Ranging session closed. + Ranging session stopped + + Distance + Current measurement + %.3f m + Signal strength + Technology + + Azimuth angle + %.2f° + Elevation angle + + Update rate + Frequent + Updates every 100 milliseconds. + Normal + Updates every 200 milliseconds. + Infrequent + Updates every 5 seconds. + + Bluetooth LE Channel Sounding + Wi-Fi NAN RTT + Wi-Fi STA RTT + UWB + Bluetooth LE RSSI + + Change Update Rate + Select a new ranging update frequency. + Selecting a new rate will cancel the current session and start a new one. + + Recent measurements + Details + + Confirm + Cancel + \ No newline at end of file diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ChannelSoundingServiceData.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ChannelSoundingServiceData.kt index 103cb51d..56ed6ad1 100644 --- a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ChannelSoundingServiceData.kt +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ChannelSoundingServiceData.kt @@ -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() \ No newline at end of file + 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 = 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 } + } +} diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt index 018e388b..77688cb0 100644 --- a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/ServiceManagerFactory.kt @@ -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() } diff --git a/settings.gradle.kts b/settings.gradle.kts index 888d18d7..04c93ffe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")