diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml
index 75fddd430..999a105be 100644
--- a/V2rayNG/app/src/main/AndroidManifest.xml
+++ b/V2rayNG/app/src/main/AndroidManifest.xml
@@ -253,6 +253,16 @@
android:value="test" />
+
+
+
+
= listOf()
+) : Serializable
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt
index f7ebb7826..88eba0d11 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SubscriptionUpdater.kt
@@ -5,26 +5,16 @@ 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
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.dto.SubscriptionUpdateMessage
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 com.v2ray.ang.util.MessageUtil
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -52,7 +42,9 @@ object SubscriptionUpdater {
ExistingPeriodicWorkPolicy.KEEP
}
- MmkvManager.decodeSubscriptions().forEach { sub ->
+ MmkvManager.decodeSubscriptions()
+ .filter { it.subscription.autoUpdate && it.subscription.url.isNotEmpty() }
+ .forEach { sub ->
scheduleOne(
context = context,
subId = sub.guid,
@@ -87,47 +79,20 @@ object SubscriptionUpdater {
}
/**
- * Update all subscriptions immediately in the background.
- * Call from: SubscriptionsViewModel.updateSubscriptions().
+ * Update the last updated timestamp and reschedule the task.
+ * This is used to reset the periodic timer and prevent rapid rescheduling loops.
*/
- 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()
- .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)")
- }
+ fun updateLastUpdatedAndReschedule(context: Context = AngApplication.application, subId: String) {
+ val subItem = MmkvManager.decodeSubscription(subId) ?: return
+ subItem.lastUpdated = System.currentTimeMillis()
+ MmkvManager.encodeSubscription(subId, subItem)
+ syncOne(context, subId)
}
// -------------------------------------------------------------------------
// Internal scheduling logic
// -------------------------------------------------------------------------
- private val updateSemaphore = Semaphore(2)
-
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
private fun scheduleOne(
@@ -143,6 +108,11 @@ object SubscriptionUpdater {
return
}
+ if (subItem.url.isEmpty()) {
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
+ return
+ }
+
val intervalMinutes = maxOf(
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
subItem.updateInterval
@@ -152,12 +122,17 @@ object SubscriptionUpdater {
val lastUpdated = subItem.lastUpdated
val intervalMillis = intervalMinutes * 60 * 1000L
val now = System.currentTimeMillis()
- val initialDelayMillis = if (lastUpdated <= 0L) {
+ var initialDelayMillis = if (lastUpdated <= 0L) {
0L
} else {
maxOf(0L, lastUpdated + intervalMillis - now)
}
+ // Add a small floor to initial delay to prevent rapid rescheduling loops.
+ if (existingWorkPolicy == ExistingPeriodicWorkPolicy.REPLACE && initialDelayMillis < 5000L) {
+ initialDelayMillis = 5000L
+ }
+
val request = PeriodicWorkRequestBuilder(intervalMinutes, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder()
@@ -192,121 +167,23 @@ object SubscriptionUpdater {
CoroutineWorker(context, params) {
@SuppressLint("MissingPermission")
- override suspend fun doWork(): Result = updateSemaphore.withPermit {
+ override suspend fun doWork(): Result {
val subId = inputData.getString(KEY_SUB_ID)
- LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting: $subId")
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
if (subId.isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
return Result.success()
}
- val subItem = MmkvManager.decodeSubscription(subId)
- if (subItem == null) {
- LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
- return Result.success()
- }
+ updateLastUpdatedAndReschedule(applicationContext, subId)
- 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
- showNotification(
+ MessageUtil.sendMsg2SubscriptionService(
applicationContext,
- R.string.title_pref_auto_update_subscription,
- "Updating ${sub.subscription.remarks}"
+ SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, listOf(subId))
)
- 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()
- 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
- )
- }
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt
new file mode 100644
index 000000000..afb4e722d
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/SubscriptionUpdateService.kt
@@ -0,0 +1,213 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+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.SubscriptionUpdateMessage
+import com.v2ray.ang.dto.entities.SubscriptionCache
+import com.v2ray.ang.enums.NotificationChannelType
+import com.v2ray.ang.extension.serializable
+import com.v2ray.ang.handler.AngConfigManager
+import com.v2ray.ang.handler.MmkvManager
+import com.v2ray.ang.util.LogUtil
+import com.v2ray.ang.util.NotificationHelper
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import java.util.Collections
+import java.util.concurrent.atomic.AtomicInteger
+
+class SubscriptionUpdateService : Service() {
+
+ private val serviceJob = Job()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+
+ private val runningTasks = AtomicInteger(0)
+
+ // manage active batch workers so each batch is independent and cancellable
+ private val activeWorkers = Collections.synchronizedList(mutableListOf())
+
+ private val updateSemaphore = Semaphore(2)
+
+ override fun onCreate() {
+ super.onCreate()
+ CoreNativeManager.initCoreEnv(this)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService is being destroyed")
+ val snapshot = ArrayList(activeWorkers)
+ snapshot.forEach { it.cancel() }
+ activeWorkers.clear()
+ serviceJob.cancel()
+ NotificationHelper.stopForeground(this)
+ NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, this)
+ super.onDestroy()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val message = intent?.serializable("content")
+ if (message == null) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
+ when (message.key) {
+ AppConfig.MSG_SUB_UPDATE_START -> handleUpdateStart(message)
+ AppConfig.MSG_SUB_UPDATE_CANCEL -> {
+ NotificationHelper.stopForeground(this)
+ stopSelf(startId)
+ }
+
+ else -> {
+ NotificationHelper.stopForeground(this)
+ stopSelf(startId)
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun handleUpdateStart(message: SubscriptionUpdateMessage) {
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService starting update task for ${message.subIds.size} subscriptions")
+
+ NotificationHelper.startForeground(
+ this,
+ NotificationChannelType.SUBSCRIPTION_UPDATE,
+ getString(R.string.title_pref_auto_update_subscription),
+ getString(R.string.app_name)
+ )
+
+ runningTasks.incrementAndGet()
+ serviceScope.launch {
+ updateSemaphore.withPermit {
+ try {
+ message.subIds.forEach { subId ->
+ updateSingle(subId)
+ }
+ } catch (e: Exception) {
+ LogUtil.e(AppConfig.TAG, "SubscriptionUpdateService update failed", e)
+ } finally {
+ if (runningTasks.decrementAndGet() == 0 && activeWorkers.isEmpty()) {
+ NotificationHelper.stopForeground(this@SubscriptionUpdateService)
+ stopSelf()
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun updateSingle(subId: String) {
+ val subItem = MmkvManager.decodeSubscription(subId) ?: return
+ if(!subItem.enabled || subItem.url.isEmpty()){
+ return
+ }
+
+ val sub = SubscriptionCache(subId, subItem)
+
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Updating ${subItem.remarks}")
+ showNotification(
+ context = this,
+ titleResId = R.string.title_pref_auto_update_subscription,
+ content = "Updating ${subItem.remarks}"
+ )
+
+ AngConfigManager.updateConfigViaSub(sub)
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
+ testSubscriptionServers(sub)
+
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}")
+ showNotification(
+ context = this,
+ titleResId = R.string.title_del_invalid_config,
+ content = subItem.remarks
+ )
+ AngConfigManager.removeInvalidServer(subId)
+ }
+ if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}")
+ showNotification(
+ context = this,
+ titleResId = R.string.title_sort_by_test_results,
+ content = subItem.remarks
+ )
+ AngConfigManager.sortByTestResultsForSub(subId)
+ }
+ }
+
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}")
+ }
+
+ private suspend fun testSubscriptionServers(sub: SubscriptionCache) {
+ val subId = sub.guid
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: starting test phase for ${sub.subscription.remarks}")
+ showNotification(
+ context = this,
+ titleResId = R.string.title_real_ping_all_server,
+ content = sub.subscription.remarks
+ )
+
+ val guids = MmkvManager.decodeServerList(subId)
+ if (guids.isNotEmpty()) {
+ val deferred = CompletableDeferred()
+ lateinit var worker: RealPingWorkerService
+ worker = RealPingWorkerService(
+ context = this,
+ guids = guids,
+ onEvent = { event ->
+ handleWorkerEvent(event, sub.subscription.remarks) {
+ activeWorkers.remove(worker)
+ deferred.complete(Unit)
+ }
+ }
+ )
+ activeWorkers.add(worker)
+ worker.start()
+ deferred.await()
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}")
+ }
+ }
+
+ private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
+ when (event) {
+ is RealPingEvent.Progress -> {
+ val text = "${event.text} in $remarks"
+ showNotification(
+ context = this,
+ titleResId = R.string.title_real_ping_all_server,
+ content = text
+ )
+ LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: $text")
+ }
+
+ is RealPingEvent.Result -> {
+ MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
+ }
+
+ is RealPingEvent.Finish -> {
+ onWorkerDone()
+ }
+ }
+ }
+
+ private fun showNotification(context: Context, titleResId: Int, content: String) {
+ NotificationHelper.notify(
+ NotificationChannelType.SUBSCRIPTION_UPDATE,
+ context,
+ context.getString(titleResId),
+ content
+ )
+ }
+}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt
index 12ac949a9..a09a28d96 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/subscription/SubscriptionsViewModel.kt
@@ -1,14 +1,16 @@
package com.v2ray.ang.ui.subscription
import android.app.Application
+import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
+import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
-import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.ui.base.BaseViewModel
+import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -59,7 +61,13 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
fun updateSubscriptions() {
SettingsChangeManager.makeSetupGroupTab()
- SubscriptionUpdater.updateAllByManual(app)
+ val subIds = MmkvManager.decodeSubscriptions()
+ .filter { it.subscription.enabled && it.subscription.url.isNotEmpty() }
+ .map { it.guid }
+
+ if (subIds.isNotEmpty()) {
+ MessageUtil.sendMsg2SubscriptionService(app, SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, subIds))
+ }
toast(R.string.subscription_updater_job_tips)
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt
index 4b3409792..ad5067cea 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/MessageUtil.kt
@@ -6,8 +6,10 @@ import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
+import com.v2ray.ang.dto.SubscriptionUpdateMessage
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.CoreTestService
+import com.v2ray.ang.service.SubscriptionUpdateService
import java.io.Serializable
object MessageUtil {
@@ -69,6 +71,39 @@ object MessageUtil {
}
}
+ /**
+ * Sends a message to the subscription service.
+ *
+ * @param ctx The context.
+ * @param message The subscription service message containing key and subId.
+ */
+ fun sendMsg2SubscriptionService(ctx: Context, message: SubscriptionUpdateMessage) {
+ try {
+ val intent = Intent()
+ intent.component = ComponentName(ctx, SubscriptionUpdateService::class.java)
+ intent.putExtra("content", message)
+ when (message.key) {
+ AppConfig.MSG_SUB_UPDATE_START -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ContextCompat.startForegroundService(ctx, intent)
+ } else {
+ ctx.startService(intent)
+ }
+ }
+
+ AppConfig.MSG_SUB_UPDATE_CANCEL -> {
+ ctx.stopService(intent)
+ }
+
+ else -> {
+ ctx.startService(intent)
+ }
+ }
+ } catch (e: Exception) {
+ LogUtil.e(AppConfig.TAG, "Failed to send message to subscription service", e)
+ }
+ }
+
/**
* Sends a message with the specified action.
*