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/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..6c56565d 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,16 @@ 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);
+ }
+ // 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
@@ -1290,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/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/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/ConnectionDialogs.kt
index 9244175e..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
@@ -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,19 +252,55 @@ 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
+ // 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, how: String) {
+ GeneralVariables.fileLog("FlexDiscovery: connect ($how) model=${radio.model} ip=${radio.ip}")
+ mainViewModel.connectFlexRadioRig(GeneralVariables.getMainContext(), radio)
+ onDismiss()
+ }
- // Subscribe to FlexRadioFactory discovery events
+ // 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)
+ 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})",
+ )
+ // 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()
@@ -269,7 +308,35 @@ 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 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(
+ discoveredCount = discoveredRadios.size,
+ userTypedIp = ipText.isNotBlank(),
+ alreadyTriggered = autoTriggered,
+ startedEmpty = startedEmpty,
+ rigAlreadyConnected = mainViewModel.isRigConnected(),
+ )
+ ) {
+ autoTriggered = true
+ val radio = discoveredRadios.first()
+ ToastMessage.show(
+ String.format(
+ GeneralVariables.getStringFromResource(R.string.select_flex_device),
+ radio.model,
+ ),
+ )
+ connect(radio, "auto")
+ }
}
Dialog(onDismissRequest = onDismiss) {
@@ -290,50 +357,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 +392,7 @@ fun FlexRadioPickerDialog(
radio.model,
),
)
- mainViewModel.connectFlexRadioRig(
- GeneralVariables.getMainContext(),
- radio,
- )
- onDismiss()
+ connect(radio, "tap")
}
.padding(horizontal = 24.dp, vertical = 10.dp),
) {
@@ -374,6 +412,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, "manual")
+ }
+ }
+ } 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..aaded12d
--- /dev/null
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogic.kt
@@ -0,0 +1,41 @@
+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.
+ *
+ * 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.
+ */
+/**
+ * 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,
+ alreadyTriggered: Boolean,
+ startedEmpty: Boolean,
+ rigAlreadyConnected: Boolean,
+): Boolean =
+ discoveredCount == 1 &&
+ !userTypedIp &&
+ !alreadyTriggered &&
+ startedEmpty &&
+ !rigAlreadyConnected
diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml
index 23145e7b..25d87a76 100644
--- a/ft8af/app/src/main/res/values/strings_compose.xml
+++ b/ft8af/app/src/main/res/values/strings_compose.xml
@@ -12,6 +12,11 @@
-->
+
+ Searching for FlexRadio on your network…
+ Enter IP manually
+ Set up your radio in Settings → Radio & Audio first.
+
Cancel
Save
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)
+ }
}
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..e00260b3
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/settings/FlexDiscoveryLogicTest.kt
@@ -0,0 +1,86 @@
+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 {
+
+ /** 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_freshSingleDiscovery_noTyping_notConnected() {
+ assertThat(autoConnect()).isTrue()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenNothingFound() {
+ assertThat(autoConnect(discoveredCount = 0)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenMultipleFound() {
+ // Ambiguous which one the user wants — leave it to a tap.
+ assertThat(autoConnect(discoveredCount = 3)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_whenUserIsTypingAnIp() {
+ assertThat(autoConnect(userTypedIp = true)).isFalse()
+ }
+
+ @Test
+ fun doesNotAutoConnect_onceAlreadyTriggered() {
+ // One-shot: a later discovery event can't re-fire the auto-connect.
+ 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()
+ }
+
+ // ---- 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()
+ }
+}