android: RU list - no reconnect for an unchanged list, failure backoff

- same SHA-256 as the applied list: only bump the timestamp, no parse,
  save or tunnel reconnect (it tore a healthy tunnel down daily)
- a failed download is not retried on every cold start (3 h backoff)
- the saved list is read and parsed off the main thread at startup
- switching the split off in system mode reconnects to drop the routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:12:22 +07:00
1 parent cf89cec20e
commit 1eb1dfe317
2 files changed
+55 -7

No files matched your search

@@ -155,8 +155,10 @@ class RuIpListManager private constructor(private val context: Context)
* classification set is hot-swapped in native code instead */ * classification set is hot-swapped in native code instead */
CharonVpnService.pushUserspaceRoutingData(context) CharonVpnService.pushUserspaceRoutingData(context)
} }
else if (enabled) else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
{ { /* routes only change when the TUN is re-established - also when
* the split is switched OFF, or the RU ranges keep bypassing the
* tunnel until the next manual reconnect */
scheduleReconnect() scheduleReconnect()
} }
} }
@@ -183,7 +185,17 @@ class RuIpListManager private constructor(private val context: Context)
return try return try
{ {
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() } val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
val parsed = withContext(Dispatchers.IO) { parse(text) } if (sha256 == prefs.getString(PREF_SHA256, null) &&
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
{ /* identical list: nothing to parse, save or re-apply - above
* all no reconnect that tears down a healthy tunnel daily */
val now = System.currentTimeMillis()
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
Log.i(TAG, "RU geoip list unchanged")
return true
}
val parsed = withContext(Dispatchers.Default) { parse(text) }
if (parsed.v4.isEmpty() && parsed.v6.isEmpty()) if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
{ {
throw IOException("downloaded list contains no CIDRs") throw IOException("downloaded list contains no CIDRs")
@@ -195,6 +207,7 @@ class RuIpListManager private constructor(private val context: Context)
prefs.edit() prefs.edit()
.putLong(PREF_LAST_UPDATE, now) .putLong(PREF_LAST_UPDATE, now)
.putString(PREF_SHA256, sha256) .putString(PREF_SHA256, sha256)
.remove(PREF_LAST_FAIL)
.apply() .apply()
publish( publish(
status = RuIpListStatus.UP_TO_DATE, status = RuIpListStatus.UP_TO_DATE,
@@ -213,6 +226,8 @@ class RuIpListManager private constructor(private val context: Context)
catch (e: Exception) catch (e: Exception)
{ {
Log.w(TAG, "RU geoip list update failed", e) Log.w(TAG, "RU geoip list update failed", e)
/* remembered so a blocked source is not retried on every cold start */
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
publish(status = RuIpListStatus.ERROR) publish(status = RuIpListStatus.ERROR)
false false
} }
@@ -224,14 +239,16 @@ class RuIpListManager private constructor(private val context: Context)
/* ------------------------------ internals ------------------------------ */ /* ------------------------------ internals ------------------------------ */
private fun loadFromDisk() private suspend fun loadFromDisk()
{ {
val file = listFile() val file = listFile()
if (file.isFile) if (file.isFile)
{ {
try try
{ {
val parsed = parse(file.readText()) /* thousands of CIDRs: read and parse off the main thread (this
* runs at every process start) */
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
excludedV4 = parsed.v4 excludedV4 = parsed.v4
excludedV6 = parsed.v6 excludedV6 = parsed.v6
publish( publish(
@@ -257,8 +274,8 @@ class RuIpListManager private constructor(private val context: Context)
{ {
return return
} }
val last = prefs.getLong(PREF_LAST_UPDATE, 0L) if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS) prefs.getLong(PREF_LAST_FAIL, 0L)))
{ {
scope.launch { updateNow() } scope.launch { updateNow() }
} }
@@ -403,6 +420,21 @@ class RuIpListManager private constructor(private val context: Context)
private const val FILE_NAME = "ru.list" private const val FILE_NAME = "ru.list"
private const val PREF_LAST_UPDATE = "rusplit_last_update" private const val PREF_LAST_UPDATE = "rusplit_last_update"
private const val PREF_SHA256 = "rusplit_sha256" private const val PREF_SHA256 = "rusplit_sha256"
private const val PREF_LAST_FAIL = "rusplit_last_fail"
/* after a failed download wait this long before the next automatic try */
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
/** Whether the automatic refresh is due (pure, unit-testable). */
@JvmStatic
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
{
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
{
return false
}
/* clock moved backwards: don't let a "future" failure block forever */
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
}
private const val RECONNECT_DEBOUNCE_MS = 2000L private const val RECONNECT_DEBOUNCE_MS = 2000L
private const val CONNECT_TIMEOUT_MS = 10_000 private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 30_000 private const val READ_TIMEOUT_MS = 30_000
@@ -290,4 +290,20 @@ class RuIpListManagerTest
assertThat(fit).isNotNull() assertThat(fit).isNotNull()
assertThat(RuIpListManager.subtractAndSplit(ranges4, fit!!.first, 6000)).isNotNull() assertThat(RuIpListManager.subtractAndSplit(ranges4, fit!!.first, 6000)).isNotNull()
} }
/* ---------------- auto check ---------------- */
@Test
fun autoCheckWaitsForTheIntervalAndBacksOffAfterFailures()
{
val day = org.strongswan.android.utils.Constants.RUSPLIT_CHECK_INTERVAL_MS
val now = 10 * day
assertThat(RuIpListManager.isAutoCheckDue(now, now - day / 2, 0L)).isFalse()
assertThat(RuIpListManager.isAutoCheckDue(now, now - day, 0L)).isTrue()
/* a recent failure blocks retries on every cold start */
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now - 60_000)).isFalse()
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now - RuIpListManager.FAIL_RETRY_MS)).isTrue()
/* a failure stamped in the future (clock change) never blocks */
assertThat(RuIpListManager.isAutoCheckDue(now, 0L, now + 60_000)).isTrue()
}
} }