Merge pull request #466 from greenart7c3/claude/wizardly-ramanujan-i240dv

Implement relay backoff and battery optimization
This commit is contained in:
greenart7c3
2026-06-12 10:10:13 -03:00
committed by GitHub
6 changed files with 116 additions and 23 deletions
@@ -46,8 +46,13 @@ object HttpClientManager {
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
/** How often OkHttp should send WebSocket pings to detect half-closed connections. */
private val PING_INTERVAL: Duration = Duration.ofSeconds(30L)
/**
* How often OkHttp should send WebSocket pings to detect half-closed connections.
* Kept relatively long to avoid waking the radio every few seconds on the
* always-on relay connection; it only needs to be short enough to surface a
* silent half-close as an explicit failure within a reasonable window.
*/
private val PING_INTERVAL: Duration = Duration.ofSeconds(90L)
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
private var defaultHttpClient: OkHttpClient? = null
@@ -17,6 +17,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -55,6 +56,21 @@ class NostrClientLoggerListener(
private var reconnectDelay = 5_000L
private var lastDisconnectTime = 0L
// 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
// successful connection (onConnected) or a network change / manual reconnect.
private fun scheduleReconnect(relay: NormalizedRelayUrl) {
if (!RelayHealthTracker.recordFailure(relay)) {
if (BuildConfig.DEBUG) {
Log.d(Amber.TAG, "Relay ${relay.url} marked dead; skipping reconnect")
}
return
}
reconnectWithBackoff()
}
private fun reconnectWithBackoff() {
val now = System.currentTimeMillis()
if (now - lastDisconnectTime > 60_000) {
@@ -115,7 +131,7 @@ class NostrClientLoggerListener(
return
}
reconnectWithBackoff()
scheduleReconnect(relay.url)
super.onCannotConnect(relay, errorMessage)
}
@@ -166,7 +182,7 @@ class NostrClientLoggerListener(
return
}
reconnectWithBackoff()
scheduleReconnect(relay.url)
super.onDisconnected(relay)
}
@@ -179,6 +195,9 @@ class NostrClientLoggerListener(
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
if (BuildConfig.DEBUG) Log.d(Amber.TAG, "onConnected: ${relay.url.url} ping: ${pingMillis}ms compressed: $compressed")
saveLog(relay.url.url, "onConnected", "Connected")
// Relay recovered: clear its failure streak so it is eligible for the
// normal reconnect-with-backoff path (and the subscription) again.
RelayHealthTracker.recordSuccess(relay.url)
super.onConnected(relay, pingMillis, compressed)
}
}
@@ -0,0 +1,45 @@
package com.greenart7c3.nostrsigner.relays
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.util.concurrent.ConcurrentHashMap
/**
* Tracks consecutive connection failures per relay so the app can stop retrying
* 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
* 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.
*
* A relay is considered dead after [MAX_RECONNECT_ATTEMPTS] consecutive failures.
* Dead relays are excluded from the subscription relay sets so the pool drops
* them entirely. The streak is cleared when a relay connects successfully
* ([recordSuccess]), and [reset] is called on every OS network change and on a
* manual reconnect so previously-dead relays get a fresh chance.
*/
object RelayHealthTracker {
private const val MAX_RECONNECT_ATTEMPTS = 10
private val failureCounts = ConcurrentHashMap<NormalizedRelayUrl, Int>()
/** Records a failed connection attempt. Returns true while the relay is still worth retrying. */
fun recordFailure(relay: NormalizedRelayUrl): Boolean {
val failures = failureCounts.merge(relay, 1, Int::plus) ?: 1
return failures <= MAX_RECONNECT_ATTEMPTS
}
/** Clears the failure streak after a successful connection. */
fun recordSuccess(relay: NormalizedRelayUrl) {
failureCounts.remove(relay)
}
/** True once a relay has failed [MAX_RECONNECT_ATTEMPTS] times in a row without recovering. */
fun isDead(relay: NormalizedRelayUrl): Boolean = (failureCounts[relay] ?: 0) > MAX_RECONNECT_ATTEMPTS
/** Forgets all failure history so every relay is eligible to be retried again. */
fun reset() {
failureCounts.clear()
}
}
@@ -15,6 +15,7 @@ import com.greenart7c3.nostrsigner.BuildConfig
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import com.greenart7c3.nostrsigner.relays.RelayHealthTracker
import java.util.Timer
import java.util.TimerTask
import kotlin.collections.set
@@ -37,6 +38,9 @@ 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.
RelayHealthTracker.reset()
scope.launch(Dispatchers.IO) {
if (!Amber.instance.client.isActive()) {
Amber.instance.client.connect()
@@ -58,6 +62,10 @@ class ConnectivityService : Service() {
if (Amber.instance.settings.killSwitch.value) return
val changed = Amber.instance.updateNetworkCapabilities(networkCapabilities)
if (changed) {
// Transport changed (e.g. wifi <-> mobile): retry dead relays.
RelayHealthTracker.reset()
}
scope.launch(Dispatchers.IO) {
Log.d(
@@ -25,6 +25,7 @@ import android.util.Log
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.BuildFlavorChecker
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.relays.RelayHealthTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
@@ -90,11 +91,19 @@ class NotificationSubscription(
val connPubKey = conn.localPubKey
indexEntries[connPubKey] = LocalKeyAccountIndex.Match(account.npub, conn.localKey)
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
// RelayHealthTracker is reset (network change / manual reconnect) or
// they connect successfully again.
val connRelays = conn.relays.ifEmpty { Amber.instance.getSavedRelays(account) }
.filterNot { RelayHealthTracker.isDead(it) }
if (connRelays.isEmpty()) continue
activeSubKeys.add(subKey)
if (!subIds.containsKey(subKey)) {
subIds[subKey] = UUID.randomUUID().toString()
}
val connRelays = conn.relays.ifEmpty { Amber.instance.getSavedRelays(account) }
client.subscribe(
subIds[subKey]!!,
connRelays.associateWith {
@@ -112,24 +121,27 @@ class NotificationSubscription(
// Main account subscription only for legacy connections (no localKey)
if (hasLegacyConnections) {
activeSubKeys.add(account.hexKey)
if (!subIds.containsKey(account.hexKey)) {
subIds[account.hexKey] = UUID.randomUUID().toString()
}
val relays = Amber.instance.getSavedRelays(account)
client.subscribe(
subIds[account.hexKey]!!,
relays.associateWith {
listOf(
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(account.hexKey)),
limit = 1,
since = since,
),
)
},
)
.filterNot { RelayHealthTracker.isDead(it) }
if (relays.isNotEmpty()) {
activeSubKeys.add(account.hexKey)
if (!subIds.containsKey(account.hexKey)) {
subIds[account.hexKey] = UUID.randomUUID().toString()
}
client.subscribe(
subIds[account.hexKey]!!,
relays.associateWith {
listOf(
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(account.hexKey)),
limit = 1,
since = since,
),
)
},
)
}
}
}
@@ -4,12 +4,16 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.relays.RelayHealthTracker
import kotlinx.coroutines.launch
class ReconnectReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Amber.instance.applicationIOScope.launch {
Amber.instance.reconnect()
// Manual reconnect: clear dead-relay state and re-run the filters so any
// relay previously dropped from the pool is re-added before reconnecting.
RelayHealthTracker.reset()
Amber.instance.checkForNewRelaysAndUpdateAllFilters(shouldReconnect = true)
}
}
}