From 898e54507b44a4f0c6128252124840747de92a07 Mon Sep 17 00:00:00 2001 From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:14:51 +0200 Subject: [PATCH] Gate DoH retries and keep-alives on screen state Screen-off DoH queries used to run the full retry loop and leave an idle TLS keep-alive socket behind after every lookup. During doze a retry is a second radio wakeup for a query that is already failing, and an idle pooled socket can be reset by the server mid-doze, waking the radio. While the screen is off: skip retries, and evict the connection pool after each query so nothing idle survives. Screen-on restores retries and normal pooling. The response cache is intentionally preserved - it is most valuable at night, when queries repeat against a dozing device. --- .../eu/faircode/netguard/ServiceSinkhole.java | 12 ++++---- .../dns/DnsOverHttpsClient.java | 29 ++++++++++++++----- .../missioncontrol/dns/DnsProxyServer.java | 19 +++++++----- .../dns/DnsOverHttpsClientTest.java | 18 ++++++++++++ 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java index 9790afb6e..9fb5215b0 100644 --- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java +++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java @@ -2819,11 +2819,13 @@ public void onStatsInteractiveStateChanged(boolean interactive) { } }); - // On screen-off, drop idle DoH keep-alive sockets so a - // server-side reset during doze can't wake the radio. - if (!last_interactive) - net.kollnig.missioncontrol.dns.DnsProxyServer - .getInstance(ServiceSinkhole.this).onScreenOff(); + // Screen state gates the DoH battery policy: while the + // screen is off the proxy drops retries and idle + // keep-alive sockets so a server-side reset during doze + // can't wake the radio. + net.kollnig.missioncontrol.dns.DnsProxyServer + .getInstance(ServiceSinkhole.this) + .onScreenStateChanged(last_interactive); } catch (Throwable ex) { Log.e(TAG, ex.toString() + "\n" + Log.getStackTraceString(ex)); diff --git a/app/src/main/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClient.java b/app/src/main/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClient.java index 47879ba23..2bc9e3100 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClient.java +++ b/app/src/main/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClient.java @@ -70,6 +70,9 @@ public class DnsOverHttpsClient { }); private static DnsOverHttpsClient instance; private static Cache responseCache; + // Screen-off DoH battery policy: while the device is dozing we skip retries + // and evict keep-alive sockets so a server-side reset can't wake the radio. + private static volatile boolean screenOff = false; private final OkHttpClient client; private final String endpoint; @@ -134,13 +137,14 @@ public static synchronized void resetInstance() { } /** - * Evict idle keep-alive connections from the current client, if any. Called on - * screen-off so an idle pooled TLS socket cannot be reset by the server during - * doze and wake the radio. In-flight requests are unaffected. No-op if no - * client has been created yet. + * Apply the screen-state DoH battery policy. While the screen is off the + * client drops retries and evicts keep-alive connections so an idle pooled + * TLS socket cannot be reset by the server during doze and wake the radio. + * In-flight requests are unaffected. No-op if no client has been created yet. */ - public static synchronized void evictIdleConnections() { - if (instance != null) { + public static synchronized void setScreenOff(boolean off) { + screenOff = off; + if (off && instance != null) { instance.evictIdle(); } } @@ -191,7 +195,11 @@ public byte[] resolve(@NonNull byte[] dnsQuery) { Request request = buildRequest(endpoint, dnsQuery); - for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + // Screen off: do not retry. A second round trip would double the radio + // wakeups during doze for a query that is already failing. + int maxRetries = screenOff ? 0 : MAX_RETRIES; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { try { Thread.sleep(RETRY_DELAY_MS); @@ -228,6 +236,13 @@ public byte[] resolve(@NonNull byte[] dnsQuery) { } } catch (IOException e) { Log.e(TAG, "DoH request failed: " + e.getMessage()); + } finally { + // Screen off: never leave an idle keep-alive socket behind — a + // server-side reset during doze would wake the radio. Cache + // hits are unaffected (no connection is created for them). + if (screenOff) { + evictIdle(); + } } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/dns/DnsProxyServer.java b/app/src/main/java/net/kollnig/missioncontrol/dns/DnsProxyServer.java index 66c81c73e..605b4f361 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/dns/DnsProxyServer.java +++ b/app/src/main/java/net/kollnig/missioncontrol/dns/DnsProxyServer.java @@ -113,6 +113,10 @@ public synchronized void start() { // Start the main listener thread new Thread(this::runServer, "DnsProxyServer").start(); + // Sync the screen-state policy so a start mid-doze (e.g. a network + // reload at night) doesn't inherit the screen-on behaviour. + DnsOverHttpsClient.setScreenOff(!eu.faircode.netguard.Util.isInteractive(context)); + Log.i(TAG, "DNS proxy server started on " + DNS_PROXY_ADDRESS + ":" + DNS_PROXY_PORT); // Start TCP server only if enabled (still in testing) @@ -166,16 +170,17 @@ public synchronized void stop() { } /** - * Called when the screen turns off. Evicts idle keep-alive HTTPS connections - * so an idle pooled TLS socket can't be reset by the server mid-doze and wake - * the radio. In-flight requests keep their connections. The response cache is - * intentionally preserved — it is most valuable precisely while the screen is - * off. No-op when the proxy is not running. + * Apply the screen-state DoH battery policy. While the screen is off the + * DoH client drops retries and evicts idle keep-alive HTTPS connections so + * a server-side reset mid-doze can't wake the radio. In-flight requests + * keep their connections; the response cache is intentionally preserved — + * it is most valuable precisely while the screen is off. No-op when the + * proxy is not running. */ - public void onScreenOff() { + public void onScreenStateChanged(boolean interactive) { if (!running.get()) return; - DnsOverHttpsClient.evictIdleConnections(); + DnsOverHttpsClient.setScreenOff(!interactive); } /** diff --git a/app/src/test/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClientTest.java b/app/src/test/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClientTest.java index bae187779..a7357c067 100644 --- a/app/src/test/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClientTest.java +++ b/app/src/test/java/net/kollnig/missioncontrol/dns/DnsOverHttpsClientTest.java @@ -46,6 +46,7 @@ public class DnsOverHttpsClientTest { @Before public void setUp() throws IOException { + DnsOverHttpsClient.setScreenOff(false); DnsOverHttpsClient.resetInstance(); server = new MockWebServer(); server.start(); @@ -53,6 +54,7 @@ public void setUp() throws IOException { @After public void tearDown() throws IOException { + DnsOverHttpsClient.setScreenOff(false); DnsOverHttpsClient.resetInstance(); server.close(); } @@ -102,6 +104,22 @@ public void resolveRetriesServerErrorsThenGivesUp() { assertEquals(3, server.getRequestCount()); } + @Test + public void resolveDoesNotRetryWhenScreenOff() { + DnsOverHttpsClient.setScreenOff(true); + try { + server.enqueue(dnsResponse(503, new byte[0])); + + assertNull(client().resolve(QUERY)); + + // A retry would be a second radio wakeup during doze; screen-off + // resolution must give up after the first failed round trip. + assertEquals(1, server.getRequestCount()); + } finally { + DnsOverHttpsClient.setScreenOff(false); + } + } + @Test public void resolveRetriesInvalidShortDnsResponse() { server.enqueue(dnsResponse(200, new byte[] { 1, 2, 3 }));