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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user