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() + } +}