Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<Exit>>(listOf())
Expand Down Expand Up @@ -82,6 +85,34 @@ class DeveloperViewModel @Inject constructor(
var logVerbosity by mutableStateOf<Long?>(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<Long>(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<String>(Sdk.getControlIpFamilyStatus())
private set

var inventory by mutableStateOf<List<LogRow>>(listOf())
private set

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down
8 changes: 8 additions & 0 deletions app/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@
<string name="dev_group_follow_window_detail">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</string>
<string name="dev_heartbeat">State heartbeat</string>
<string name="dev_heartbeat_detail">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</string>
<string name="dev_ip_family">Control connections</string>
<string name="dev_ip_family_auto">Automatic</string>
<string name="dev_ip_family_auto_demoted_detail">Automatic. %1$s.</string>
<string name="dev_ip_family_auto_detail">Uses whichever family connects first, and routes around one that fails after connecting.</string>
<string name="dev_ip_family_force4">Force IPv4</string>
<string name="dev_ip_family_force4_detail">Control-plane connections use IPv4 only. Turn this off on an IPv6-only network.</string>
<string name="dev_ip_family_force6">Force IPv6</string>
<string name="dev_ip_family_force6_detail">Control-plane connections use IPv6 only. Turn this off if the app cannot reach the server.</string>
<string name="dev_load_corroboration">Load corroboration</string>
<string name="dev_load_corroboration_detail">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</string>
<string name="dev_log_verbosity">Log detail</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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)"),
)
}
}
Loading