From cf909def01668d3313682e972df41274c1f778a0 Mon Sep 17 00:00:00 2001 From: himalia416 Date: Fri, 10 Oct 2025 15:51:17 +0200 Subject: [PATCH] Handled data observation to avoid recomposition data loss --- .../channelSounding/ChannelSoundingManager.kt | 160 +++++++++++++++--- .../viewmodel/ChannelSoundingViewModel.kt | 34 ++-- .../profile/viewmodel/ProfileViewModel.kt | 2 +- 3 files changed, 143 insertions(+), 53 deletions(-) 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 e058b342..461c5500 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 @@ -22,10 +22,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import no.nordicsemi.android.toolbox.profile.data.ChannelSoundingServiceData import no.nordicsemi.android.toolbox.profile.data.RangingSessionAction import no.nordicsemi.android.toolbox.profile.data.RangingSessionFailedReason import no.nordicsemi.android.toolbox.profile.data.SessionClosedReason @@ -36,26 +38,26 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Singleton -@RequiresApi(Build.VERSION_CODES.BAKLAVA) @Singleton -class ChannelSoundingManager @Inject constructor( +internal class ChannelSoundingManager @Inject constructor( @param:ApplicationContext private val context: Context, ) { + @RequiresApi(Build.VERSION_CODES.BAKLAVA) 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 _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) { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) + ) // Unregister the callback to avoid memory leaks rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) // Cleanup previous data @@ -63,8 +65,10 @@ class ChannelSoundingManager @Inject constructor( } override fun onOpenFailed(reason: Int) { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(RangingSessionFailedReason.getReason(reason)) + ) // Unregister the callback to avoid memory leaks rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) // Cleanup previous data @@ -72,7 +76,10 @@ class ChannelSoundingManager @Inject constructor( } override fun onOpened() { - _rangingData.value = RangingSessionAction.OnStart + updateRangingData( + device, + RangingSessionAction.OnStart + ) } override fun onResults( @@ -84,9 +91,12 @@ class ChannelSoundingManager @Inject constructor( updatedList.add(it.toFloat()) } _previousRangingDataList.value = updatedList - _rangingData.value = RangingSessionAction.OnResult( - data = data.toCsRangingData(), - previousData = _previousRangingDataList.value + updateRangingData( + device, + RangingSessionAction.OnResult( + data = data.toCsRangingData(), + previousData = _previousRangingDataList.value + ) ) } @@ -94,7 +104,7 @@ class ChannelSoundingManager @Inject constructor( peer: RangingDevice, technology: Int ) { - _rangingData.value = RangingSessionAction.OnStart + updateRangingData(device, RangingSessionAction.OnStart) // Cleanup previous data _previousRangingDataList.value = emptyList() } @@ -103,20 +113,43 @@ class ChannelSoundingManager @Inject constructor( peer: RangingDevice, technology: Int ) { - _rangingData.value = RangingSessionAction.OnClosed + updateRangingData( + device, + RangingSessionAction.OnClosed + ) // Cleanup previous data _previousRangingDataList.value = emptyList() } } + /** + * Returns a [Flow] of [ChannelSoundingServiceData] for the given device ID. + * If no data exists for the device, a new [MutableStateFlow] with default [ChannelSoundingServiceData] is created. + */ + fun getData(deviceId: String): Flow { + return _dataMap.getOrPut(deviceId) { MutableStateFlow(ChannelSoundingServiceData()) } + } + + /** + * Adds a device to the ranging session and starts the session if not already active. + * If the session is already active, it continues the session. + * If the RangingManager is not available or permissions are missing, it updates the state with an error. + * Requires Android version Baklava (API 36) or higher. + * + * @param device The device address to add to the ranging session. + * @param updateRate The desired update rate for ranging measurements. Default is [UpdateRate.NORMAL]. + */ @RequiresApi(Build.VERSION_CODES.BAKLAVA) fun addDeviceToRangingSession( device: String, updateRate: UpdateRate = UpdateRate.NORMAL ) { + this.device = device if (rangingManager == null) { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(SessionClosedReason.RANGING_NOT_AVAILABLE) + ) return } // If session is already active then continue the session, otherwise create a new one @@ -181,31 +214,42 @@ class ChannelSoundingManager @Inject constructor( it.addDeviceToRangingSession(rawRangingDeviceConfig) } catch (e: Exception) { Timber.e("Failed to add device to ranging session: ${e.message}") - _rangingData.value = RangingSessionAction.OnClosed + updateRangingData( + device, + RangingSessionAction.OnClosed + ) } finally { it.start(rangingPreference) } } ?: run { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(SessionClosedReason.UNKNOWN) + ) return@RangingCapabilitiesCallback } } else { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError( SessionClosedReason.MISSING_PERMISSION ) + ) return@RangingCapabilitiesCallback } } else { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(SessionClosedReason.CS_SECURITY_NOT_AVAILABLE) - closeSession() + ) + closeSession(device) } } else { - _rangingData.value = + updateRangingData( + device, RangingSessionAction.OnError(SessionClosedReason.NOT_SUPPORTED) - closeSession() + ) + closeSession(device) } } @@ -216,11 +260,26 @@ class ChannelSoundingManager @Inject constructor( ) } + /** + * Closes the current ranging session if it exists. + * Waits for the session to stop before closing it and unregistering the capabilities callback. + * If onClosed is provided, it will be called after the session is closed. + * Requires Android version Baklava (API 36) or higher. + * + * @param deviceAddress The address of the device associated with the ranging session. + * @param onClosed An optional suspend function to be called after the session is closed. + */ @RequiresApi(Build.VERSION_CODES.BAKLAVA) - fun closeSession(onClosed: (suspend () -> Unit)? = null) { + fun closeSession( + deviceAddress: String, + onClosed: (suspend () -> Unit)? = null + ) { val session = rangingSession ?: return CoroutineScope(Dispatchers.IO).launch { try { + onClosed ?.let { + updateRangingData(deviceAddress, RangingSessionAction.OnRestarting) + } session.stop() // Wait for onStopped() or onClosed() before closing delay(1000) // Give the system time to propagate onStopped @@ -228,19 +287,29 @@ class ChannelSoundingManager @Inject constructor( session.close() rangingSession = null rangingManager?.unregisterCapabilitiesCallback(rangingCapabilityCallback) - _rangingData.value = null delay(1500) - onClosed?.let { - it() + onClosed?.let { it() } ?: run { + clear(deviceAddress) } } } catch (e: Exception) { Timber.e(e, "Error closing ranging session") - _rangingData.value = RangingSessionAction.OnError(SessionClosedReason.UNKNOWN) + updateRangingData( + device, + RangingSessionAction.OnError(SessionClosedReason.UNKNOWN) + ) } } } + /** + * Checks if the app has the RANGING permission. + * Requires Android version Baklava (API 36) or higher. + * + * @param context The context to use for checking permissions. + * @return True if the RANGING permission is granted, false otherwise. + */ + @RequiresApi(Build.VERSION_CODES.BAKLAVA) private fun hasRangingPermissions(context: Context): Boolean { return ContextCompat.checkSelfPermission( context, @@ -248,5 +317,40 @@ class ChannelSoundingManager @Inject constructor( ) == PackageManager.PERMISSION_GRANTED } + /** + * Clears the data associated with the given device ID. + * This removes the entry from the internal map, effectively resetting the state for that device. + * + * @param deviceId The ID of the device whose data should be cleared. + */ + fun clear(deviceId: String) = _dataMap.remove(deviceId) + + /** + * Updates the ranging session action for the specified device. + * + * @param deviceId The ID of the device to update. + * @param rangingData The new ranging session action to set. + */ + fun updateRangingData(deviceId: String, rangingData: RangingSessionAction) = + _dataMap[deviceId]?.update { it.copy(rangingSessionAction = rangingData) } + + /** + * Updates the ranging update rate for the specified device. + * + * @param address The ID of the device to update. + * @param frequency The new update rate to set. + */ + fun updateRangingRate(address: String, frequency: UpdateRate) = + _dataMap[address]?.update { it.copy(updateRate = frequency) } + + /** + * Updates the interval rate for the specified device. + * + * @param address The ID of the device to update. + * @param interval The new interval rate to set. + */ + fun updateIntervalRate(address: String, interval: Int) = + _dataMap[address]?.update { it.copy(interval = interval) } + } 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 f32bab8a..bbb4c690 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 @@ -17,7 +17,6 @@ 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 @@ -38,7 +37,6 @@ internal class ChannelSoundingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val channelSoundingManager: ChannelSoundingManager, ) : SimpleNavigationViewModel(navigator, savedStateHandle) { - // StateFlow to hold the selected temperature unit private val _channelSoundingServiceState = MutableStateFlow(ChannelSoundingServiceData()) val channelSoundingState = _channelSoundingServiceState.asStateFlow() @@ -76,24 +74,16 @@ internal class ChannelSoundingViewModel @Inject constructor( * Starts the Channel Sounding service and observes channel sounding profile data changes. */ private fun startChannelSounding(address: String, rate: UpdateRate = UpdateRate.NORMAL) { - ChannelSoundingRepository.getData(address).onEach { + channelSoundingManager.getData(address).onEach { _channelSoundingServiceState.value = _channelSoundingServiceState.value.copy( - profile = it.profile + profile = it.profile, + updateRate = it.updateRate, + rangingSessionAction = it.rangingSessionAction, ) }.launchIn(viewModelScope) - if (Build.VERSION.SDK_INT >= 36) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { 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}") } @@ -113,7 +103,7 @@ internal class ChannelSoundingViewModel @Inject constructor( try { viewModelScope.launch { if (_channelSoundingServiceState.value.updateRate != event.frequency) { - channelSoundingManager.closeSession { + channelSoundingManager.closeSession(address) { channelSoundingManager.addDeviceToRangingSession( address, event.frequency @@ -127,16 +117,12 @@ internal class ChannelSoundingViewModel @Inject constructor( } } // Update the update rate in the state - _channelSoundingServiceState.value = _channelSoundingServiceState.value.copy( - updateRate = event.frequency - ) + channelSoundingManager.updateRangingRate(address, event.frequency) + } is ChannelSoundingEvent.UpdateInterval -> { - // Update the interval in the state - _channelSoundingServiceState.value = _channelSoundingServiceState.value.copy( - interval = event.interval - ) + channelSoundingManager.updateIntervalRate(address, event.interval) } ChannelSoundingEvent.RestartRangingSession -> { @@ -144,7 +130,7 @@ internal class ChannelSoundingViewModel @Inject constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { try { viewModelScope.launch { - channelSoundingManager.closeSession { + channelSoundingManager.closeSession(address) { channelSoundingManager.addDeviceToRangingSession( address, _channelSoundingServiceState.value.updateRate 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 8117274c..1e853ac2 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 @@ -132,7 +132,7 @@ internal class ProfileViewModel @Inject constructor( 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.get().closeSession() + channelSoundingManager.get().closeSession(address) } catch (e: Exception) { Timber.e(" ${e.message}") }