From 4ac757578d295071782aedd6856d12f0d64be9df Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 12:08:33 -0500 Subject: [PATCH] Flex picker: refresh discovered radios live while dialog is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FlexRadio network picker only refreshed its discovered-radio list via a one-shot FlexRadioFactory event listener that fired on the first add. Discovery typically takes a few seconds, so a radio that surfaced shortly after the dialog opened never appeared — the user had to close and reselect Network to get a fresh seed. Replace the listener with a 1s poll of FlexRadioFactory.flexRadios while the dialog is open, rebuilding the displayed list only when the discovered set actually changes (FlexPickerLogic.identityKey/listChanged) so the list doesn't recompose / reset scroll every tick. Covered by FlexPickerLogicTest. Co-Authored-By: Claude Opus 4.8 --- .../ft8af/ui/settings/ConnectionDialogs.kt | 32 ++++++------ .../ft8af/ui/settings/FlexPickerLogic.kt | 33 ++++++++++++ .../ft8af/ui/settings/FlexPickerLogicTest.kt | 52 +++++++++++++++++++ 3 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt index 9244175e..0e40fed6 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -58,6 +59,7 @@ import com.k1af.ft8af.rigs.InstructionSet import com.k1af.ft8af.ui.ToastMessage import com.k1af.ft8af.x6100.X6100Radio import com.k1af.ft8af.x6100.XieguRadioFactory +import kotlinx.coroutines.delay import radio.ks3ckc.ft8af.theme.* // ───────────────────────────────────────────────────────────────────── @@ -251,25 +253,25 @@ fun FlexRadioPickerDialog( var ipText by remember { mutableStateOf("") } val discoveredRadios = remember { mutableStateListOf() } - // Subscribe to FlexRadioFactory discovery events - DisposableEffect(Unit) { + // Poll discovery while the dialog is open so radios found *after* opening + // (discovery typically takes a few seconds) appear live — no need to close + // and reopen the picker. The previous one-shot event listener only fired on + // the first add, so a radio that surfaced moments later went unseen until + // the dialog was reopened. We rebuild the list only when the discovered set + // actually changes (see FlexPickerLogic) to avoid recomposing / resetting + // the scroll position every tick. + LaunchedEffect(Unit) { val factory = FlexRadioFactory.getInstance() - // Seed with any already-discovered radios - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - - val listener = object : FlexRadioFactory.OnFlexRadioEvents { - override fun OnFlexRadioAdded(flexRadio: FlexRadio) { - discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) - } - override fun OnFlexRadioInvalid(flexRadio: FlexRadio) { + while (true) { + val latest = factory.flexRadios.toList() + val displayedKeys = discoveredRadios.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + val latestKeys = latest.map { FlexPickerLogic.identityKey(it.serial, it.ip) } + if (FlexPickerLogic.listChanged(displayedKeys, latestKeys)) { discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) + discoveredRadios.addAll(latest) } + delay(1000) } - factory.setOnFlexRadioEvents(listener) - onDispose { factory.setOnFlexRadioEvents(null) } } Dialog(onDismissRequest = onDismiss) { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt new file mode 100644 index 00000000..407fabbf --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogic.kt @@ -0,0 +1,33 @@ +package radio.ks3ckc.ft8af.ui.settings + +/** + * Pure helpers for the FlexRadio picker's live-discovery list. + * + * The picker polls [com.k1af.ft8af.flex.FlexRadioFactory] once a second while + * open. These functions keep that polling cheap and stable: derive a stable + * identity per radio, and only rebuild the displayed list when the discovered + * set actually changed (so the list doesn't recompose / reset its scroll every + * tick). Kept free of Android/Compose types so it's unit-testable. + */ +internal object FlexPickerLogic { + + /** + * Stable identity for a discovered radio. Prefers the serial number; falls + * back to the IP when the serial is missing/blank. Trimmed so incidental + * whitespace doesn't read as a change. + */ + fun identityKey(serial: String?, ip: String?): String { + val s = serial?.trim().orEmpty() + if (s.isNotEmpty()) return s + return ip?.trim().orEmpty() + } + + /** + * Whether the latest discovery snapshot differs from what's displayed, + * compared by identity key in order. When false, the caller skips the + * (otherwise per-second) list rebuild. + */ + fun listChanged(displayedKeys: List, latestKeys: List): Boolean { + return displayedKeys != latestKeys + } +} diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt new file mode 100644 index 00000000..20f7a2f1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexPickerLogicTest.kt @@ -0,0 +1,52 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [FlexPickerLogic], the live-discovery list helpers behind the + * FlexRadio picker polling fix. + */ +class FlexPickerLogicTest { + + @Test + fun identityKey_prefersSerial() { + assertThat(FlexPickerLogic.identityKey("0123-4567-89", "192.168.86.159")) + .isEqualTo("0123-4567-89") + } + + @Test + fun identityKey_fallsBackToIpWhenSerialBlank() { + assertThat(FlexPickerLogic.identityKey(" ", "192.168.86.159")) + .isEqualTo("192.168.86.159") + assertThat(FlexPickerLogic.identityKey(null, "192.168.86.159")) + .isEqualTo("192.168.86.159") + } + + @Test + fun identityKey_trimsWhitespace() { + assertThat(FlexPickerLogic.identityKey(" FLEX-1 ", null)).isEqualTo("FLEX-1") + } + + @Test + fun identityKey_emptyWhenNothingKnown() { + assertThat(FlexPickerLogic.identityKey(null, null)).isEmpty() + } + + @Test + fun listChanged_falseWhenIdentical() { + // The common per-tick case: nothing new discovered → no rebuild. + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a", "b"))).isFalse() + } + + @Test + fun listChanged_trueWhenRadioAppears() { + // A radio surfaces after the dialog is already open — must update live. + assertThat(FlexPickerLogic.listChanged(emptyList(), listOf("a"))).isTrue() + } + + @Test + fun listChanged_trueWhenRadioDropsOff() { + assertThat(FlexPickerLogic.listChanged(listOf("a", "b"), listOf("a"))).isTrue() + } +}