From 03074fba612a07b8fd913e5ee0e0e041dec74a8c Mon Sep 17 00:00:00 2001
From: 2dust <31833384+2dust@users.noreply.github.com>
Date: Sun, 3 May 2026 14:44:38 +0800
Subject: [PATCH] Add NotificationHelper and improve core test service
Introduce unified notification support and use foreground notifications for core test tasks. Added NotificationChannelType enum and NotificationHelper (cached NotificationManager and Builder cache, channel creation, notify/update/startForeground/stopForeground/cancel). Updated AndroidManifest to mark CoreTestService as a specialUse foreground service. Renamed AppConfig message constants (MSG_MEASURE_CONFIG_START, MSG_MEASURE_CONFIG_CANCEL, MSG_MEASURE_CONFIG_SUCCESS) and updated usages: MessageUtil now starts a foreground service on O+ for measurement start and handles cancel without forcing start; CoreTestService starts foreground immediately, logs lifecycle, manages RealPingWorkerService via onProgress/onFinish callbacks, and stops foreground when done; RealPingWorkerService reports progress via onProgress instead of direct UI messages; SubscriptionUpdater now uses NotificationHelper and NotificationChannelType for update notifications. These changes improve notification performance, ensure startForegroundService compliance on Android O+, and centralize notification logic.
---
V2rayNG/app/src/main/AndroidManifest.xml | 7 +-
.../src/main/java/com/v2ray/ang/AppConfig.kt | 8 +-
.../ang/enums/NotificationChannelType.kt | 22 +++
.../v2ray/ang/handler/SubscriptionUpdater.kt | 42 ++---
.../com/v2ray/ang/service/CoreTestService.kt | 72 ++++++--
.../ang/service/RealPingWorkerService.kt | 3 +-
.../java/com/v2ray/ang/util/MessageUtil.kt | 21 ++-
.../com/v2ray/ang/util/NotificationHelper.kt | 162 ++++++++++++++++++
.../com/v2ray/ang/viewmodel/MainViewModel.kt | 2 +-
9 files changed, 289 insertions(+), 50 deletions(-)
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/enums/NotificationChannelType.kt
create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/util/NotificationHelper.kt
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()
)