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() )