Refactor subscription update background workflow

Move manual subscription updates to WorkManager-based background jobs and simplify scheduler logic by handling auto-update checks in `scheduleOne`. Add post-update server testing support with optional invalid-node cleanup and sorting, plus MMKV/AngConfig helpers to remove multiple servers and reorder subscription servers by test delay. Update the subscriptions UI flow and zh-TW strings to reflect background execution and new auto-test options.
This commit is contained in:
2dust
2026-07-20 15:16:21 +08:00
parent 1b9401fc42
commit f1eac6fb8c
9 changed files with 266 additions and 46 deletions
@@ -83,6 +83,7 @@ object AppConfig {
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION = "pref_auto_test_after_update_subscription"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
@@ -615,6 +615,41 @@ object AngConfigManager {
}
}
/**
* Removes invalid server configurations for a subscription.
*
* @param subId The subscription ID.
*/
fun removeInvalidServer(subId: String) {
val serverList = MmkvManager.decodeServerList(subId)
val invalidServers = serverList.filter {
val aff = MmkvManager.decodeServerAffiliationInfo(it)
aff != null && aff.testDelayMillis < 0L
}
MmkvManager.removeServers(invalidServers, subId)
}
/**
* Sorts servers by test results for a subscription.
*
* @param subId The subscription ID.
*/
fun sortByTestResultsForSub(subId: String) {
val serverList = MmkvManager.decodeServerList(subId)
if (serverList.isEmpty()) return
val sorted = serverList
.map { guid ->
val delay =
MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
guid to if (delay <= 0L) Long.MAX_VALUE else delay
}
.sortedBy { it.second }
.map { it.first }
.toMutableList()
MmkvManager.encodeServerList(sorted, subId)
}
/**
* Parses the configuration via a subscription.
*
@@ -242,6 +242,31 @@ object MmkvManager {
encodeServerList(serverList, subId)
}
/**
* Removes multiple server configurations from a subscription.
*
* @param guids The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/
fun removeServers(guids: List<String>, subscriptionId: String) {
if (guids.isEmpty()) return
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
if (serverList.removeAll(guids)) {
encodeServerList(serverList, subId)
}
val selectedServer = getSelectServer()
guids.forEach { guid ->
if (selectedServer == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
serverRawStorage.remove(guid)
}
}
/**
* Decodes the server affiliation information.
*
@@ -5,7 +5,9 @@ import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.multiprocess.RemoteWorkManager
@@ -13,10 +15,16 @@ import androidx.work.workDataOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.enums.NotificationChannelType
import com.v2ray.ang.service.RealPingWorkerService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -48,7 +56,6 @@ object SubscriptionUpdater {
scheduleOne(
context = context,
subId = sub.guid,
shouldRun = sub.subscription.autoUpdate,
existingWorkPolicy = existingWorkPolicy
)
}
@@ -63,11 +70,9 @@ object SubscriptionUpdater {
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
*/
fun syncOne(context: Context = AngApplication.application, subId: String) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
scheduleOne(
context = context,
subId = subId,
shouldRun = subItem.autoUpdate,
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
)
}
@@ -81,27 +86,63 @@ object SubscriptionUpdater {
.cancelUniqueWork(taskName(subId))
}
/**
* Update all subscriptions immediately in the background.
* Call from: SubscriptionsViewModel.updateSubscriptions().
*/
fun updateAllByManual(context: Context = AngApplication.application) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: manual updateAll triggered")
val rw = RemoteWorkManager.getInstance(context)
MmkvManager.decodeSubscriptions().forEach { sub ->
if (!sub.subscription.enabled || sub.subscription.url.isEmpty()) {
return@forEach
}
val subId = sub.guid
val request = OneTimeWorkRequestBuilder<UpdateTask>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(
workDataOf(
KEY_SUB_ID to subId,
)
)
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
.build()
rw.enqueueUniqueWork(
"${taskName(subId)}_manual",
ExistingWorkPolicy.KEEP,
request
)
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: enqueued manual task for ${sub.subscription.remarks} ($subId)")
}
}
// -------------------------------------------------------------------------
// Internal scheduling logic
// -------------------------------------------------------------------------
private val updateSemaphore = Semaphore(2)
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
private fun scheduleOne(
context: Context,
subId: String,
shouldRun: Boolean,
existingWorkPolicy: ExistingPeriodicWorkPolicy
) {
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val rw = RemoteWorkManager.getInstance(context)
if (!shouldRun) {
rw.cancelUniqueWork(taskName(subId))
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
if (!subItem.autoUpdate) {
cancelOne(context, subId)
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
return
}
val subItem = MmkvManager.decodeSubscription(subId) ?: return
val intervalMinutes = maxOf(
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
subItem.updateInterval
@@ -136,7 +177,7 @@ object SubscriptionUpdater {
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
@@ -151,9 +192,9 @@ object SubscriptionUpdater {
CoroutineWorker(context, params) {
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
override suspend fun doWork(): Result = updateSemaphore.withPermit {
val subId = inputData.getString(KEY_SUB_ID)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting: $subId")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
@@ -166,28 +207,106 @@ object SubscriptionUpdater {
return Result.success()
}
if (!subItem.autoUpdate) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
if (subItem.url.isEmpty()) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
return Result.success()
}
val sub = SubscriptionCache(subId, subItem)
// Notify about update start
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
showNotification(
applicationContext,
applicationContext.getString(R.string.title_pref_auto_update_subscription),
R.string.title_pref_auto_update_subscription,
"Updating ${sub.subscription.remarks}"
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(sub)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
testSubscriptionServers(applicationContext, sub)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: removing invalid servers for ${subItem.remarks}")
showNotification(
applicationContext,
R.string.title_del_invalid_config,
sub.subscription.remarks
)
AngConfigManager.removeInvalidServer(subId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: sorting servers for ${subItem.remarks}")
showNotification(
applicationContext,
R.string.title_sort_by_test_results,
sub.subscription.remarks
)
AngConfigManager.sortByTestResultsForSub(subId)
}
}
// Clear notification
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
// Reset periodic task timer to align with this successful update
syncOne(applicationContext, subId)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update finished and rescheduled: ${subItem.remarks}")
return Result.success()
}
}
}
private suspend fun testSubscriptionServers(context: Context, sub: SubscriptionCache) {
val subId = sub.guid
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: starting test phase for ${sub.subscription.remarks}")
showNotification(
context,
R.string.title_real_ping_all_server,
sub.subscription.remarks
)
CoreNativeManager.initCoreEnv(context)
val guids = MmkvManager.decodeServerList(subId)
if (guids.isNotEmpty()) {
val deferred = CompletableDeferred<Unit>()
val worker = RealPingWorkerService(
context = context,
guids = guids,
onEvent = { event ->
when (event) {
is RealPingEvent.Progress -> {
showNotification(
context,
R.string.title_real_ping_all_server,
"${event.text} in ${sub.subscription.remarks}"
)
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: ${event.text} in ${sub.subscription.remarks}")
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
}
is RealPingEvent.Finish -> {
deferred.complete(Unit)
}
}
}
)
worker.start()
deferred.await()
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: test phase finished for ${sub.subscription.remarks}")
}
}
private fun showNotification(context: Context, titleResId: Int, content: String) {
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
context,
context.getString(titleResId),
content
)
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -23,6 +24,7 @@ import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -44,10 +46,12 @@ import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.SelectListDialog
import com.v2ray.ang.compose.QRCodeDialog
import com.v2ray.ang.compose.ReorderableListItem
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.colorFabActive
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
@@ -108,6 +112,7 @@ fun SubSettingScreen(
shareSubMethodEntries: List<String>
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
var showUpdateDialog by remember { mutableStateOf(false) }
var removeTarget by remember { mutableStateOf<String?>(null) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
@@ -130,7 +135,7 @@ fun SubSettingScreen(
IconButton(onClick = onAddClick) {
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_config))
}
IconButton(onClick = onSubUpdate) {
IconButton(onClick = { showUpdateDialog = true }) {
Icon(painterResource(R.drawable.ic_restore_24dp), contentDescription = stringResource(R.string.title_sub_update))
}
}
@@ -279,4 +284,51 @@ fun SubSettingScreen(
onDismiss = { removeTarget = null }
)
}
if (showUpdateDialog) {
var autoTestAfterUpdateSubscription by rememberMmkvBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)
var autoRemoveInvalidAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)
var autoSortAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)
AlertDialog(
onDismissRequest = { showUpdateDialog = false },
title = { Text(text = stringResource(R.string.title_sub_update)) },
text = {
Column {
SettingsSwitchItem(
title = stringResource(R.string.title_pref_auto_test_after_update_subscription),
summary = stringResource(R.string.summary_pref_auto_test_after_update_subscription),
checked = autoTestAfterUpdateSubscription,
onCheckedChange = { autoTestAfterUpdateSubscription = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_auto_remove_invalid_after_test),
summary = stringResource(R.string.summary_pref_auto_remove_invalid_after_test),
checked = autoRemoveInvalidAfterTest,
onCheckedChange = { autoRemoveInvalidAfterTest = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_auto_sort_after_test),
summary = stringResource(R.string.summary_pref_auto_sort_after_test),
checked = autoSortAfterTest,
onCheckedChange = { autoSortAfterTest = it }
)
}
},
confirmButton = {
TextButton(onClick = {
showUpdateDialog = false
onSubUpdate()
}) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = { showUpdateDialog = false }) {
Text(text = stringResource(android.R.string.cancel))
}
}
)
}
}
@@ -1,23 +1,16 @@
package com.v2ray.ang.viewmodel
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import com.v2ray.ang.handler.SubscriptionUpdater
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
private val subscriptions: MutableList<SubscriptionCache> =
@@ -64,24 +57,9 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
}
fun updateSubscriptions() {
launchLoading {
val result = withContext(Dispatchers.IO) {
AngConfigManager.updateConfigViaSubAll()
}
SettingsChangeManager.makeSetupGroupTab()
SubscriptionUpdater.updateAllByManual(app)
if (result.successCount + result.failureCount + result.skipCount == 0) {
toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else {
toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
)
)
}
reload()
}
toast(R.string.subscription_updater_job_tips)
}
}
@@ -161,6 +161,9 @@
<string name="title_pref_is_booted">开机时自动连接</string>
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="subscription_updater_job_tips">任务将在后台运行,在通知中会显示进度信息</string>
<string name="title_pref_auto_test_after_update_subscription">更新订阅后自动测试</string>
<string name="summary_pref_auto_test_after_update_subscription">自动测试需要比较长的时间,请耐心等待</string>
<string name="title_pref_auto_remove_invalid_after_test">测试后自动删除无效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">测试结果可能不准确且已删除的配置无法恢复</string>
<string name="title_pref_auto_sort_after_test">测试后自动排序</string>
@@ -160,6 +160,9 @@
<string name="title_pref_is_booted">開機時自動連線</string>
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="subscription_updater_job_tips">任務將在背景運行,在通知中會顯示進度資訊</string>
<string name="title_pref_auto_test_after_update_subscription">更新訂閱後自動測試</string>
<string name="summary_pref_auto_test_after_update_subscription">自動測試需要比較長的時間,請耐心等待</string>
<string name="title_pref_auto_remove_invalid_after_test">測試後自動刪除無效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">測試結果可能不準確且已刪除的配置無法復原</string>
<string name="title_pref_auto_sort_after_test">測試後自動排序</string>
@@ -165,6 +165,10 @@
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="subscription_updater_job_tips">The task will run in the background, and progress information will be displayed in the notification.</string>
<string name="title_pref_auto_test_after_update_subscription">Auto test after updating subscription</string>
<string name="summary_pref_auto_test_after_update_subscription">Automated testing takes a relatively long time, please be patient.</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>