@@ -19,7 +19,9 @@ package org.meshtastic.feature.settings.radio
1919import androidx.lifecycle.ViewModel
2020import androidx.lifecycle.viewModelScope
2121import co.touchlab.kermit.Logger
22+ import kotlinx.coroutines.CancellationException
2223import kotlinx.coroutines.Job
24+ import kotlinx.coroutines.NonCancellable
2325import kotlinx.coroutines.delay
2426import kotlinx.coroutines.flow.MutableStateFlow
2527import kotlinx.coroutines.flow.StateFlow
@@ -33,6 +35,9 @@ import kotlinx.coroutines.flow.map
3335import kotlinx.coroutines.flow.onEach
3436import kotlinx.coroutines.flow.update
3537import kotlinx.coroutines.launch
38+ import kotlinx.coroutines.sync.Mutex
39+ import kotlinx.coroutines.sync.withLock
40+ import kotlinx.coroutines.withContext
3641import org.jetbrains.compose.resources.StringResource
3742import org.koin.core.annotation.InjectedParam
3843import org.koin.core.annotation.KoinViewModel
@@ -76,6 +81,7 @@ import org.meshtastic.core.resources.key_backup_restore_failed
7681import org.meshtastic.core.resources.key_backup_restored
7782import org.meshtastic.core.resources.key_backup_saved
7883import org.meshtastic.core.resources.timeout
84+ import org.meshtastic.core.resources.unknown_error
7985import org.meshtastic.core.ui.util.SnackbarManager
8086import org.meshtastic.core.ui.util.getChannelList
8187import org.meshtastic.core.ui.viewmodel.safeLaunch
@@ -98,8 +104,11 @@ import org.meshtastic.proto.LocalModuleConfig
98104import org.meshtastic.proto.MeshPacket
99105import org.meshtastic.proto.ModuleConfig
100106import org.meshtastic.proto.User
107+ import kotlin.time.Duration
101108import kotlin.time.Duration.Companion.seconds
102109
110+ internal val MANUAL_CHANNEL_WRITE_DELAY : Duration = 1 .seconds
111+
103112/* * Data class that represents the current RadioConfig state. */
104113data class RadioConfigState (
105114 val isLocal : Boolean = false ,
@@ -218,6 +227,9 @@ open class RadioConfigViewModel(
218227 val mqttProbeStatus: StateFlow <MqttProbeStatus ?> = _mqttProbeStatus .asStateFlow()
219228
220229 private var probeJob: Job ? = null
230+ private val channelUpdateMutex = Mutex ()
231+ private var manualChannelBatchEnqueueing = false
232+ private val manualChannelBatchRequestIds = mutableSetOf<Int >()
221233
222234 /* *
223235 * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting
@@ -249,6 +261,7 @@ open class RadioConfigViewModel(
249261 get() = _destNode
250262
251263 private val requestIds = MutableStateFlow (hashSetOf<Int >())
264+ private val requestTimeoutJobs = mutableMapOf<Int , Job >()
252265 private val _radioConfigState = MutableStateFlow (RadioConfigState ())
253266 val radioConfigState: StateFlow <RadioConfigState > = _radioConfigState
254267
@@ -401,22 +414,83 @@ open class RadioConfigViewModel(
401414 }
402415 }
403416
417+ @Suppress(" TooGenericExceptionCaught" )
404418 fun updateChannels (new : List <ChannelSettings >, old : List <ChannelSettings >) {
405419 val destNum = destNum ? : destNode.value?.num ? : return
406- getChannelList(new, old).forEach { channel ->
407- safeLaunch(tag = " setRemoteChannel" ) {
408- val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
409- registerRequestId(packetId)
420+
421+ safeLaunch(tag = " setRemoteChannels" ) {
422+ // Manual channel saves are an ordered batch: only update canonical local state after every write request is
423+ // enqueued. Serialize batches so two ordered write plans cannot interleave on the radio link, and diff
424+ // each queued save against the canonical list at the moment it starts.
425+ channelUpdateMutex.withLock {
426+ val current = radioConfigState.value.channelList.ifEmpty { old }
427+ val updatePlan = getManualChannelUpdatePlan(new, current)
428+ if (updatePlan.isEmpty()) return @withLock
429+ if (! beginManualChannelBatch(updatePlan.size)) return @withLock
430+ val batchRequestIds = mutableSetOf<Int >()
431+
432+ try {
433+ applyManualChannelUpdatePlan(
434+ updatePlan = updatePlan,
435+ currentSettings = current,
436+ finalSettings = new,
437+ writeChannel = { channel -> radioConfigUseCase.setRemoteChannel(destNum, channel) },
438+ registerRequestId = { packetId ->
439+ batchRequestIds.add(packetId)
440+ registerManualChannelBatchRequestId(packetId)
441+ },
442+ onInterrupted = { result ->
443+ reconcileInterruptedManualChannelUpdate(
444+ destNum = destNum,
445+ oldSettings = current,
446+ appliedSettings = result.appliedSettings,
447+ )
448+ },
449+ )
450+ commitManualChannelSettings(destNum = destNum, oldSettings = current, newSettings = new)
451+ finishManualChannelBatch()
452+ } catch (e: CancellationException ) {
453+ abortManualChannelBatch(batchRequestIds)
454+ throw e
455+ } catch (e: Throwable ) {
456+ abortManualChannelBatch(batchRequestIds)
457+ Logger .w(e) { " Manual channel update failed after enqueue" }
458+ e.message?.let (::sendError) ? : sendError(Res .string.unknown_error)
459+ }
460+ }
461+ }
462+ }
463+
464+ private fun getManualChannelUpdatePlan (new : List <ChannelSettings >, old : List <ChannelSettings >): List <Channel > =
465+ getChannelList(new, old).sortedBy { it.index }
466+
467+ private suspend fun commitManualChannelSettings (
468+ destNum : Int ,
469+ oldSettings : List <ChannelSettings >,
470+ newSettings : List <ChannelSettings >,
471+ ) {
472+ withContext(NonCancellable ) {
473+ if (destNum == myNodeNum) {
474+ packetRepository.migrateChannelsByPSK(oldSettings, newSettings)
475+ radioConfigRepository.replaceAllSettings(newSettings)
410476 }
477+ _radioConfigState .update { it.copy(channelList = newSettings) }
411478 }
479+ }
412480
413- if (destNum == myNodeNum) {
414- safeLaunch(tag = " migrateChannels" ) {
415- packetRepository.migrateChannelsByPSK(old, new)
416- radioConfigRepository.replaceAllSettings(new)
481+ private suspend fun reconcileInterruptedManualChannelUpdate (
482+ destNum : Int ,
483+ oldSettings : List <ChannelSettings >,
484+ appliedSettings : List <ChannelSettings >,
485+ ) {
486+ withContext(NonCancellable ) {
487+ Logger .w { " Reconciling interrupted manual channel update appliedSettings=${appliedSettings.size} " }
488+ if (destNum == myNodeNum) {
489+ packetRepository.migrateChannelsByPSK(oldSettings, appliedSettings)
490+ radioConfigRepository.replaceAllSettings(appliedSettings)
417491 }
492+ _radioConfigState .update { it.copy(channelList = appliedSettings) }
418493 }
419- _radioConfigState .update { it.copy(channelList = new) }
420494 }
421495
422496 fun setConfig (config : Config ) {
@@ -628,7 +702,7 @@ open class RadioConfigViewModel(
628702 }
629703
630704 fun clearPacketResponse () {
631- requestIds.value = hashSetOf ()
705+ clearRequestIds ()
632706 _radioConfigState .update { it.copy(responseState = ResponseState .Empty ) }
633707 }
634708
@@ -740,6 +814,38 @@ open class RadioConfigViewModel(
740814 }
741815 }
742816
817+ private fun beginManualChannelBatch (total : Int ): Boolean {
818+ if (hasUnrelatedPendingRequest() || hasPendingRequestRegistration()) {
819+ Logger .w { " Manual channel update skipped while another radio request is pending" }
820+ return false
821+ }
822+ manualChannelBatchEnqueueing = true
823+ _radioConfigState .update { state ->
824+ state.copy(route = " " , responseState = ResponseState .Loading (total = total))
825+ }
826+ return true
827+ }
828+
829+ private fun finishManualChannelBatch () {
830+ manualChannelBatchEnqueueing = false
831+ if (requestIds.value.isEmpty()) {
832+ setResponseStateSuccess()
833+ }
834+ }
835+
836+ private fun abortManualChannelBatch (batchRequestIds : Set <Int >) {
837+ manualChannelBatchEnqueueing = false
838+ removeRequestIds(batchRequestIds)
839+ }
840+
841+ private fun completeSetRequestOrProgressBatch () {
842+ if (manualChannelBatchEnqueueing) {
843+ incrementCompleted()
844+ } else {
845+ setResponseStateSuccess()
846+ }
847+ }
848+
743849 protected fun setResponseStateSuccess () {
744850 _radioConfigState .update { state ->
745851 if (state.responseState is ResponseState .Loading ) {
@@ -772,7 +878,8 @@ open class RadioConfigViewModel(
772878 }
773879
774880 private fun registerRequestId (packetId : Int ) {
775- requestIds.update { it.apply { add(packetId) } }
881+ requestTimeoutJobs.remove(packetId)?.cancel()
882+ requestIds.update { it.withPacketId(packetId) }
776883 _radioConfigState .update { state ->
777884 if (state.responseState is ResponseState .Loading ) {
778885 val total = maxOf(requestIds.value.size, state.responseState.total)
@@ -786,15 +893,46 @@ open class RadioConfigViewModel(
786893 }
787894
788895 val requestTimeout = 30 .seconds
789- safeLaunch(tag = " requestTimeout" ) {
790- delay(requestTimeout)
791- if (requestIds.value.contains(packetId)) {
792- requestIds.update { it.apply { remove(packetId) } }
793- if (requestIds.value.isEmpty()) {
794- sendError(Res .string.timeout)
896+ requestTimeoutJobs[packetId] =
897+ safeLaunch(tag = " requestTimeout" ) {
898+ delay(requestTimeout)
899+ if (requestIds.value.contains(packetId)) {
900+ removeRequestId(packetId)
901+ if (requestIds.value.isEmpty()) {
902+ sendError(Res .string.timeout)
903+ }
795904 }
796905 }
797- }
906+ }
907+
908+ private fun registerManualChannelBatchRequestId (packetId : Int ) {
909+ manualChannelBatchRequestIds.add(packetId)
910+ registerRequestId(packetId)
911+ }
912+
913+ private fun hasUnrelatedPendingRequest (): Boolean = requestIds.value.any { it !in manualChannelBatchRequestIds }
914+
915+ private fun hasPendingRequestRegistration (): Boolean = requestIds.value.isEmpty() &&
916+ manualChannelBatchRequestIds.isEmpty() &&
917+ radioConfigState.value.responseState is ResponseState .Loading
918+
919+ private fun clearRequestIds () {
920+ requestTimeoutJobs.values.forEach { it.cancel() }
921+ requestTimeoutJobs.clear()
922+ manualChannelBatchRequestIds.clear()
923+ requestIds.value = hashSetOf()
924+ }
925+
926+ private fun removeRequestId (packetId : Int ) {
927+ requestTimeoutJobs.remove(packetId)?.cancel()
928+ manualChannelBatchRequestIds.remove(packetId)
929+ requestIds.update { it.withoutPacketId(packetId) }
930+ }
931+
932+ private fun removeRequestIds (packetIds : Set <Int >) {
933+ packetIds.forEach { requestTimeoutJobs.remove(it)?.cancel() }
934+ manualChannelBatchRequestIds.removeAll(packetIds)
935+ requestIds.update { ids -> ids.withoutPacketIds(packetIds) }
798936 }
799937
800938 private fun processPacketResponse (packet : MeshPacket ) {
@@ -813,9 +951,9 @@ open class RadioConfigViewModel(
813951 is RadioResponseResult .Success -> {
814952 if (route.isEmpty()) {
815953 val data = packet.decoded!!
816- requestIds.update { it. apply { remove (data.request_id) } }
954+ removeRequestId (data.request_id)
817955 if (requestIds.value.isEmpty()) {
818- setResponseStateSuccess ()
956+ completeSetRequestOrProgressBatch ()
819957 } else {
820958 incrementCompleted()
821959 }
@@ -938,14 +1076,79 @@ open class RadioConfigViewModel(
9381076 }
9391077
9401078 val requestId = packet.decoded?.request_id ? : return
941- requestIds.update { it. apply { remove (requestId) } }
1079+ removeRequestId (requestId)
9421080
9431081 if (requestIds.value.isEmpty()) {
9441082 if (route.isNotEmpty() && ! AdminRoute .entries.any { it.name == route }) {
9451083 clearPacketResponse()
9461084 } else if (route.isEmpty()) {
947- setResponseStateSuccess ()
1085+ completeSetRequestOrProgressBatch ()
9481086 }
9491087 }
9501088 }
9511089}
1090+
1091+ internal data class ManualChannelUpdateResult (val packetIds : List <Int >, val finalSettings : List <ChannelSettings >)
1092+
1093+ internal data class InterruptedManualChannelUpdate (
1094+ val appliedSettings : List <ChannelSettings >,
1095+ val appliedWriteCount : Int ,
1096+ )
1097+
1098+ internal suspend fun applyManualChannelUpdatePlan (
1099+ updatePlan : List <Channel >,
1100+ currentSettings : List <ChannelSettings >,
1101+ finalSettings : List <ChannelSettings >,
1102+ writeChannel : suspend (Channel ) -> Int ,
1103+ registerRequestId : (Int ) -> Unit ,
1104+ onInterrupted : suspend (InterruptedManualChannelUpdate ) -> Unit = {},
1105+ writeDelay : Duration = MANUAL_CHANNEL_WRITE_DELAY ,
1106+ delayFn : suspend (Duration ) -> Unit = { delay(it) },
1107+ ): ManualChannelUpdateResult {
1108+ val packetIds = mutableListOf<Int >()
1109+ val appliedSettings = currentSettings.toMutableList()
1110+ var appliedWriteCount = 0
1111+ var updateComplete = false
1112+ try {
1113+ for ((index, channel) in updatePlan.withIndex()) {
1114+ val packetId = writeChannel(channel)
1115+ packetIds.add(packetId)
1116+ registerRequestId(packetId)
1117+ appliedSettings.applyManualChannelWrite(channel)
1118+ appliedWriteCount++
1119+ if (index < updatePlan.lastIndex) {
1120+ delayFn(writeDelay)
1121+ }
1122+ }
1123+ updateComplete = true
1124+ } finally {
1125+ if (! updateComplete && appliedWriteCount > 0 ) {
1126+ onInterrupted(
1127+ InterruptedManualChannelUpdate (
1128+ appliedSettings = appliedSettings.toList(),
1129+ appliedWriteCount = appliedWriteCount,
1130+ ),
1131+ )
1132+ }
1133+ }
1134+ return ManualChannelUpdateResult (packetIds = packetIds, finalSettings = finalSettings)
1135+ }
1136+
1137+ private fun MutableList<ChannelSettings>.applyManualChannelWrite (channel : Channel ) {
1138+ while (size <= channel.index) {
1139+ add(ChannelSettings ())
1140+ }
1141+ this [channel.index] =
1142+ if (channel.role == Channel .Role .DISABLED ) {
1143+ ChannelSettings ()
1144+ } else {
1145+ channel.settings ? : ChannelSettings ()
1146+ }
1147+ }
1148+
1149+ private fun HashSet<Int>.withPacketId (packetId : Int ): HashSet <Int > = HashSet (this ).apply { add(packetId) }
1150+
1151+ private fun HashSet<Int>.withoutPacketId (packetId : Int ): HashSet <Int > = HashSet (this ).apply { remove(packetId) }
1152+
1153+ private fun HashSet<Int>.withoutPacketIds (packetIds : Set <Int >): HashSet <Int > =
1154+ HashSet (this ).apply { removeAll(packetIds) }
0 commit comments