From 7ddcb26dc43416d38be4b45e061e85745ba98508 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Mon, 14 Sep 2026 08:39:31 -0300 Subject: [PATCH] Reduce background battery drain from subscription tick and Tor retries The ConnectivityService safety-net tick woke the CPU every 30 seconds for the whole life of the process (~2,880 wakeups/day) even though every real state change already refreshes the subscriptions explicitly and Quartz replays subscriptions on reconnect. Raise the tick to 5 minutes; it now only covers genuinely missed paths. Bound the built-in Tor startup: when Tor cannot bootstrap (airplane mode, censored network), runMigrations previously stop/start cycled the whole daemon every ~2 minutes forever, burning battery and data until the network came back. After 5 attempts (120s bootstrap window each, backoff 3/6/12/24s) the daemon is stopped, a non-ongoing Failed notification with the Restart action is shown, and relay connections stay down instead of failing endlessly through a dead SOCKS proxy. Relay connections now recover automatically when Tor comes back: a new rising-edge observer on TorManager.isRunning refreshes subscriptions (covers manual restarts mid-session, which previously left relays disconnected), and a network change retries Tor when it had given up. --- .../nostrsigner/service/TorManager.kt | 9 ++ .../nostrsigner/service/TorManager.kt | 9 ++ .../java/com/greenart7c3/nostrsigner/Amber.kt | 104 ++++++++++++++---- .../database/CachingApplicationDao.kt | 2 +- .../relays/NostrClientLoggerListener.kt | 2 +- .../nostrsigner/relays/RelayHealthTracker.kt | 2 +- .../service/ConnectivityService.kt | 27 ++++- .../service/NotificationSubscription.kt | 5 +- .../nostrsigner/service/TorManager.kt | 4 + 9 files changed, 134 insertions(+), 30 deletions(-) diff --git a/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt index 6a016bed..d985e8df 100644 --- a/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -153,6 +153,15 @@ object TorManager { appContext?.let { showNotification(it.getString(R.string.tor_retrying)) } } + /** + * Shows the terminal failure state (used after the bounded startup retries + * in Amber.runMigrations give up). Non-ongoing so the user can swipe it + * away; the Restart action is the way back to a connection attempt. + */ + fun showFailed() { + appContext?.let { showNotification(it.getString(R.string.tor_connection_failed), ongoing = false) } + } + fun start(context: Context, scope: CoroutineScope) { if (appContext == null) { appContext = context.applicationContext diff --git a/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt index cc6437ce..3afbcecb 100644 --- a/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -153,6 +153,15 @@ object TorManager { appContext?.let { showNotification(it.getString(R.string.tor_retrying)) } } + /** + * Shows the terminal failure state (used after the bounded startup retries + * in Amber.runMigrations give up). Non-ongoing so the user can swipe it + * away; the Restart action is the way back to a connection attempt. + */ + fun showFailed() { + appContext?.let { showNotification(it.getString(R.string.tor_connection_failed), ongoing = false) } + } + fun start(context: Context, scope: CoroutineScope) { if (appContext == null) { appContext = context.applicationContext diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt index 5bd3fdc2..863716ab 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/Amber.kt @@ -160,6 +160,11 @@ class Amber : // Hoisted out of runMigrations() so re-entry (e.g. test re-init) can't // double-register and double-fire foreground/background callbacks. private var processLifecycleObserverRegistered = false + + // Guard for startTorRecoveryObserver(): runMigrations' only caller + // (ConnectivityService.onCreate) can run again after the service is + // recreated, and the observer must be registered once per process. + private var torRecoveryObserverStarted = false private val processLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { AmberLog.d("ProcessLifecycleOwner", "App in foreground") @@ -394,11 +399,6 @@ class Amber : HttpClientManager.getHttpClient(false) HttpClientManager.getHttpClient(true) - // Start Tor immediately in the background without blocking app startup - if (settings.torMode == TorMode.BUILTIN && !BuildFlavorChecker.isOfflineFlavor()) { - TorManager.start(this@Amber, applicationIOScope) - } - launch(Dispatchers.Main) { if (!processLifecycleObserverRegistered) { ProcessLifecycleOwner.get().lifecycle.addObserver(processLifecycleObserver) @@ -406,28 +406,43 @@ class Amber : } } - // Wait for Tor to be ready before establishing relay connections + // Start Tor immediately in the background without blocking app startup + var torReady = true if (settings.torMode == TorMode.BUILTIN && !BuildFlavorChecker.isOfflineFlavor()) { - var attempt = 0 - while (!TorManager.isRunning.value) { - if (attempt > 0) { - TorManager.showRetrying() - TorManager.stop() - delay(3.seconds) - TorManager.start(this@Amber, applicationIOScope) - } - attempt++ - withTimeoutOrNull(120.seconds) { - TorManager.isRunning.first { it } - } + TorManager.start(this@Amber, applicationIOScope) + + startTorRecoveryObserver() + + // Wait for Tor to be ready before establishing relay connections. + // Bounded: when Tor cannot bootstrap (airplane mode, censored + // network) an unbounded stop/start cycle restarts the whole + // daemon forever, burning battery and data for as long as the + // device stays offline. After the last attempt the daemon is + // stopped and the Failed notification (Restart action) takes + // over; startFunctions() parks on waitForTorIfNeeded() until + // Tor actually comes back. + torReady = waitForTorStartup() + if (!torReady) { + TorManager.stop() + TorManager.showFailed() + AmberLog.e( + TAG, + "Built-in Tor failed to start after $MAX_TOR_START_ATTEMPTS attempts; relays stay disconnected until it recovers", + ) } } - checkForNewRelaysAndUpdateAllFilters(true) - if (settings.killSwitch.value) { - disconnectIntentionally() + if (torReady) { + checkForNewRelaysAndUpdateAllFilters(true) + if (settings.killSwitch.value) { + disconnectIntentionally() + } + } else { + // Do not open relay sockets through a dead SOCKS proxy: every + // connect would fail and the retry churn would waste battery. + // Relays connect via the recovery observer once Tor is up. + onDone() } - onDone() } catch (e: Exception) { AmberLog.e(TAG, "Failed to run migrations", e) if (e is CancellationException) throw e @@ -436,6 +451,50 @@ class Amber : } } + /** + * Bounded Tor startup: at most [MAX_TOR_START_ATTEMPTS] full daemon starts, + * each allowed 120s to bootstrap, with a growing pause (3s, 6s, 12s, 24s) + * between attempts. Returns true as soon as the SOCKS port is up. + */ + private suspend fun waitForTorStartup(): Boolean { + var attempt = 0 + while (!TorManager.isRunning.value) { + if (attempt >= MAX_TOR_START_ATTEMPTS) return false + if (attempt > 0) { + TorManager.showRetrying() + TorManager.stop() + delay((3L shl (attempt - 1)).seconds) + TorManager.start(this@Amber, applicationIOScope) + } + attempt++ + withTimeoutOrNull(120.seconds) { + TorManager.isRunning.first { it } + } + } + return true + } + + /** + * Reconnects relays whenever built-in Tor transitions into a running state. + * Covers recovery after the bounded startup retries gave up (manual Restart + * action or a network change retried Tor) and mid-session restarts, neither + * of which runs through [runMigrations] again. + */ + private fun startTorRecoveryObserver() { + if (torRecoveryObserverStarted) return + torRecoveryObserverStarted = true + applicationIOScope.launch { + var wasRunning = TorManager.isRunning.value + TorManager.isRunning.collect { running -> + if (running && !wasRunning) { + AmberLog.d(TAG, "Built-in Tor is up; refreshing relay connections") + checkForNewRelaysAndUpdateAllFilters(true) + } + wasRunning = running + } + } + } + suspend fun waitForTorIfNeeded() { if (settings.torMode == TorMode.BUILTIN && !BuildFlavorChecker.isOfflineFlavor()) { TorManager.isRunning.first { it } @@ -730,6 +789,7 @@ class Amber : companion object { var isAppInForeground = false const val TAG = "Amber" + private const val MAX_TOR_START_ATTEMPTS = 5 lateinit var instance: Amber private set diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt index cb6a2f8a..dda2e5ea 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/database/CachingApplicationDao.kt @@ -21,7 +21,7 @@ import androidx.paging.PagingSource * [getAll] results are cached per account pubKey in a separate small LRU. * That query decrypts every application row (AndroidKeyStore AES-GCM, a binder * hop to keystore2 per field) and [com.greenart7c3.nostrsigner.service.NotificationSubscription] - * re-runs it on every ~30s relay-refresh cycle, which made it the app's + * re-runs it on every relay-refresh cycle, which made it the app's * dominant native allocator. Any mutation of the `application` table evicts * the affected account's entry (or all entries when only the app key — not * the owning account — is known). Cached lists are handed out as defensive diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt index 8d00388f..6e968a11 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/NostrClientLoggerListener.kt @@ -59,7 +59,7 @@ class NostrClientLoggerListener( // Counts the failure against the relay and only schedules a reconnect while it // is still worth retrying. Once a relay is dead, RelayHealthTracker also makes // NotificationSubscription.updateFilter drop it from the subscription relay - // set, so Quartz stops opening sockets to it every 30s. The streak resets on a + // set, so Quartz stops opening sockets to it on every refresh. The streak resets on a // successful connection (onConnected) or a network change / manual reconnect. private fun scheduleReconnect(relay: NormalizedRelayUrl) { if (!RelayHealthTracker.recordFailure(relay)) { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt index d395f35f..4370306b 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/relays/RelayHealthTracker.kt @@ -8,7 +8,7 @@ import java.util.concurrent.ConcurrentHashMap * relays that are permanently unreachable. * * Quartz's relay pool is driven by the relays referenced in active subscriptions: - * [NotificationSubscription.updateFilter] re-subscribes every 30s, and any relay + * [NotificationSubscription.updateFilter] re-subscribes on every refresh, and any relay * present in that map is (re)connected by the pool. Without this tracker an * offline relay stays in the map forever, so a socket is opened to it on every * refresh, needlessly waking the radio and draining the battery. diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt index f5cf84bc..6d77a67d 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/ConnectivityService.kt @@ -14,6 +14,7 @@ import com.greenart7c3.nostrsigner.AmberLog import com.greenart7c3.nostrsigner.BuildConfig import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences +import com.greenart7c3.nostrsigner.models.TorMode import com.greenart7c3.nostrsigner.okhttp.HttpClientManager import com.greenart7c3.nostrsigner.relays.RelayHealthTracker import java.util.Timer @@ -38,8 +39,16 @@ class ConnectivityService : Service() { if (Amber.instance.settings.killSwitch.value) return if (lastNetwork != null && lastNetwork != network) { - // New network: give previously-dead relays a fresh chance. The - // 30s updateFilter tick re-adds them to the subscription set. + // New network: give previously-dead relays a fresh chance. + // RelayHealthTracker.reset() makes updateFilter re-add them + // to the subscription set (explicit refreshes plus the + // periodic safety net below). + if (Amber.instance.settings.torMode == TorMode.BUILTIN && !TorManager.isRunning.value) { + // Built-in Tor gave up earlier (bounded startup retries + // in runMigrations). The network is back, so retry now + // instead of waiting for a manual restart. + TorManager.restart(this@ConnectivityService, Amber.instance.applicationIOScope) + } RelayHealthTracker.reset() scope.launch(Dispatchers.IO) { if (!Amber.instance.client.isActive()) { @@ -168,7 +177,7 @@ class ConnectivityService : Service() { } }, 5000, - 30000, + UPDATE_FILTER_PERIOD_MS, ) } } @@ -212,4 +221,16 @@ class ConnectivityService : Service() { } return START_STICKY } + + companion object { + /** + * How often the safety-net subscription refresh runs. Every real state + * change (login/logout, relay edits, app connect/disconnect, NIP-46 + * CONNECT, backup restore, kill switch) already refreshes the filters + * explicitly, and Quartz replays subscriptions on reconnect, so this + * tick only covers missed paths. Kept slow: it wakes the CPU even when + * nothing changed, and a 30s period prevented doze 2,880 times a day. + */ + const val UPDATE_FILTER_PERIOD_MS = 5 * 60 * 1000L + } } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationSubscription.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationSubscription.kt index d0eeebe4..0fc9ede3 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationSubscription.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/NotificationSubscription.kt @@ -87,7 +87,8 @@ class NotificationSubscription( val since = computeSince() // Cached dao: getAll() is served from CachingApplicationDao's per-account - // LRU. updateFilter re-runs on every ~30s relay refresh, and an uncached + // LRU. updateFilter re-runs on every relay refresh (explicit refreshes + // plus the periodic ConnectivityService safety net), and an uncached // getAll re-decrypts every application row through AndroidKeyStore // (a keystore2 binder round-trip per encrypted field) each cycle — // previously the app's dominant native allocator. @@ -102,7 +103,7 @@ class NotificationSubscription( val subKey = "${account.hexKey}_$connPubKey" // Exclude relays that have been declared dead so Quartz stops opening - // a socket to them on every 30s refresh. They are re-added once + // a socket to them on every refresh. They are re-added once // RelayHealthTracker is reset (network change / manual reconnect) or // they connect successfully again. val connRelays = conn.relays.ifEmpty { Amber.instance.getSavedRelays(account) } diff --git a/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt index dfe6ef65..934eb4c6 100644 --- a/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -39,6 +39,10 @@ object TorManager { // No-op in offline flavor } + fun showFailed() { + // No-op in offline flavor + } + fun cancelNotification() { // No-op in offline flavor }