From 2479f7ea7c563930edb19edb7fd7931308ea2cb5 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 13:58:35 -0500 Subject: [PATCH 1/4] Flex network: auto-discover and connect, no IP typing required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting Network + FlexRadio dropped the user on a manual "IP Address" field. Discovery code existed (FlexRadioFactory listens for the Flex UDP broadcast on 4992) and the picker even rendered found radios — but the list was always empty, so typing the IP was the only thing that worked. Root cause: Android's Wi-Fi chip filters out incoming broadcast/multicast packets unless an app holds a WifiManager.MulticastLock, and we never took one (nor held the CHANGE_WIFI_MULTICAST_STATE permission needed for it). So the discovery socket received nothing. - Add CHANGE_WIFI_MULTICAST_STATE and acquire a MulticastLock while the Flex picker is open (released on dismiss, so no battery cost otherwise). - Lead the picker with discovery: a "Searching…" state, the found radios as the primary list, and manual IP entry collapsed behind a link as a fallback (different subnet / VPN / broadcast blocked). - Auto-connect when discovery finds exactly one radio and the user hasn't started typing — connect to your Flex with zero input. The decision (shouldAutoConnectFlex) is a pure, unit-tested one-shot. Co-Authored-By: Claude Opus 4.8 --- ft8af/app/src/main/AndroidManifest.xml | 4 + .../ft8af/ui/settings/ConnectionDialogs.kt | 158 ++++++++++++------ .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 17 ++ .../src/main/res/values/strings_compose.xml | 4 + .../ui/settings/FlexDiscoveryLogicTest.kt | 49 ++++++ 5 files changed, 183 insertions(+), 49 deletions(-) create mode 100644 ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml index 9a6ff9e1..872a3e4c 100644 --- a/ft8af/app/src/main/AndroidManifest.xml +++ b/ft8af/app/src/main/AndroidManifest.xml @@ -9,6 +9,10 @@ + + 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..f7c7d6f3 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 @@ -20,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,6 +30,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 @@ -37,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -249,12 +252,31 @@ fun FlexRadioPickerDialog( onDismiss: () -> Unit, ) { var ipText by remember { mutableStateOf("") } + var showManualEntry by remember { mutableStateOf(false) } + var autoTriggered by remember { mutableStateOf(false) } val discoveredRadios = remember { mutableStateListOf() } + val context = LocalContext.current - // Subscribe to FlexRadioFactory discovery events + fun connect(radio: FlexRadio) { + mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio) + onDismiss() + } + + // Start discovery while the dialog is open. Acquiring a MulticastLock is the + // load-bearing part: the FlexRadioFactory UDP listener is already running, but + // Android's Wi-Fi chip drops incoming broadcast/multicast packets (which is what + // Flex discovery is) unless an app holds this lock — without it the discovered + // list stays empty and the user is forced to type an IP. Lock is held only while + // the picker is open, so it doesn't drain battery the rest of the time. DisposableEffect(Unit) { + val wifi = context.applicationContext + .getSystemService(android.content.Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val multicastLock = wifi.createMulticastLock("ft8af-flex-discovery").apply { + setReferenceCounted(true) + runCatching { acquire() } + } + val factory = FlexRadioFactory.getInstance() - // Seed with any already-discovered radios discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) @@ -269,7 +291,26 @@ fun FlexRadioPickerDialog( } } factory.setOnFlexRadioEvents(listener) - onDispose { factory.setOnFlexRadioEvents(null) } + onDispose { + factory.setOnFlexRadioEvents(null) + if (multicastLock.isHeld) runCatching { multicastLock.release() } + } + } + + // Auto-connect the moment a single radio is found and the user hasn't started + // typing — the "just connect to my Flex" path. One-shot via autoTriggered. + LaunchedEffect(discoveredRadios.size, ipText) { + if (shouldAutoConnectFlex(discoveredRadios.size, ipText.isNotBlank(), autoTriggered)) { + autoTriggered = true + val radio = discoveredRadios.first() + ToastMessage.show( + String.format( + GeneralVariables.getStringFromResource(R.string.select_flex_device), + radio.model, + ), + ) + connect(radio) + } } Dialog(onDismissRequest = onDismiss) { @@ -290,50 +331,25 @@ fun FlexRadioPickerDialog( Spacer(modifier = Modifier.height(16.dp)) - // Manual IP entry - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = ipText, - onValueChange = { ipText = it }, - label = { Text("IP Address") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - colors = DialogFieldColors(), - modifier = Modifier.weight(1f), - ) - DialogAccentButton( - label = stringResource(R.string.icom_login_button), - enabled = ipText.isNotBlank(), - modifier = Modifier.width(80.dp), + if (discoveredRadios.isEmpty()) { + // Searching state — discovery is active; no manual typing required. + Row( + modifier = Modifier.padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - ToastMessage.show( - String.format( - GeneralVariables.getStringFromResource(R.string.connect_flex_ip), - ipText, - ), + CircularProgressIndicator( + color = Accent, + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp), + ) + Text( + text = stringResource(R.string.flex_searching), + color = TextMuted, + fontSize = 14.sp, ) - val flexRadio = FlexRadio() - flexRadio.ip = ipText.trim() - flexRadio.model = "FlexRadio" - mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), flexRadio) - onDismiss() } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Discovered radios - if (discoveredRadios.isNotEmpty()) { - HorizontalDivider(color = Border, modifier = Modifier.padding(horizontal = 24.dp)) - - Spacer(modifier = Modifier.height(8.dp)) - + } else { LazyColumn( modifier = Modifier .fillMaxWidth() @@ -350,11 +366,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - mainViewModel.connectFlexRadioRig( - GeneralVariables.getMainContext(), - radio, - ) - onDismiss() + connect(radio) } .padding(horizontal = 24.dp, vertical = 10.dp), ) { @@ -374,6 +386,54 @@ fun FlexRadioPickerDialog( } } + Spacer(modifier = Modifier.height(12.dp)) + + // Manual IP entry — a fallback for when discovery can't reach the rig + // (different subnet, VPN, broadcast blocked). Collapsed by default so + // discovery is the obvious path. + if (showManualEntry) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = ipText, + onValueChange = { ipText = it }, + label = { Text("IP Address") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + colors = DialogFieldColors(), + modifier = Modifier.weight(1f), + ) + DialogAccentButton( + label = stringResource(R.string.icom_login_button), + enabled = ipText.isNotBlank(), + modifier = Modifier.width(80.dp), + ) { + ToastMessage.show( + String.format( + GeneralVariables.getStringFromResource(R.string.connect_flex_ip), + ipText, + ), + ) + val flexRadio = FlexRadio() + flexRadio.ip = ipText.trim() + flexRadio.model = "FlexRadio" + connect(flexRadio) + } + } + } else { + TextButton( + onClick = { showManualEntry = true }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text(stringResource(R.string.flex_enter_ip_manually), color = TextMuted) + } + } + Spacer(modifier = Modifier.height(8.dp)) Row( diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt new file mode 100644 index 00000000..18e1ad81 --- /dev/null +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -0,0 +1,17 @@ +package radio.ks3ckc.ft8af.ui.settings + +/** + * Pure decision for whether the Flex picker should auto-connect without the user + * doing anything. Extracted so the rule is unit-tested independently of the + * dialog/discovery plumbing. + * + * We auto-connect only when discovery has found exactly one radio (the common + * single-rig case — connect with zero typing), the user hasn't started entering + * an IP manually (don't hijack a deliberate manual entry), and we haven't already + * fired (one-shot, so it can't loop or fight a second discovery event). + */ +internal fun shouldAutoConnectFlex( + discoveredCount: Int, + userTypedIp: Boolean, + alreadyTriggered: Boolean, +): Boolean = discoveredCount == 1 && !userTypedIp && !alreadyTriggered diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 23145e7b..45ff9501 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -12,6 +12,10 @@ --> + + Searching for FlexRadio on your network… + Enter IP manually + Cancel Save diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt new file mode 100644 index 00000000..cfd9f0b1 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -0,0 +1,49 @@ +package radio.ks3ckc.ft8af.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [shouldAutoConnectFlex] — the rule that decides whether the Flex + * picker connects with no user action. Pure logic, no Android/Compose types. + */ +class FlexDiscoveryLogicTest { + + @Test + fun autoConnects_whenExactlyOneFound_noTyping_notYetTriggered() { + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = false), + ).isTrue() + } + + @Test + fun doesNotAutoConnect_whenNothingFound() { + assertThat( + shouldAutoConnectFlex(discoveredCount = 0, userTypedIp = false, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_whenMultipleFound() { + // Ambiguous which one the user wants — leave it to a tap. + assertThat( + shouldAutoConnectFlex(discoveredCount = 3, userTypedIp = false, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_whenUserIsTypingAnIp() { + // A deliberate manual entry must not be hijacked. + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = true, alreadyTriggered = false), + ).isFalse() + } + + @Test + fun doesNotAutoConnect_onceAlreadyTriggered() { + // One-shot: a later discovery event can't re-fire the auto-connect. + assertThat( + shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = true), + ).isFalse() + } +} From 46c45263b2bc3f15dca9601e5b9f5648c0b86577 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:29:51 -0500 Subject: [PATCH 2/4] Flex discovery: stop auto-connect closing the dialog / breaking live session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: the picker "closes the instant you open it" and the rig stops connecting. Cause: FlexRadioFactory is a singleton, so once a radio is cached, reopening the picker seeds the list with it and the auto-connect LaunchedEffect fired immediately — dismissing the dialog and, worse, reconnecting an already-connected rig, which tears down the working session. Gate auto-connect so it only fires on a genuinely fresh discovery: - startedEmpty: the picker must have opened with no cached radios, so we never auto-connect to a stale singleton entry. - rigAlreadyConnected: never reconnect over a live session. (plus the existing single-radio / not-typing / one-shot guards.) Also add debug.log diagnostics (multicastLock.held, seeded count, each radio added, and connect path) so we can confirm whether discovery actually receives the UDP broadcasts on the user's network. Co-Authored-By: Claude Opus 4.8 --- .../ft8af/ui/settings/ConnectionDialogs.kt | 35 ++++++++++--- .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 23 ++++++-- .../ui/settings/FlexDiscoveryLogicTest.kt | 52 +++++++++++++------ 3 files changed, 81 insertions(+), 29 deletions(-) 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 f7c7d6f3..f0486bd2 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 @@ -256,8 +256,12 @@ fun FlexRadioPickerDialog( var autoTriggered by remember { mutableStateOf(false) } val discoveredRadios = remember { mutableStateListOf() } val context = LocalContext.current + // True only if the picker opened with NO radios cached in the factory singleton. + // Gates auto-connect to a freshly discovered radio, never a stale cached one. + val startedEmpty = remember { FlexRadioFactory.getInstance().flexRadios.isEmpty() } - fun connect(radio: FlexRadio) { + fun connect(radio: FlexRadio, how: String) { + GeneralVariables.fileLog("FlexDiscovery: connect ($how) model=${radio.model} ip=${radio.ip}") mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio) onDismiss() } @@ -279,9 +283,17 @@ fun FlexRadioPickerDialog( val factory = FlexRadioFactory.getInstance() discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) + GeneralVariables.fileLog( + "FlexDiscovery: picker open, multicastLock.held=${multicastLock.isHeld}, " + + "seeded=${factory.flexRadios.size}, startedEmpty=$startedEmpty", + ) val listener = object : FlexRadioFactory.OnFlexRadioEvents { override fun OnFlexRadioAdded(flexRadio: FlexRadio) { + GeneralVariables.fileLog( + "FlexDiscovery: radio added model=${flexRadio.model} ip=${flexRadio.ip} " + + "(total=${factory.flexRadios.size})", + ) discoveredRadios.clear() discoveredRadios.addAll(factory.flexRadios) } @@ -297,10 +309,19 @@ fun FlexRadioPickerDialog( } } - // Auto-connect the moment a single radio is found and the user hasn't started - // typing — the "just connect to my Flex" path. One-shot via autoTriggered. + // Auto-connect the moment a single radio is freshly discovered and the user + // hasn't started typing — the "just connect to my Flex" path. Heavily gated + // (see shouldAutoConnectFlex) so it never fires on a stale cached radio or + // while a rig is already connected. One-shot via autoTriggered. LaunchedEffect(discoveredRadios.size, ipText) { - if (shouldAutoConnectFlex(discoveredRadios.size, ipText.isNotBlank(), autoTriggered)) { + if (shouldAutoConnectFlex( + discoveredCount = discoveredRadios.size, + userTypedIp = ipText.isNotBlank(), + alreadyTriggered = autoTriggered, + startedEmpty = startedEmpty, + rigAlreadyConnected = mainViewModel.isRigConnected(), + ) + ) { autoTriggered = true val radio = discoveredRadios.first() ToastMessage.show( @@ -309,7 +330,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - connect(radio) + connect(radio, "auto") } } @@ -366,7 +387,7 @@ fun FlexRadioPickerDialog( radio.model, ), ) - connect(radio) + connect(radio, "tap") } .padding(horizontal = 24.dp, vertical = 10.dp), ) { @@ -422,7 +443,7 @@ fun FlexRadioPickerDialog( val flexRadio = FlexRadio() flexRadio.ip = ipText.trim() flexRadio.model = "FlexRadio" - connect(flexRadio) + connect(flexRadio, "manual") } } } else { diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt index 18e1ad81..68a3a931 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -5,13 +5,26 @@ package radio.ks3ckc.ft8af.ui.settings * doing anything. Extracted so the rule is unit-tested independently of the * dialog/discovery plumbing. * - * We auto-connect only when discovery has found exactly one radio (the common - * single-rig case — connect with zero typing), the user hasn't started entering - * an IP manually (don't hijack a deliberate manual entry), and we haven't already - * fired (one-shot, so it can't loop or fight a second discovery event). + * Auto-connect only when ALL hold: + * - discovery has found exactly one radio (the common single-rig case), + * - the user hasn't started entering an IP manually (don't hijack manual entry), + * - we haven't already fired (one-shot, can't loop or fight a later event), + * - the picker opened with an EMPTY list ([startedEmpty]) — so we only auto-connect + * to a freshly discovered radio, never to a stale entry cached in the + * FlexRadioFactory singleton from earlier this session (that bug made the dialog + * appear to "close the instant you open it"), + * - no rig is already connected ([rigAlreadyConnected]) — reconnecting a live + * session would tear it down and can leave it broken. */ internal fun shouldAutoConnectFlex( discoveredCount: Int, userTypedIp: Boolean, alreadyTriggered: Boolean, -): Boolean = discoveredCount == 1 && !userTypedIp && !alreadyTriggered + startedEmpty: Boolean, + rigAlreadyConnected: Boolean, +): Boolean = + discoveredCount == 1 && + !userTypedIp && + !alreadyTriggered && + startedEmpty && + !rigAlreadyConnected diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt index cfd9f0b1..1f8652c6 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -9,41 +9,59 @@ import org.junit.Test */ class FlexDiscoveryLogicTest { + /** All five preconditions met → auto-connect. */ + private fun autoConnect( + discoveredCount: Int = 1, + userTypedIp: Boolean = false, + alreadyTriggered: Boolean = false, + startedEmpty: Boolean = true, + rigAlreadyConnected: Boolean = false, + ) = shouldAutoConnectFlex( + discoveredCount = discoveredCount, + userTypedIp = userTypedIp, + alreadyTriggered = alreadyTriggered, + startedEmpty = startedEmpty, + rigAlreadyConnected = rigAlreadyConnected, + ) + @Test - fun autoConnects_whenExactlyOneFound_noTyping_notYetTriggered() { - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = false), - ).isTrue() + fun autoConnects_freshSingleDiscovery_noTyping_notConnected() { + assertThat(autoConnect()).isTrue() } @Test fun doesNotAutoConnect_whenNothingFound() { - assertThat( - shouldAutoConnectFlex(discoveredCount = 0, userTypedIp = false, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(discoveredCount = 0)).isFalse() } @Test fun doesNotAutoConnect_whenMultipleFound() { // Ambiguous which one the user wants — leave it to a tap. - assertThat( - shouldAutoConnectFlex(discoveredCount = 3, userTypedIp = false, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(discoveredCount = 3)).isFalse() } @Test fun doesNotAutoConnect_whenUserIsTypingAnIp() { - // A deliberate manual entry must not be hijacked. - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = true, alreadyTriggered = false), - ).isFalse() + assertThat(autoConnect(userTypedIp = true)).isFalse() } @Test fun doesNotAutoConnect_onceAlreadyTriggered() { // One-shot: a later discovery event can't re-fire the auto-connect. - assertThat( - shouldAutoConnectFlex(discoveredCount = 1, userTypedIp = false, alreadyTriggered = true), - ).isFalse() + assertThat(autoConnect(alreadyTriggered = true)).isFalse() + } + + @Test + fun doesNotAutoConnect_whenPickerOpenedWithCachedRadio() { + // The bug that made the dialog "close the instant you open it": a radio + // cached in the FlexRadioFactory singleton seeds the list, so the picker + // did NOT start empty — must not auto-connect to it. + assertThat(autoConnect(startedEmpty = false)).isFalse() + } + + @Test + fun doesNotAutoConnect_whenRigAlreadyConnected() { + // Reconnecting a live session would tear it down. + assertThat(autoConnect(rigAlreadyConnected = true)).isFalse() } } From c9cc3b463769374517286a9ab672b7900e7958df Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:44:58 -0500 Subject: [PATCH 3/4] CAT chip: one-tap connect to saved Flex (and remember its address) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "If my Flex settings are set, I should just click the CAT button near CQ and have it connect." Two gaps stopped that: the Flex address was never persisted, and reconnectRig() no-ops when no connector exists yet (a cold start) — so the CAT chip did nothing until you'd already connected once via the picker. - Persist the Flex address (flexLastIp) whenever we connect — discovered, tapped, or typed — and load it at startup. - Route the CAT chip tap (catTapAction): reconnect an existing connector if one's live; otherwise, on a cold start with a saved network Flex, connect straight to the remembered address; with nothing saved, point the user to setup. Pure routing (catTapAction) is unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/GeneralVariables.java | 3 ++ .../java/com/k1af/ft8af/MainViewModel.java | 6 ++++ .../com/k1af/ft8af/database/DatabaseOpr.java | 3 ++ .../kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt | 28 +++++++++++++++++- .../ft8af/ui/components/CatStatusChip.kt | 23 +++++++++++++++ .../src/main/res/values/strings_compose.xml | 1 + .../ui/components/CatStatusChipLogicTest.kt | 29 +++++++++++++++++++ 7 files changed, 92 insertions(+), 1 deletion(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java index 401b5389..1dac8608 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/GeneralVariables.java @@ -93,6 +93,9 @@ public static boolean isConfiguredUsbAudioOutput(int vid, int pid) { public static int flexMaxRfPower = 10;//Flex radio max transmit power public static int flexMaxTunePower = 10;//Flex radio max tune power + // Last FlexRadio address connected to (discovered or typed). Persisted so the + // CAT chip can reconnect on a cold start without re-opening the picker. + public static String flexLastIp = ""; // Hidden debug mode (unlocked by tapping the version 7 times in About). // When true, Settings exposes the Debug screen for log viewing/sharing. diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 3bda4674..8870ed63 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1283,6 +1283,12 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { } } GeneralVariables.controlMode = ControlMode.CAT;//network control mode + // Remember the address so the CAT chip can reconnect on a cold start + // without re-opening the picker. + if (flexRadio != null && flexRadio.getIp() != null && !flexRadio.getIp().isEmpty()) { + GeneralVariables.flexLastIp = flexRadio.getIp(); + databaseOpr.writeConfig("flexLastIp", flexRadio.getIp(), null); + } FlexConnector flexConnector = new FlexConnector(context, flexRadio, GeneralVariables.controlMode); flexConnector.setOnWaveDataReceived(new FlexConnector.OnWaveDataReceived() { @Override diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java index 07d846c4..2d251707 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/database/DatabaseOpr.java @@ -2565,6 +2565,9 @@ protected Void doInBackground(Void... voids) { if (name.equalsIgnoreCase("flexMaxTunePower")) {//Flex max tune power GeneralVariables.flexMaxTunePower = result.equals("") ? 10 : Integer.parseInt(result); } + if (name.equalsIgnoreCase("flexLastIp")) {//Last Flex address, for CAT-chip reconnect + GeneralVariables.flexLastIp = result == null ? "" : result; + } if (name.equalsIgnoreCase("saveSWL")) {//Save decoded messages GeneralVariables.saveSWLMessage = result.equals("1"); } diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt index 083802af..98daf623 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/FT8AFApp.kt @@ -38,10 +38,14 @@ import com.k1af.ft8af.ModeProfile import com.k1af.ft8af.R import com.k1af.ft8af.database.OperationBand import com.k1af.ft8af.ft8transmit.FT8TransmitSignal +import com.k1af.ft8af.flex.FlexRadio import com.k1af.ft8af.rigs.CatConnectionState +import com.k1af.ft8af.rigs.InstructionSet import com.k1af.ft8af.rigs.BaseRigOperation import radio.ks3ckc.ft8af.theme.BgApp import radio.ks3ckc.ft8af.ui.components.ActiveQsoPanel +import radio.ks3ckc.ft8af.ui.components.CatTapAction +import radio.ks3ckc.ft8af.ui.components.catTapAction import radio.ks3ckc.ft8af.ui.components.shouldShowCatChip import radio.ks3ckc.ft8af.ui.components.CqOptionsSheet import radio.ks3ckc.ft8af.ui.components.canEnableFieldDay @@ -488,7 +492,29 @@ fun FT8AFApp(mainViewModel: MainViewModel) { ).show() } }, - onReconnectCat = { mainViewModel.reconnectRig() }, + onReconnectCat = { + when ( + catTapAction( + connectorExists = mainViewModel.baseRig?.connector != null, + isFlexNetwork = GeneralVariables.instructionSet == InstructionSet.FLEX_NETWORK, + savedFlexIp = GeneralVariables.flexLastIp, + ) + ) { + CatTapAction.RECONNECT_EXISTING -> mainViewModel.reconnectRig() + CatTapAction.CONNECT_SAVED_FLEX -> { + val flexRadio = FlexRadio() + flexRadio.ip = GeneralVariables.flexLastIp + flexRadio.model = "FlexRadio" + mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), flexRadio) + } + CatTapAction.NEEDS_SETUP -> + Toast.makeText( + context, + context.getString(R.string.cat_needs_setup), + Toast.LENGTH_SHORT, + ).show() + } + }, onOpenFrequencyPicker = { showFrequencyPicker = true }, onToggleExpand = { qsoPanelExpanded = !qsoPanelExpanded }, ) diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt index 27fa2fd0..a03aaef7 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChip.kt @@ -79,6 +79,29 @@ internal fun catChipVisuals(state: CatConnectionState): CatChipVisuals = when (s internal fun shouldShowCatChip(controlMode: Int, state: CatConnectionState): Boolean = controlMode != ControlMode.VOX || state != CatConnectionState.DISCONNECTED +/** What tapping the CAT chip should do, given the current connection state. */ +enum class CatTapAction { RECONNECT_EXISTING, CONNECT_SAVED_FLEX, NEEDS_SETUP } + +/** + * Decide what a tap on the CAT chip does. If a connector already exists, just + * reconnect it (the existing behavior — handy for Bluetooth/Flex retries). + * Otherwise, on a cold start, if the saved rig is a network Flex with a + * remembered address, connect straight to it — "my settings are set, just + * connect" — instead of doing nothing (the old reconnectRig() no-ops with no + * connector). With nothing saved to go on, the user needs to set the rig up. + * + * Pure so the routing is unit-tested without the rig/connect plumbing. + */ +internal fun catTapAction( + connectorExists: Boolean, + isFlexNetwork: Boolean, + savedFlexIp: String, +): CatTapAction = when { + connectorExists -> CatTapAction.RECONNECT_EXISTING + isFlexNetwork && savedFlexIp.isNotBlank() -> CatTapAction.CONNECT_SAVED_FLEX + else -> CatTapAction.NEEDS_SETUP +} + /** * A small CAT (rig control) connection indicator for the TX strip. Tappable to * re-trigger the connection — Bluetooth often only connects on the second try, diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml index 45ff9501..25d87a76 100644 --- a/ft8af/app/src/main/res/values/strings_compose.xml +++ b/ft8af/app/src/main/res/values/strings_compose.xml @@ -15,6 +15,7 @@ Searching for FlexRadio on your network… Enter IP manually + Set up your radio in Settings → Radio & Audio first. Cancel diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt index e53fc66b..6549ec2f 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/components/CatStatusChipLogicTest.kt @@ -87,4 +87,33 @@ class CatStatusChipLogicTest { assertThat(CatConnectionState.afterDisconnect(CatConnectionState.DISCONNECTED)) .isEqualTo(CatConnectionState.DISCONNECTED) } + + // ---- catTapAction (what tapping the CAT chip does) ---- + + @Test + fun `cat tap reconnects an existing connector`() { + // A live connector exists (any rig) — just re-poke it. Flex IP irrelevant. + assertThat(catTapAction(connectorExists = true, isFlexNetwork = true, savedFlexIp = "192.168.1.9")) + .isEqualTo(CatTapAction.RECONNECT_EXISTING) + assertThat(catTapAction(connectorExists = true, isFlexNetwork = false, savedFlexIp = "")) + .isEqualTo(CatTapAction.RECONNECT_EXISTING) + } + + @Test + fun `cat tap cold-connects to a saved Flex when no connector yet`() { + // The whole point: settings saved (Flex + remembered IP), no live + // connector → connect straight to it instead of no-opping. + assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = "192.168.1.9")) + .isEqualTo(CatTapAction.CONNECT_SAVED_FLEX) + } + + @Test + fun `cat tap needs setup when nothing to go on`() { + // Flex selected but no remembered address. + assertThat(catTapAction(connectorExists = false, isFlexNetwork = true, savedFlexIp = "")) + .isEqualTo(CatTapAction.NEEDS_SETUP) + // Not a Flex-network rig and nothing connected (e.g. cold USB before scan). + assertThat(catTapAction(connectorExists = false, isFlexNetwork = false, savedFlexIp = "")) + .isEqualTo(CatTapAction.NEEDS_SETUP) + } } From 89f64a626f71b198446d8f7b78b6584568937bea Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Tue, 23 Jun 2026 14:54:48 -0500 Subject: [PATCH 4/4] Flex: show discovered radio in picker, auto-connect it, toast on connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test on a real FLEX-6400 exposed two bugs behind "it never found the radio / didn't connect until I hit CAT": 1. Discovery worked (MulticastLock fine — debug.log showed the radio found in 0.4s) but the picker kept spinning. FlexRadioFactory fires OnFlexRadioAdded BEFORE appending to its list, so the listener re-read an empty list and dropped the radio (logged "total=0"). It also meant the discoveredRadios count stayed 0, so auto-connect never fired. Now we include the radio handed to the callback (mergeDiscovered, deduped by address) — so it shows and auto-connect can fire. 2. The Flex connect never reported success: FlexConnector.onConnectSuccess didn't notify the rig state, so onConnected() (which sets CONNECTED + toasts) never ran — the CAT chip stayed idle and there was no confirmation. Added an OnConnectionResult callback; connectFlexRadioRig now sets CONNECTING up front and CONNECTED + a "connected" toast on success (ERROR on failure). mergeDiscovered is pure + unit-tested. Co-Authored-By: Claude Opus 4.8 --- .../java/com/k1af/ft8af/MainViewModel.java | 18 ++++++++++++++++++ .../k1af/ft8af/connector/FlexConnector.java | 14 +++++++++++++- .../ft8af/ui/settings/ConnectionDialogs.kt | 7 ++++++- .../ft8af/ui/settings/FlexDiscoveryLogic.kt | 11 +++++++++++ .../ui/settings/FlexDiscoveryLogicTest.kt | 19 +++++++++++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java index 8870ed63..6c56565d 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/MainViewModel.java @@ -1289,6 +1289,10 @@ public void connectFlexRadioRig(Context context, FlexRadio flexRadio) { GeneralVariables.flexLastIp = flexRadio.getIp(); databaseOpr.writeConfig("flexLastIp", flexRadio.getIp(), null); } + // Reflect the in-progress attempt on the CAT chip immediately; the Flex + // connect is async (TCP), so without this the chip looks idle until/unless + // the link comes up. + setCatConnectionState(CatConnectionState.CONNECTING); FlexConnector flexConnector = new FlexConnector(context, flexRadio, GeneralVariables.controlMode); flexConnector.setOnWaveDataReceived(new FlexConnector.OnWaveDataReceived() { @Override @@ -1296,6 +1300,20 @@ public void OnDataReceived(int bufferLen, float[] buffer) { hamRecorder.doOnWaveDataReceived(bufferLen, buffer); } }); + // Surface success/failure: the Flex path doesn't fire the BaseRig + // onConnected() callback, so without this the CAT chip never turns + // connected and there's no "connected" confirmation toast. + flexConnector.setOnConnectionResult(new FlexConnector.OnConnectionResult() { + @Override + public void onConnected() { + setCatConnectionState(CatConnectionState.CONNECTED); + ToastMessage.show(getStringFromResource(R.string.connected_rig)); + } + @Override + public void onFailed() { + setCatConnectionState(CatConnectionState.ERROR); + } + }); flexConnector.connect(); connectRig(); diff --git a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java index 78ba8031..5a00389a 100644 --- a/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java +++ b/ft8af/app/src/main/java/com/k1af/ft8af/connector/FlexConnector.java @@ -32,6 +32,12 @@ public class FlexConnector extends BaseRigConnector { public interface OnWaveDataReceived{ void OnDataReceived(int bufferLen,float[] buffer); } + /** Fired when the Flex link is up (TCP connected + GUI client created) or fails, + * so MainViewModel can surface the CAT connection state + a success/failure toast. */ + public interface OnConnectionResult{ + void onConnected(); + void onFailed(); + } public int maxRfPower; public int maxTunePower; @@ -40,6 +46,11 @@ public interface OnWaveDataReceived{ private FlexRadio flexRadio; private OnWaveDataReceived onWaveDataReceived; + private OnConnectionResult onConnectionResult; + + public void setOnConnectionResult(OnConnectionResult listener){ + this.onConnectionResult = listener; + } public FlexConnector(Context context, FlexRadio flexRadio, int controlMode) { @@ -209,13 +220,14 @@ public void onConnectSuccess(RadioTcpClient tcpClient) { //flexRadio.sendCommand("c1|client gui\n"); //playData(); - + if (onConnectionResult != null) onConnectionResult.onConnected(); } @Override public void onConnectFail(RadioTcpClient tcpClient) { ToastMessage.show(String.format(GeneralVariables.getStringFromResource (R.string.flex_connect_failed),flexRadio.getModel())); + if (onConnectionResult != null) onConnectionResult.onFailed(); } }); 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 f0486bd2..c14145d2 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 @@ -294,8 +294,13 @@ fun FlexRadioPickerDialog( "FlexDiscovery: radio added model=${flexRadio.model} ip=${flexRadio.ip} " + "(total=${factory.flexRadios.size})", ) + // The factory fires this callback BEFORE appending the radio to + // flexRadios, so re-reading that list here would miss the new one + // (the bug where the picker kept spinning despite a found radio). + // Include the radio handed to us; dedupe by address. + val merged = mergeDiscovered(factory.flexRadios.toList(), flexRadio) { it.ip ?: "" } discoveredRadios.clear() - discoveredRadios.addAll(factory.flexRadios) + discoveredRadios.addAll(merged) } override fun OnFlexRadioInvalid(flexRadio: FlexRadio) { discoveredRadios.clear() diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt index 68a3a931..aaded12d 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt @@ -16,6 +16,17 @@ package radio.ks3ckc.ft8af.ui.settings * - no rig is already connected ([rigAlreadyConnected]) — reconnecting a live * session would tear it down and can leave it broken. */ +/** + * The radios to show after a discovery event: the factory's current list plus the + * just-added one, deduped by a stable key (its address). The FlexRadioFactory + * fires its "added" callback BEFORE appending the radio to its list, so simply + * re-reading that list drops the new radio — the bug where the picker kept + * "Searching…" even though a radio had been found. Including [added] explicitly + * fixes it; dedup guards against a later event that does include it. + */ +internal fun mergeDiscovered(existing: List, added: T, key: (T) -> String): List = + (existing + added).distinctBy(key) + internal fun shouldAutoConnectFlex( discoveredCount: Int, userTypedIp: Boolean, diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt index 1f8652c6..e00260b3 100644 --- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt @@ -64,4 +64,23 @@ class FlexDiscoveryLogicTest { // Reconnecting a live session would tear it down. assertThat(autoConnect(rigAlreadyConnected = true)).isFalse() } + + // ---- mergeDiscovered ---- + + @Test + fun mergeDiscovered_includesTheNewRadio_evenWhenFactoryListLagsBehind() { + // The factory fires the callback before appending, so existing is empty + // when the very first radio arrives — it must still appear. + assertThat(mergeDiscovered(emptyList(), "192.168.1.9") { it }) + .containsExactly("192.168.1.9") + } + + @Test + fun mergeDiscovered_dedupesByKey() { + // A later event that already includes the radio must not duplicate it. + assertThat(mergeDiscovered(listOf("192.168.1.9"), "192.168.1.9") { it }) + .containsExactly("192.168.1.9") + assertThat(mergeDiscovered(listOf("192.168.1.9"), "10.0.0.5") { it }) + .containsExactly("192.168.1.9", "10.0.0.5").inOrder() + } }