diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml
index 07b2949b..754a8ddf 100644
--- a/V2rayNG/app/src/main/AndroidManifest.xml
+++ b/V2rayNG/app/src/main/AndroidManifest.xml
@@ -205,7 +205,12 @@
+ android:foregroundServiceType="specialUse"
+ android:process=":RunSoLibV2RayDaemon">
+
+
= Build.VERSION_CODES.O) {
- notificationManager.createNotificationChannel(
- NotificationChannel(
- AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
- AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
- NotificationManager.IMPORTANCE_MIN
- )
- )
- }
+ // Notify about update start
+ NotificationHelper.notify(
+ NotificationChannelType.SUBSCRIPTION_UPDATE,
+ applicationContext,
+ applicationContext.getString(R.string.title_pref_auto_update_subscription),
+ "Updating ${sub.subscription.remarks}"
+ )
- notificationManager.notify(3, notification.build())
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
AngConfigManager.updateConfigViaSub(sub)
- notification.setContentText("Updating ${sub.subscription.remarks}")
- notificationManager.cancel(3)
+ // Clear notification
+ NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
+
return Result.success()
}
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt
index 862b973f..0a348ebe 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt
@@ -4,13 +4,15 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
-import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
-import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
+import com.v2ray.ang.R
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.core.CoreNativeManager
+import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
+import com.v2ray.ang.util.NotificationHelper
+import com.v2ray.ang.enums.NotificationChannelType
import java.util.Collections
class CoreTestService : Service() {
@@ -39,11 +41,13 @@ class CoreTestService : Service() {
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
- super.onDestroy()
+ LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
+ NotificationHelper.stopForeground(this)
+ super.onDestroy()
}
/**
@@ -54,9 +58,24 @@ class CoreTestService : Service() {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val message = intent?.serializable("content") ?: return super.onStartCommand(intent, flags, startId)
+ val message = intent?.serializable("content")
+ if (message == null) {
+ stopSelf(startId)
+ return START_NOT_STICKY
+ }
+
when (message.key) {
- MSG_MEASURE_CONFIG -> {
+ AppConfig.MSG_MEASURE_CONFIG_START -> {
+ LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
+
+ // start foreground immediately to satisfy startForegroundService timing requirement
+ NotificationHelper.startForeground(
+ this,
+ NotificationChannelType.CORE_TEST,
+ getString(R.string.app_name),
+ getString(R.string.title_real_ping_all_server)
+ )
+
val guidsList = if (message.serverGuids.isNotEmpty()) {
message.serverGuids
} else if (message.subscriptionId.isNotEmpty()) {
@@ -67,23 +86,52 @@ class CoreTestService : Service() {
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
- worker = RealPingWorkerService(this, guidsList) { status ->
- // notify UI and remove the worker from active list when finished
- MessageUtil.sendMsg2UI(this@CoreTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
- activeWorkers.remove(worker)
- }
+ worker = RealPingWorkerService(
+ context = this,
+ guids = guidsList,
+ onProgress = { progress ->
+ NotificationHelper.updateNotification(
+ channelType = NotificationChannelType.CORE_TEST,
+ context = this,
+ content = getString(R.string.connection_runing_task_left, progress)
+ )
+ // notify UI about progress updates
+ MessageUtil.sendMsg2UI(this@CoreTestService, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, progress)
+ },
+ onFinish = { status ->
+ // notify UI and remove the worker from active list when finished
+ MessageUtil.sendMsg2UI(this@CoreTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
+ activeWorkers.remove(worker)
+ if (activeWorkers.isEmpty()) {
+ NotificationHelper.stopForeground(this@CoreTestService)
+ stopSelf()
+ }
+
+ }
+ )
activeWorkers.add(worker)
worker.start()
+ } else {
+ NotificationHelper.stopForeground(this)
+ stopSelf(startId)
}
}
- MSG_MEASURE_CONFIG_CANCEL -> {
+ AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
+ LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
// cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
+ NotificationHelper.stopForeground(this)
+ stopSelf()
+ }
+
+ else -> {
+ NotificationHelper.stopForeground(this)
+ stopSelf(startId)
}
}
- return super.onStartCommand(intent, flags, startId)
+ return START_NOT_STICKY
}
}
\ No newline at end of file
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt
index 447d90ef..9523575b 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt
@@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicInteger
class RealPingWorkerService(
private val context: Context,
private val guids: List,
+ private val onProgress: (progress: String) -> Unit = {},
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
@@ -46,7 +47,7 @@ class RealPingWorkerService(
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
- MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
+ onProgress("$left / $count")
}
}
}
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 2e131165..4b340979 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
@@ -3,6 +3,8 @@ package com.v2ray.ang.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.os.Build
+import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.CoreTestService
@@ -44,7 +46,24 @@ object MessageUtil {
val intent = Intent()
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
- ctx.startService(intent)
+ when (message.key) {
+ AppConfig.MSG_MEASURE_CONFIG_START -> {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ContextCompat.startForegroundService(ctx, intent)
+ } else {
+ ctx.startService(intent)
+ }
+ }
+
+ AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
+ // Do not wake up service just to cancel; stop only if it is already running.
+ ctx.stopService(intent)
+ }
+
+ else -> {
+ ctx.startService(intent)
+ }
+ }
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/util/NotificationHelper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/util/NotificationHelper.kt
new file mode 100644
index 00000000..e40d4d8f
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/util/NotificationHelper.kt
@@ -0,0 +1,162 @@
+package com.v2ray.ang.util
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import com.v2ray.ang.R
+import com.v2ray.ang.enums.NotificationChannelType
+
+/**
+ * Unified notification helper for different notification channels.
+ * Supports both regular notifications and foreground service notifications.
+ *
+ * Performance: NotificationManager is cached. Builder is created once per update.
+ * Safe for high-frequency updates (100+ times/second).
+ */
+object NotificationHelper {
+
+ // Cached instances for performance
+ private var cachedNotificationManager: NotificationManager? = null
+ private val builderCache = mutableMapOf()
+
+ /**
+ * Notify with a regular notification (non-foreground).
+ *
+ * @param channelType The notification channel type (defines channelId, notificationId, etc.)
+ * @param context The context for building the notification
+ * @param title The notification title
+ * @param content The notification content text
+ */
+ fun notify(
+ channelType: NotificationChannelType,
+ context: Context,
+ title: String,
+ content: String
+ ) {
+ ensureChannelCreated(channelType, context)
+ val notificationManager = getNotificationManager(context)
+ val builder = buildNotificationBuilder(channelType, context, title, content)
+ notificationManager.notify(channelType.notificationId, builder.build())
+ }
+
+ /**
+ * Update an existing notification's content.
+ * Optimized for high-frequency updates (100+/sec).
+ * Reuses cached Builder to minimize allocation overhead.
+ *
+ * @param channelType The notification channel type
+ * @param context The context
+ * @param content The new content text
+ */
+ fun updateNotification(
+ channelType: NotificationChannelType,
+ context: Context,
+ content: String
+ ) {
+ val notificationManager = getNotificationManager(context)
+
+ // Get or create builder from cache
+ val builder = builderCache.getOrPut(channelType.notificationId) {
+ buildNotificationBuilder(channelType, context, "", content)
+ }
+
+ // Update only the content text (fast operation)
+ builder.setContentText(content)
+ notificationManager.notify(channelType.notificationId, builder.build())
+ }
+
+ /**
+ * Start a foreground service with a notification.
+ *
+ * @param service The service to set as foreground
+ * @param channelType The notification channel type
+ * @param title The notification title
+ * @param content The notification content text
+ */
+ fun startForeground(
+ service: Service,
+ channelType: NotificationChannelType,
+ title: String,
+ content: String
+ ) {
+ ensureChannelCreated(channelType, service)
+ val builder = buildNotificationBuilder(channelType, service, title, content)
+ service.startForeground(channelType.notificationId, builder.build())
+ }
+
+ /**
+ * Stop the foreground notification for a service.
+ *
+ * @param service The service to stop foreground on
+ */
+ fun stopForeground(service: Service) {
+ service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
+ }
+
+ /**
+ * Cancel a notification and clean up cached builder.
+ *
+ * @param channelType The notification channel type
+ * @param context The context
+ */
+ fun cancel(
+ channelType: NotificationChannelType,
+ context: Context
+ ) {
+ getNotificationManager(context).cancel(channelType.notificationId)
+ builderCache.remove(channelType.notificationId) // Clean up cache
+ }
+
+ // ====== Private helper methods ======
+
+ private fun getNotificationManager(context: Context): NotificationManager {
+ if (cachedNotificationManager == null) {
+ cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ }
+ return cachedNotificationManager!!
+ }
+
+ private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
+
+ val channel = NotificationChannel(
+ channelType.channelId,
+ channelType.channelName,
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ private fun buildNotificationBuilder(
+ channelType: NotificationChannelType,
+ context: Context,
+ title: String,
+ content: String
+ ): NotificationCompat.Builder {
+ val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ channelType.channelId
+ } else {
+ ""
+ }
+
+ val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
+ return NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setContentTitle(displayTitle)
+ .setContentText(content)
+ .setOngoing(false)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ }
+}
+
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
index a2349217..23762284 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
@@ -222,7 +222,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
- key = AppConfig.MSG_MEASURE_CONFIG,
+ key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = subscriptionId,
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
)