Skip to content

Developer control for the control-plane IP family policy - #477

Merged
Ryanmello07 merged 1 commit into
urnetwork:mainfrom
Ryanmello07:upstream/ip-family
Sep 3, 2026
Merged

Developer control for the control-plane IP family policy#477
Ryanmello07 merged 1 commit into
urnetwork:mainfrom
Ryanmello07:upstream/ip-family

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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 DeveloperVerbositySetting beside 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:

  1. 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 !connected guard, so it does not vanish when disconnected.

  2. A three-way write fallback. DeviceManager.networkSpace is device?.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. DeveloperViewModel now also injects NetworkSpaceManagerProvider (as LeaderboardViewModel, ProfileViewModel and AccountViewModel already 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.xml had moved upstream since the fork base; the new strings were integrated rather than overwritten, with no duplicate names.

⚠️ Verification: not compiled

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.R in DeveloperViewModel.kt, which the new R.string-returning helpers require), delimiter balance, R.string existence, and signature matching against what urnetwork/sdk actually 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.gradle pulls 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: ipFamilyPolicy is initialised via Sdk.getControlIpFamilyPolicy() in a property initialiser, which forces gojni to load at DeveloperViewModel construction. 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

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.
@Ryanmello07
Ryanmello07 merged commit 6240f80 into urnetwork:main Sep 3, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant