diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt index 6440ce21b..9f9906ba6 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt @@ -187,7 +187,8 @@ internal fun DeviceConnectedView( when (serviceManager.profile) { Profile.HTS -> HTSScreen() Profile.CHANNEL_SOUNDING -> ChannelSoundingScreen( - isNotificationPermissionGranted + deviceId = state.deviceData.peripheral.address, + isNotificationPermissionGranted = isNotificationPermissionGranted ) Profile.BPS -> BPSScreen() 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 729f3cb1d..e7387374d 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 @@ -46,81 +46,59 @@ internal class ChannelSoundingManager @Inject constructor( private val rangingManager: RangingManager? = context.getSystemService(RangingManager::class.java) private val _dataMap = mutableMapOf>() - private var device: String = "" - private lateinit var rangingCapabilityCallback: RangingManager.RangingCapabilitiesCallback - private val _previousRangingDataList = MutableStateFlow>(emptyList()) - private var rangingSession: RangingSession? = null + private val _activeSessions = mutableMapOf() + private val _capabilityCallbacks = + mutableMapOf() + private val _previousRangingData = mutableMapOf>() - private val rangingSessionCallback = @RequiresApi(Build.VERSION_CODES.BAKLAVA) - object : RangingSession.Callback { - override fun onClosed(reason: Int) { - updateRangingData( - device, - RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) - ) - // Unregister the callback to avoid memory leaks - rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) - // Cleanup previous data - _previousRangingDataList.value = emptyList() - } - - override fun onOpenFailed(reason: Int) { - updateRangingData( - device, - RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) - ) - // Unregister the callback to avoid memory leaks - rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) - // Cleanup previous data - _previousRangingDataList.value = emptyList() - } + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private fun createRangingSessionCallback(deviceAddress: String) = + object : RangingSession.Callback { + override fun onClosed(reason: Int) { + updateRangingData( + deviceAddress, + RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) + ) + cleanUpDeviceSession(deviceAddress) + } - override fun onOpened() { - updateRangingData( - device, - RangingSessionAction.OnStart - ) - } + override fun onOpenFailed(reason: Int) { + updateRangingData( + deviceAddress, + RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) + ) + cleanUpDeviceSession(deviceAddress) + } - override fun onResults( - peer: RangingDevice, - data: RangingData - ) { - val updatedList = _previousRangingDataList.value.toMutableList() - data.distance?.measurement?.let { - updatedList.add(it.toFloat()) + override fun onOpened() { + updateRangingData(deviceAddress, RangingSessionAction.OnStart) } - _previousRangingDataList.value = updatedList - updateRangingData( - device, - RangingSessionAction.OnResult( - data = data.toCsRangingData(), - previousData = _previousRangingDataList.value + + override fun onResults(peer: RangingDevice, data: RangingData) { + val history = _previousRangingData.getOrPut(deviceAddress) { mutableListOf() } + data.distance?.measurement?.let { + history.add(it.toFloat()) + } + + updateRangingData( + deviceAddress, + RangingSessionAction.OnResult( + data = data.toCsRangingData(), + previousData = history.toList() + ) ) - ) - } + } - override fun onStarted( - peer: RangingDevice, - technology: Int - ) { - updateRangingData(device, RangingSessionAction.OnStart) - // Cleanup previous data - _previousRangingDataList.value = emptyList() - } + override fun onStarted(peer: RangingDevice, technology: Int) { + updateRangingData(deviceAddress, RangingSessionAction.OnStart) + _previousRangingData[deviceAddress]?.clear() + } - override fun onStopped( - peer: RangingDevice, - technology: Int - ) { - updateRangingData( - device, - RangingSessionAction.OnClosed - ) - // Cleanup previous data - _previousRangingDataList.value = emptyList() + override fun onStopped(peer: RangingDevice, technology: Int) { + updateRangingData(deviceAddress, RangingSessionAction.OnClosed) + _previousRangingData[deviceAddress]?.clear() + } } - } /** * Returns a [Flow] of [ChannelSoundingServiceData] for the given device ID. @@ -144,7 +122,6 @@ internal class ChannelSoundingManager @Inject constructor( device: String, updateRate: UpdateRate = UpdateRate.NORMAL ) { - this.device = device if (rangingManager == null) { updateRangingData( device, @@ -152,9 +129,9 @@ internal class ChannelSoundingManager @Inject constructor( ) return } - // If session is already active then continue the session, otherwise create a new one - if (rangingSession != null) { - Timber.w("Ranging session already active.") + // If session is already active for the device then continue the session, otherwise create a new one. + if (_activeSessions.containsKey(device)) { + Timber.w("Ranging session already active for device: $device") return } val setRangingUpdateRate = when (updateRate) { @@ -199,25 +176,23 @@ internal class ChannelSoundingManager @Inject constructor( .setSessionConfig(sessionConfig) .build() - rangingCapabilityCallback = RangingManager.RangingCapabilitiesCallback { capabilities -> + val 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( + + val session = rangingManager.createRangingSession( context.mainExecutor, - rangingSessionCallback + createRangingSessionCallback(device) // Dynamic device callback ) - rangingSession?.let { + + session?.let { try { + _activeSessions[device] = it it.addDeviceToRangingSession(rawRangingDeviceConfig) } catch (e: Exception) { Timber.e("Failed to add device to ranging session: ${e.message}") - updateRangingData( - device, - RangingSessionAction.OnClosed - ) + updateRangingData(device, RangingSessionAction.OnClosed) } finally { it.start(rangingPreference) } @@ -231,9 +206,7 @@ internal class ChannelSoundingManager @Inject constructor( } else { updateRangingData( device, - RangingSessionAction.OnError( - SessionClosedReason.MISSING_PERMISSION - ) + RangingSessionAction.OnError(SessionClosedReason.MISSING_PERMISSION) ) return@RangingCapabilitiesCallback } @@ -251,13 +224,10 @@ internal class ChannelSoundingManager @Inject constructor( ) closeSession(device) } - } - rangingManager.registerCapabilitiesCallback( - context.mainExecutor, - rangingCapabilityCallback - ) + _capabilityCallbacks[device] = rangingCapabilityCallback + rangingManager.registerCapabilitiesCallback(context.mainExecutor, rangingCapabilityCallback) } /** @@ -274,10 +244,10 @@ internal class ChannelSoundingManager @Inject constructor( deviceAddress: String, onClosed: (suspend () -> Unit)? = null ) { - val session = rangingSession ?: return + val session = _activeSessions[deviceAddress] ?: return CoroutineScope(Dispatchers.IO).launch { try { - onClosed ?.let { + onClosed?.let { updateRangingData(deviceAddress, RangingSessionAction.OnRestarting) } session.stop() @@ -285,23 +255,34 @@ internal class ChannelSoundingManager @Inject constructor( delay(1000) // Give the system time to propagate onStopped withContext(Dispatchers.Main) { session.close() - rangingSession = null - rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) + cleanUpDeviceSession(deviceAddress) delay(1500) onClosed?.let { it() } ?: run { clear(deviceAddress) } } } catch (e: Exception) { - Timber.e(e, "Error closing ranging session") + Timber.e(e, "Error closing ranging session for $deviceAddress") updateRangingData( - device, + deviceAddress, RangingSessionAction.OnError(SessionClosedReason.UNKNOWN) ) } } } + /** + * Shared helper to cleanly tear down session maps for a specific device. + */ + @RequiresApi(Build.VERSION_CODES.BAKLAVA) + private fun cleanUpDeviceSession(deviceAddress: String) { + _activeSessions.remove(deviceAddress) + _previousRangingData.remove(deviceAddress) + _capabilityCallbacks.remove(deviceAddress)?.let { callback -> + rangingManager?.unregisterCapabilitiesCallback(callback) + } + } + /** * Checks if the app has the RANGING permission. * Requires Android version Baklava (API 36) or higher. @@ -353,4 +334,3 @@ internal class ChannelSoundingManager @Inject constructor( _dataMap[address]?.update { it.copy(interval = interval) } } - 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 4f0a7e209..cfdd36c9b 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 @@ -57,19 +57,27 @@ import no.nordicsemi.android.ui.view.TextWithAnimatedDots import no.nordicsemi.android.ui.view.internal.LoadingView @Composable -internal fun ChannelSoundingScreen(isNotificationPermissionGranted: Boolean?) { +internal fun ChannelSoundingScreen( + deviceId: String, + isNotificationPermissionGranted: Boolean?, +) { // Channel Sounding is available from Android 16 (API 36) onward, while better accuracy and // performance are provided from Android 16 (API 36, minor version 1) and later. if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1 && isNotificationPermissionGranted != null) { RequestRangingPermission { val channelSoundingViewModel = hiltViewModel() - val channelSoundingState by channelSoundingViewModel.channelSoundingState.collectAsStateWithLifecycle() - val onClickEvent: (event: ChannelSoundingEvent) -> Unit = - { channelSoundingViewModel.onEvent(it) } + val channelSoundingMapState by channelSoundingViewModel.channelSoundingState.collectAsStateWithLifecycle() + val channelSoundingState = + channelSoundingMapState[deviceId] ?: ChannelSoundingServiceData() + + val onClickEvent: (event: ChannelSoundingEvent) -> Unit = { + channelSoundingViewModel.onEvent(it) + } + ChannelSoundingView(channelSoundingState, onClickEvent) } } else if (Build.VERSION.SDK_INT_FULL == Build.VERSION_CODES_FULL.BAKLAVA && isNotificationPermissionGranted != null) { - // It supports the Channel Sounding but we are intentionally not enabling it because of the accuracy and performance issues. + // It supports the Channel Sounding, but we are intentionally not enabling it because of the accuracy and performance issues. ChannelSoundingNotEnabledView() } else { ChannelSoundingNotSupportedView() 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 dedb61bd9..b7d0b5b3f 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 @@ -4,6 +4,7 @@ import android.os.Build import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter @@ -37,10 +38,14 @@ internal class ChannelSoundingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val channelSoundingManager: ChannelSoundingManager, ) : SimpleNavigationViewModel(navigator, savedStateHandle) { - private val _channelSoundingServiceState = MutableStateFlow(ChannelSoundingServiceData()) - val channelSoundingState = _channelSoundingServiceState.asStateFlow() + private val _channelSoundingServiceState = + MutableStateFlow>(emptyMap()) + + val channelSoundingState = + _channelSoundingServiceState.asStateFlow() private val address = parameterOf(ProfileDestinationId) + private val collectionJobs = mutableMapOf() init { observeChannelSoundingProfile() @@ -50,7 +55,6 @@ internal class ChannelSoundingViewModel @Inject constructor( * Observes the [DeviceRepository.profileHandlerFlow] from the [deviceRepository] that contains [Profile.CHANNEL_SOUNDING]. */ private fun observeChannelSoundingProfile() = viewModelScope.launch { - // update state or emit to UI deviceRepository.profileHandlerFlow .onEach { mapOfPeripheralProfiles -> mapOfPeripheralProfiles.forEach { (peripheral, profiles) -> @@ -70,20 +74,42 @@ internal class ChannelSoundingViewModel @Inject constructor( }.launchIn(this) } + /** + * Ensures we listen to the data updates for a specific device exactly once. + */ + private fun observeDeviceData(deviceAddress: String) { + if (collectionJobs.containsKey(deviceAddress)) return // Already observing this device + + collectionJobs[deviceAddress] = channelSoundingManager.getData(deviceAddress) + .onEach { incomingData -> + val currentMap = _channelSoundingServiceState.value + val existingData = currentMap[deviceAddress] ?: ChannelSoundingServiceData() + val updatedData = existingData.copy( + profile = incomingData.profile, + updateRate = incomingData.updateRate, + rangingSessionAction = incomingData.rangingSessionAction, + ) + _channelSoundingServiceState.value = currentMap + (deviceAddress to updatedData) + } + .launchIn(viewModelScope) + } + /** * Starts the Channel Sounding service and observes channel sounding profile data changes. */ - private fun startChannelSounding(address: String, rate: UpdateRate = UpdateRate.NORMAL) { - channelSoundingManager.getData(address).onEach { - _channelSoundingServiceState.value = _channelSoundingServiceState.value.copy( - profile = it.profile, - updateRate = it.updateRate, - rangingSessionAction = it.rangingSessionAction, - ) - }.launchIn(viewModelScope) + private fun startChannelSounding(deviceAddress: String, rate: UpdateRate = UpdateRate.NORMAL) { + observeDeviceData(deviceAddress) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { try { - channelSoundingManager.startRangingMeasurement(address, rate) + val currentDeviceData = _channelSoundingServiceState.value[deviceAddress] + if (currentDeviceData != null && currentDeviceData.updateRate != rate) { + channelSoundingManager.closeSession(deviceAddress) { + channelSoundingManager.startRangingMeasurement(deviceAddress, rate) + } + } else { + channelSoundingManager.startRangingMeasurement(deviceAddress, rate) + } } catch (e: Exception) { Timber.e("${e.message}") } @@ -96,55 +122,50 @@ internal class ChannelSoundingViewModel @Inject constructor( * Handles events related to the Channel Sounding profile. */ fun onEvent(event: ChannelSoundingEvent) { + val targetAddress = address + when (event) { is ChannelSoundingEvent.RangingUpdateRate -> { - // Stop the current session and start a new one with the updated rate + channelSoundingManager.updateRangingRate(targetAddress, event.frequency) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { try { - viewModelScope.launch { - if (_channelSoundingServiceState.value.updateRate != event.frequency) { - channelSoundingManager.closeSession(address) { - channelSoundingManager.startRangingMeasurement( - address, - event.frequency - ) - } + val currentDeviceData = _channelSoundingServiceState.value[targetAddress] + if (currentDeviceData?.updateRate != event.frequency) { + channelSoundingManager.closeSession(targetAddress) { + channelSoundingManager.startRangingMeasurement( + targetAddress, + event.frequency + ) } } - } catch (e: Exception) { Timber.e("Error closing session: ${e.message}") } } - // Update the update rate in the state - channelSoundingManager.updateRangingRate(address, event.frequency) - } is ChannelSoundingEvent.UpdateInterval -> { - channelSoundingManager.updateIntervalRate(address, event.interval) + channelSoundingManager.updateIntervalRate(targetAddress, event.interval) } ChannelSoundingEvent.RestartRangingSession -> { // Restart the ranging session with the current update rate if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { try { - viewModelScope.launch { - channelSoundingManager.closeSession(address) { - channelSoundingManager.startRangingMeasurement( - address, - _channelSoundingServiceState.value.updateRate - ) - } + channelSoundingManager.closeSession(targetAddress) { + val deviceData = _channelSoundingServiceState.value[targetAddress] + val targetRate = deviceData?.updateRate ?: UpdateRate.NORMAL + channelSoundingManager.startRangingMeasurement( + targetAddress, + targetRate + ) } - } catch (e: Exception) { Timber.e("Error closing session: ${e.message}") } } - } } } - } \ No newline at end of file diff --git a/profile/src/main/res/values/channelSoundingStrings.xml b/profile/src/main/res/values/channelSoundingStrings.xml index 5a19be17a..68c83fced 100644 --- a/profile/src/main/res/values/channelSoundingStrings.xml +++ b/profile/src/main/res/values/channelSoundingStrings.xml @@ -43,7 +43,7 @@ Missing permissions. Ranging service not available. Channel Sounding with required security level is not supported. - Oops! \nThat session disappeared faster than your last Tinder match. Try reconnecting… + Oops! \nSession disappeared. Try reconnecting… Session terminated by local request. Session terminated by remote peer. Peer terminated the session.