Stop asking about Private DNS during onboarding, notify when it breaks - #711
Conversation
Blocking DoT on port 853 is on by default, so Private DNS on "automatic" now resolves itself: Android falls back to plaintext and tracker detection carries on. The onboarding slide asks the user to go turn a setting off that no longer costs them anything, in the middle of the one flow where attention is scarcest. "Hostname" mode is the case that still breaks, and it breaks harder than the slide ever conveyed: Android does not fall back to plaintext for a resolver the user pinned by name, so DNS simply fails and nothing loads. Onboarding is also the wrong place to say so — it fires once, before the user has any traffic to lose, and says nothing to whoever turns Private DNS on next week. Notify from where the tunnel is built instead, so the reason arrives when the failure does, naming the configured resolver and opening the network settings that hold the switch. Clear that notification and the local network one on VPN stop, but not on a temporary one: re-posting a cancelled notification alerts again, and these two describe configuration that has not changed, so cancelling on every incoming call buzzes the user each time the VPN returns. The WireGuard error notification wants the opposite, once it can retract itself. stopInternal() never cleared lastError, and the state listener checks lastError before isRunning — deliberately, so a start that fails without ever producing a tunnel still reports — so a stopped tunnel went on reporting its final error and the listener's isRunning branch could never run. Clear it there, and the listener retracts the notification as the tunnel goes down, which is more accurate than holding a tunnel error over a tunnel that no longer exists. The call in stop() stays for the case the listener cannot see, a failed start with no tunnel to tear down. Util.isPrivateDns() had no callers left once the slide went; the notification uses getPrivateDnsSpecifier(), which is non-null only in the mode that actually breaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewRead the whole diff against The core reasoning holds up. I checked the two claims the diff leans hardest on:
Findings, roughly in order of how much they matter: 1. The notification is the only surface, and it can be silently unavailable
#709 handled the same shape differently: the local-network warning is a notification and a row in 2. Detection only runs when the tunnel is (re)built
3.
|
isPrivateDnsHostnameMode() checks private_dns_mode directly, so a resolver that reads back null for any reason no longer silently suppresses the warning in exactly the configuration that is broken; the specifier is now read once and passed through instead of a second Settings lookup, and a missing specifier falls back to generic text instead of interpolating "null". Also drops the onboarding_privatedns_* strings left behind in seven locale files after the onboarding slide was removed.
|
Pushed a follow-up commit addressing findings #3 and #5 from the review above:
Findings #1, #2, and #4 are left as-is (design/verification questions rather than straightforward fixes) — flagging again in case you want them addressed before merge. |
Device verification (Pixel 8, Android 16,
|
Correction to finding #2: no observer needed, and the gap is narrower than I saidTwo things I got wrong above, both worth recording. The signal is already delivered —
|
global private_dns_mode |
private DNS fields in the app-visible LinkProperties |
|---|---|
hostname |
UsePrivateDns: true PrivateDnsServerName: dns.google |
off |
(absent) |
opportunistic |
(absent, until opportunistic validation succeeds) |
TrackerControl's own onLinkPropertiesChanged log printed those fields, so a plain app sees them. The reason a Private DNS change produces no reload today is entirely this, in NetworkReloadPolicy.onLinkPropertiesChanged:
if (compareDns ? !same(lastDns, currentDns) : reloadOnConnectivity)It compares getDnsServers() and nothing else, so a private DNS change — which leaves the resolver list untouched — is dropped on the floor. Threading getPrivateDnsServerName() (or isPrivateDnsActive()) through that comparison is a few lines, costs no new registration, and drops straight into the existing pure-JVM NetworkReloadPolicyTest, which already covers this exact function.
Most of the ways a user would notice already work
I overstated the gap. Walking the reload call sites:
- Toggling the VPN in-app →
start()→getBuilder()→ notification. Device-verified. - Changing any TrackerControl setting →
ServiceSinkhole.reload(...)→getBuilder()→ notification. There are dozens of these call sites inActivitySettingsalone. - Any network change → reload → notification.
- Merely opening the app → no.
ActivityMain.onResume()doesn't reload; it only re-evaluatestvNotificationsandtvLocalNetwork.
So the genuinely uncovered window is narrow: VPN already running, user pins a resolver in Android settings, and then never opens TrackerControl, changes no setting, and doesn't change network. Real, but much smaller than "the notification only fires at tunnel build".
These two fixes are the same fix as #1
ActivityMain.onResume() is already the place where tvLocalNetwork is re-evaluated on every resume — no observer, no service involvement. A Private DNS row alongside it would cover the "user just opens the app" case and be the in-app surface from finding #1, which also survives the user declining notification permission. Between that row and the LinkProperties comparison, nothing needs to watch anything.
🤖 Generated with Claude Code
|
Both fixes from the discussion above are now on the branch (496f308), test-first. Reload on a Private DNS change. In-app row. The shared condition is
🤖 Generated with Claude Code |
Blocking DoT on port 853 is on by default, so Private DNS on "automatic" now resolves itself: Android falls back to plaintext and tracker detection carries on. The onboarding slide asks the user to go turn a setting off that no longer costs them anything, in the middle of the one flow where attention is scarcest. It goes.
"Hostname" mode is the case that still breaks, and it breaks harder than the slide ever conveyed: Android does not fall back to plaintext for a resolver the user pinned by name, so DNS simply fails and nothing loads. Onboarding is also the wrong place to say so — it fires once, before the user has any traffic to lose, and says nothing to whoever turns Private DNS on next week. So notify from
getBuilder(), where the tunnel is built, and the reason arrives when the failure does. The text names the configured resolver, and tapping opens the network settings that hold the switch. It re-evaluates on every VPN rebuild, so it clears itself as soon as the setting changes, and it is gated onblock_dot— research mode, which turns that off, never sees it.Notification lifetimes
Clear the Private DNS and local network notifications on VPN stop, but not on a temporary one. Re-posting a cancelled notification alerts again (
setOnlyAlertOnceonly suppresses re-alerts while a notification is still active), and both describe configuration that has not changed, so cancelling on every incoming call would buzz the user each time the VPN returns.The WireGuard error notification wants the opposite treatment, once it can retract itself.
stopInternal()never clearedlastError, and the state listener checkslastErrorbeforeisRunning()— deliberately, so a start that fails without ever producing a tunnel still reports — so a stopped tunnel went on reporting its final error, and the listener'sisRunningbranch could never run. Clearing it there lets the listener retract the notification as the tunnel goes down, which is more accurate than holding a tunnel error over a tunnel that no longer exists. The call instop()stays for the one case the listener cannot see: a failed start with no tunnel to tear down, whereWgEgress.stop()finds nothing to notify about.Reordering the listener's two checks would have been the smaller diff and is wrong — a failed
startTunnel()setslastErrorwhiletunnelis still null, so checkingisRunning()first would silently swallow genuine start failures.Also
Util.isPrivateDns()had no callers left once the slide went, so it is removed. The notification usesgetPrivateDnsSpecifier(), which is non-null only in the mode that actually breaks.Testing
compileGithubDebugJavaWithJavac,lintGithubDebug,testGithubDebugUnitTest, andcargo testinwgbridge-rsall pass. Not yet exercised on a device — the notification paths in particular are unverified against a real "hostname"-mode resolver.🤖 Generated with Claude Code