Merge #3704bcf5: Reduce background battery drain from subscription tick…

Reduce background battery drain from subscription tick and Tor retries

nostr:nevent1qqsrwp9u7h7mr7an3z84emd3fn0mryauytg6teyaymk59d86wq8e4gspz3mhxue69uhhyetvv9ujumn8d96zuer9wc3y9sgd

PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5

PR description:

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.
This commit is contained in:
greenart7c3
2026-09-14 09:01:27 -03:00
9 changed files with 134 additions and 30 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)) {
@@ -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.
@@ -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
}
}
@@ -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) }
@@ -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
}