mirror of
https://github.com/aljazceru/Android-nRF-Toolbox.git
synced 2026-01-15 20:54:27 +01:00
Handled data observation to avoid recomposition data loss
This commit is contained in:
@@ -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<String, MutableStateFlow<ChannelSoundingServiceData>>()
|
||||
private var device: String = ""
|
||||
private lateinit var rangingCapabilityCallback: RangingManager.RangingCapabilitiesCallback
|
||||
|
||||
private val _rangingData = MutableStateFlow<RangingSessionAction?>(null)
|
||||
val rangingData = _rangingData.asStateFlow()
|
||||
private val _previousRangingDataList = MutableStateFlow<List<Float>>(emptyList())
|
||||
|
||||
private var rangingSession: RangingSession? = null
|
||||
|
||||
private val rangingSessionCallback = @RequiresApi(Build.VERSION_CODES.BAKLAVA)
|
||||
object : RangingSession.Callback {
|
||||
override fun onClosed(reason: Int) {
|
||||
_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<ChannelSoundingServiceData> {
|
||||
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) }
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user