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 <[email protected]>
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 */
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()
}
}
@@ -183,7 +185,17 @@ class RuIpListManager private constructor(private val context: Context)
return try
{
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())
{
throw IOException("downloaded list contains no CIDRs")
@@ -195,6 +207,7 @@ class RuIpListManager private constructor(private val context: Context)
prefs.edit()
.putLong(PREF_LAST_UPDATE, now)
.putString(PREF_SHA256, sha256)
.remove(PREF_LAST_FAIL)
.apply()
publish(
status = RuIpListStatus.UP_TO_DATE,
@@ -213,6 +226,8 @@ class RuIpListManager private constructor(private val context: Context)
catch (e: Exception)
{
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)
false
}
@@ -224,14 +239,16 @@ class RuIpListManager private constructor(private val context: Context)
/* ------------------------------ internals ------------------------------ */
private fun loadFromDisk()
private suspend fun loadFromDisk()
{
val file = listFile()
if (file.isFile)
{
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
excludedV6 = parsed.v6
publish(
@@ -257,8 +274,8 @@ class RuIpListManager private constructor(private val context: Context)
{
return
}
val last = prefs.getLong(PREF_LAST_UPDATE, 0L)
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS)
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
prefs.getLong(PREF_LAST_FAIL, 0L)))
{
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 PREF_LAST_UPDATE = "rusplit_last_update"
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 CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 30_000
@@ -290,4 +290,20 @@ class RuIpListManagerTest
assertThat(fit).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()
}
}