From 38836d0b27ac0ae201a2cbafdbcdc06ec04f1d4a Mon Sep 17 00:00:00 2001 From: Ryanmello07 Date: Wed, 2 Sep 2026 17:31:27 -0700 Subject: [PATCH] Add a control-plane IP family control to the developer screen api.bringyour.com and connect.bringyour.com each publish an A and an AAAA record, and the AAAA sits in a tunnel-brokered range that some ISPs route badly. A path like that is not a plain blackhole: it completes the TCP handshake, so Happy Eyeballs declares IPv6 the winner, and then drops the larger TLS ServerHello and stalls. Go's own Happy Eyeballs only heals the pre-connect case, so the post-connect stall is what strands a user with an unreachable api. The sdk demotes a family that fails that way on its own. This adds the override for when it does not: a Developer row cycling Automatic -> Force IPv4 -> Force IPv6, with any demotion the sdk has learned named in the detail line rather than folded into the displayed policy -- an Automatic that read back as "Force IPv4" because the heuristic fired could not be set back to Automatic. The row is deliberately not gated on `connected`. Signed out and with the tunnel down are exactly the states a user is in when the api cannot be reached, which is the only reason to reach for it, so the policy is read from process-global sdk state that is always answerable and the row is live from the first frame. Writes take the furthest-reaching path available: the device when there is one, otherwise the network space from NetworkSpaceManagerProvider (which also records the choice for the next launch), otherwise the process-global setter for this session. The value is read back from the sdk rather than assumed, since the sdk clamps an out-of-range policy instead of throwing. Unit tests pin the constants against the Go values and cover the clamp, the cycle, and that Automatic's detail distinguishes nothing-learned from a demotion. --- .../network/ui/settings/DeveloperScreen.kt | 69 ++++++++++ .../network/ui/settings/DeveloperViewModel.kt | 126 ++++++++++++++++++ app/app/src/main/res/values/strings.xml | 8 ++ .../network/ui/settings/IpFamilyTest.kt | 50 +++++++ 4 files changed, 253 insertions(+) create mode 100644 app/app/src/test/java/com/bringyour/network/ui/settings/IpFamilyTest.kt diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperScreen.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperScreen.kt index 3bf34746c..1a3dc893d 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperScreen.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperScreen.kt @@ -151,6 +151,16 @@ private fun DeveloperContent(developerViewModel: DeveloperViewModel) { onSelect = developerViewModel.setLogVerbosity, ) + // Beside the verbosity row and, like it, ABOVE the !connected guard below. + // An address family that fails after connecting is what makes the api + // unreachable, so this row is reached while signed out or with the tunnel + // down -- exactly where a row gated on `connected` would not be drawn. + DeveloperIpFamilySetting( + policy = developerViewModel.ipFamilyPolicy, + status = developerViewModel.ipFamilyStatus, + onSelect = developerViewModel.setIpFamilyPolicy, + ) + // Persistent, not a one-shot toast: it has to be on screen at the moment // the user reaches for "Export all logs (raw)", which can be many minutes // after the level was raised. This pairing is the point -- raising the @@ -957,6 +967,65 @@ private fun DeveloperVerbositySetting( } } +/** + * Which address family the control plane dials over, cycling Automatic -> + * Force IPv4 -> Force IPv6 on tap. + * + * Unlike [DeveloperVerbositySetting] this row is ALWAYS live. That row is + * inert without a device because there is no process to set a log level on; + * this policy is process-global sdk state that is always answerable, and the + * row has to work signed out and with the tunnel down -- those are the states + * a user is in when the api is unreachable, which is the only reason to reach + * for it. + * + * The value shown is the policy the sdk reports and never a demotion the sdk + * made on its own: a row that read "Force IPv4" because the heuristic fired + * could not be set back to Automatic. The demotion is named in the detail + * line instead, so Automatic does not look identical whether it has fired or + * not. + */ +@Composable +private fun DeveloperIpFamilySetting( + policy: Long, + status: String, + onSelect: (Long) -> Unit, +) { + val name = ipFamilyNameResource(policy) + val detail = ipFamilyDetailResource(policy, status) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(nextIpFamilyPolicy(policy)) } + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.fillMaxWidth(0.72f)) { + Text( + stringResource(id = R.string.dev_ip_family), + style = MaterialTheme.typography.bodyLarge, + color = Color.White, + ) + Text( + // the demoted variant is a %1$s format string; the quiet one + // ignores the argument + stringResource(id = detail, status), + style = MaterialTheme.typography.bodySmall, + color = TextMuted, + ) + } + Text( + stringResource(id = name), + style = MaterialTheme.typography.bodyLarge, + // a forced family is not an ordinary setting value: it overrides + // the judgement that keeps a user on a working path, and it is + // what will strand them on the next network that lacks it + color = if (clampIpFamilyPolicy(policy) == IP_FAMILY_AUTO) BlueMedium else TextDanger, + ) + } +} + /** * A duration that cycles through presets on tap. The first preset is always 0, * which restores the behaviour that shipped before the fix it controls. diff --git a/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperViewModel.kt b/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperViewModel.kt index 7cb31da3f..223a79eaa 100644 --- a/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperViewModel.kt +++ b/app/app/src/main/java/com/bringyour/network/ui/settings/DeveloperViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bringyour.network.APP_LOG_PROCESS_NAME import com.bringyour.network.DeviceManager +import com.bringyour.network.NetworkSpaceManagerProvider +import com.bringyour.network.R import com.bringyour.network.utils.formatByteCountCompact import com.bringyour.sdk.Exit import com.bringyour.sdk.ReliabilityMetrics @@ -34,6 +36,7 @@ import kotlinx.coroutines.withContext @HiltViewModel class DeveloperViewModel @Inject constructor( private val deviceManager: DeviceManager, + private val networkSpaceManagerProvider: NetworkSpaceManagerProvider, ) : ViewModel() { var exits by mutableStateOf>(listOf()) @@ -82,6 +85,34 @@ class DeveloperViewModel @Inject constructor( var logVerbosity by mutableStateOf(null) private set + /** + * The control-plane address family policy this process reports, and what + * the sdk has learned on its own. + * + * NOT nullable, unlike [logVerbosity]. The policy is process-global sdk + * state that is always answerable -- there is no device to ask and + * therefore no "unavailable" -- and the row has to work signed out and + * with the tunnel down, because those are the states a user is in when + * the api is unreachable. + * + * Seeded from the sdk here rather than from IP_FAMILY_AUTO. [refresh] is + * polled, not run on open, so a constant seed would have the row claim + * Automatic for the first REFRESH_POLL_MILLIS of every visit -- and the + * row is deliberately live from the first frame, so a tap inside that + * window would step from a policy that is not the one in force. + */ + var ipFamilyPolicy by mutableStateOf(Sdk.getControlIpFamilyPolicy()) + private set + + /** + * Any family this process has demoted on its own, as the sdk describes + * it, and empty when there is none. Reported beside the policy rather + * than folded into it, so Automatic never reads back as a force the user + * cannot then clear. + */ + var ipFamilyStatus by mutableStateOf(Sdk.getControlIpFamilyStatus()) + private set + var inventory by mutableStateOf>(listOf()) private set @@ -403,6 +434,35 @@ class DeveloperViewModel @Inject constructor( logVerbosity = device?.getLogVerbosity() } + /** + * Applies a control-plane address family policy through the best write + * path available, and re-reads what the sdk then reports. + * + * THREE-way, not two. The device carries the policy furthest (on the + * other platform binding the same call reaches the packet tunnel + * extension), the network space sets this process AND records the choice + * for the next launch, and the process-global setter is the last resort + * that at least puts the choice in force for this session. Signed out + * there is no device and therefore -- see DeviceManager.networkSpace, + * which is `device?.networkSpace` -- no space through the device either, + * which is why the space is fetched from the provider. + * + * Read back from the sdk rather than assumed: it clamps out-of-range + * values without throwing, so reading back is what makes a set that did + * not take visible. + */ + val setIpFamilyPolicy: (Long) -> Unit = { policy -> + val device = deviceManager.device + val networkSpace = networkSpaceManagerProvider.getNetworkSpace() + when { + device != null -> device.setControlIpFamilyPolicy(policy) + networkSpace != null -> networkSpace.setControlIpFamilyPolicy(policy) + else -> Sdk.setControlIpFamilyPolicy(policy) + } + ipFamilyPolicy = Sdk.getControlIpFamilyPolicy() + ipFamilyStatus = Sdk.getControlIpFamilyStatus() + } + fun toggleLogSelection(name: String) { selectedLogNames = if (selectedLogNames.contains(name)) { selectedLogNames - name @@ -422,6 +482,13 @@ class DeveloperViewModel @Inject constructor( // leaving the last device's reading on screen. logVerbosity = device?.getLogVerbosity() + // Also above the guard, and for a stronger reason than the verbosity + // read: this one never needs a device at all. The policy is + // process-global sdk state, and the row exists for the signed-out, + // tunnel-down case where the api cannot be reached. + ipFamilyPolicy = Sdk.getControlIpFamilyPolicy() + ipFamilyStatus = Sdk.getControlIpFamilyStatus() + if (device == null) { exits = listOf() reliability = null @@ -1100,6 +1167,65 @@ fun logVerbosityRecordsDestinations(level: Long?): Boolean = else -> false } +/** + * The control-plane address family policy, as the java `long` gobind binds the + * sdk's Go `int` to (`Sdk.IpFamilyPolicyAuto`, `IpFamilyPolicyForce4`, + * `IpFamilyPolicyForce6`). + * + * The service publishes both an A and an AAAA record for its api and its + * control websocket, and the AAAA is in a tunnel-brokered range some ISPs + * route badly: such a path completes the tcp handshake and then drops the + * larger tls handshake, so Happy Eyeballs picks it, declares it the winner and + * stalls. The sdk demotes a family that fails that way on its own; a force is + * the override for when it does not. + */ +const val IP_FAMILY_AUTO = 0L + +/** Control-plane dials use IPv4 only. */ +const val IP_FAMILY_FORCE_4 = 1L + +/** Control-plane dials use IPv6 only. */ +const val IP_FAMILY_FORCE_6 = 2L + +/** + * Anything the sdk would not recognise is Automatic, matching what the sdk + * itself does with an out-of-range value rather than throwing. + */ +fun clampIpFamilyPolicy(policy: Long): Long = when (policy) { + IP_FAMILY_FORCE_4 -> IP_FAMILY_FORCE_4 + IP_FAMILY_FORCE_6 -> IP_FAMILY_FORCE_6 + else -> IP_FAMILY_AUTO +} + +/** Automatic first, so a tap always returns to the safe default. */ +fun nextIpFamilyPolicy(policy: Long): Long = when (clampIpFamilyPolicy(policy)) { + IP_FAMILY_AUTO -> IP_FAMILY_FORCE_4 + IP_FAMILY_FORCE_4 -> IP_FAMILY_FORCE_6 + else -> IP_FAMILY_AUTO +} + +/** The name shown for a policy. */ +fun ipFamilyNameResource(policy: Long): Int = when (clampIpFamilyPolicy(policy)) { + IP_FAMILY_FORCE_4 -> R.string.dev_ip_family_force4 + IP_FAMILY_FORCE_6 -> R.string.dev_ip_family_force6 + else -> R.string.dev_ip_family_auto +} + +/** + * The detail resource for a policy. `status` is the sdk's demotion + * description and is empty when nothing is demoted; it is reported only under + * Automatic, because a force does not consult the ledger and naming a demotion + * beside one would describe state that is not in effect. + */ +fun ipFamilyDetailResource(policy: Long, status: String): Int = + when (clampIpFamilyPolicy(policy)) { + IP_FAMILY_FORCE_4 -> R.string.dev_ip_family_force4_detail + IP_FAMILY_FORCE_6 -> R.string.dev_ip_family_force6_detail + else -> + if (status.isEmpty()) R.string.dev_ip_family_auto_detail + else R.string.dev_ip_family_auto_demoted_detail + } + /** * A plain-kotlin snapshot of one row of the log inventory. * diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml index 2753e6f34..ba73a7e71 100644 --- a/app/app/src/main/res/values/strings.xml +++ b/app/app/src/main/res/values/strings.xml @@ -192,6 +192,14 @@ How long into a bench a site\'s new connections keep following their exit — early benches are usually false alarms; one that lasts is trending toward removal and stops collecting flows. Off scatters immediately State heartbeat How often one line summarizing live state is written to the log for later forensics. Off silences it; shorter spots a transition, longer keeps more buffer + Control connections + Automatic + Automatic. %1$s. + Uses whichever family connects first, and routes around one that fails after connecting. + Force IPv4 + Control-plane connections use IPv4 only. Turn this off on an IPv6-only network. + Force IPv6 + Control-plane connections use IPv6 only. Turn this off if the app cannot reach the server. Load corroboration Extra silent destinations required per this many flows before a busy exit can be benched on soft evidence: a 24-flow exit at 8 needs 3 silent sites, not 2. Off keeps the flat minimum Log detail diff --git a/app/app/src/test/java/com/bringyour/network/ui/settings/IpFamilyTest.kt b/app/app/src/test/java/com/bringyour/network/ui/settings/IpFamilyTest.kt new file mode 100644 index 000000000..a65545ab8 --- /dev/null +++ b/app/app/src/test/java/com/bringyour/network/ui/settings/IpFamilyTest.kt @@ -0,0 +1,50 @@ +package com.bringyour.network.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class IpFamilyTest { + + @Test + fun theValuesAreTheOnesTheSdkDefines() { + // Sdk.IpFamilyPolicyAuto / Force4 / Force6 (sdk/sdk.go). Every other + // assertion here is expressed in terms of these constants, so without + // this one the whole file still passes with FORCE_4 and FORCE_6 + // swapped -- the row would force the wrong family, and Automatic + // would be unreachable, with a green test suite. The literals are + // written out so a reviewer can diff them against the Go source. + // + // Literals rather than Sdk.IpFamilyPolicy*: this is a JVM unit test + // and touching the gomobile class would try to load gojni. + assertEquals(0L, IP_FAMILY_AUTO) + assertEquals(1L, IP_FAMILY_FORCE_4) + assertEquals(2L, IP_FAMILY_FORCE_6) + } + + @Test + fun clampsOutOfRangeToAuto() { + assertEquals(IP_FAMILY_AUTO, clampIpFamilyPolicy(-1L)) + assertEquals(IP_FAMILY_AUTO, clampIpFamilyPolicy(7L)) + assertEquals(IP_FAMILY_FORCE_4, clampIpFamilyPolicy(IP_FAMILY_FORCE_4)) + assertEquals(IP_FAMILY_FORCE_6, clampIpFamilyPolicy(IP_FAMILY_FORCE_6)) + } + + @Test + fun cyclesAutoForce4Force6AndBack() { + assertEquals(IP_FAMILY_FORCE_4, nextIpFamilyPolicy(IP_FAMILY_AUTO)) + assertEquals(IP_FAMILY_FORCE_6, nextIpFamilyPolicy(IP_FAMILY_FORCE_4)) + assertEquals(IP_FAMILY_AUTO, nextIpFamilyPolicy(IP_FAMILY_FORCE_6)) + } + + // Parity with ios IpFamilyTests.autoDetailReportsALearnedDemotion: the + // detail must distinguish auto-with-nothing-learned from + // auto-with-a-demotion, or the row looks the same either way. + @Test + fun autoDetailResourceDiffersWhenSomethingIsDemoted() { + assertNotEquals( + ipFamilyDetailResource(IP_FAMILY_AUTO, status = ""), + ipFamilyDetailResource(IP_FAMILY_AUTO, status = "IPv6 demoted for 4m (2 strikes)"), + ) + } +}