Developer control for the control-plane IP family policy - #477
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Android half of the control-plane IPv4/IPv6 policy, and the parity counterpart to urnetwork/apple#339. Depends on urnetwork/sdk#152, which depends on urnetwork/connect#207.
Why
Some users' ISPs route badly to the service's Hurricane Electric IPv6 address. The path connects and then stalls in TLS, which Happy Eyeballs cannot see because it races only the TCP handshake. connect learns and routes around it; this is the manual override.
What this adds
A tri-state Control connections row in the Developer screen, cycling Automatic → Force IPv4 → Force IPv6, modelled on
DeveloperVerbositySettingbeside it including its read-back discipline — the value shown is what the SDK reports, not what was last tapped.Two things it deliberately does differently from that row:
It is live with no device. The verbosity row is
clickable(enabled = level != null)because there is nothing to set a level on without one. This row reads and writes process-global SDK state that is always answerable, and it must work signed out and with the tunnel down — those are exactly the states a user is in when the API is unreachable. It also sits before the!connectedguard, so it does not vanish when disconnected.A three-way write fallback.
DeviceManager.networkSpaceisdevice?.networkSpace, so with no device there is no space either — a two-way "device else space" fallback would be dead in precisely the signed-out state the row exists for.DeveloperViewModelnow also injectsNetworkSpaceManagerProvider(asLeaderboardViewModel,ProfileViewModelandAccountViewModelalready do) and falls back device → space →Sdk.setControlIpFamilyPolicy.The detail line reports what Automatic has learned, e.g. "Automatic — IPv6 demoted for 4m (2 strikes)", so Auto does not look identical whether the heuristic has fired or not.
Scope
Four files only. The design and plan documents that accompanied this work in the fork are deliberately excluded — this repo has no
docs/tree, and that spec references a fork branch by name.strings.xmlhad moved upstream since the fork base; the new strings were integrated rather than overwritten, with no duplicate names.No JDK, Android SDK, NDK or working gradle wrapper was available in the environment that produced this, and I did not install one. Everything is a reading-level check: symbol resolution, import presence (notably
com.bringyour.network.RinDeveloperViewModel.kt, which the newR.string-returning helpers require), delimiter balance,R.stringexistence, and signature matching against whaturnetwork/sdkactually exports in Go.CI is this code's first real compile. That is stated plainly rather than dressed up — please treat the CI result as the verification, not this description.
One consequence worth knowing:
app/build.gradlepulls the SDK from a sibling checkout's AAR rather than a pinned artifact, so this branch cannot compile until urnetwork/sdk#152 lands and an AAR is built from it.One Kotlin-level risk not rulable out by reading:
ipFamilyPolicyis initialised viaSdk.getControlIpFamilyPolicy()in a property initialiser, which forcesgojnito load atDeveloperViewModelconstruction. That mirrors the existing pattern in this file and is harmless in-app, but it is an inspection-level claim.🤖 Generated with Claude Code
https://claude.ai/code/session_014vN7zh8WeQYCtirhcA3aWw