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.
This commit is contained in:
2dust
2026-05-03 14:44:38 +08:00
parent ac02d23083
commit 03074fba61
9 changed files with 289 additions and 50 deletions
+6 -1
View File
@@ -205,7 +205,12 @@
<service
android:name=".service.CoreTestService"
android:exported="false"
android:process=":RunSoLibV2RayDaemon" />
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<receiver
android:name=".receiver.WidgetProvider"
@@ -165,17 +165,15 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
const val MSG_MEASURE_CONFIG = 7
const val MSG_MEASURE_CONFIG_SUCCESS = 71
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -0,0 +1,22 @@
package com.v2ray.ang.enums
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
notificationId = 12
)
}
@@ -1,12 +1,7 @@
package com.v2ray.ang.handler
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
@@ -20,6 +15,8 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import com.v2ray.ang.enums.NotificationChannelType
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -140,7 +137,7 @@ object SubscriptionUpdater {
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
@@ -153,16 +150,6 @@ object SubscriptionUpdater {
class UpdateTask(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
private val notificationManager = NotificationManagerCompat.from(applicationContext)
private val notification =
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
.setWhen(0)
.setTicker("Update")
.setContentTitle(applicationContext.getString(R.string.title_pref_auto_update_subscription))
.setSmallIcon(R.drawable.ic_stat_name)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val subId = inputData.getString(KEY_SUB_ID)
@@ -173,7 +160,6 @@ object SubscriptionUpdater {
return Result.success()
}
val subItem = MmkvManager.decodeSubscription(subId)
if (subItem == null) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
@@ -187,22 +173,20 @@ object SubscriptionUpdater {
val sub = SubscriptionCache(subId, subItem)
if (Build.VERSION.SDK_INT >= 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()
}
}
@@ -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<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
val message = intent?.serializable<TestServiceMessage>("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
}
}
@@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicInteger
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
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")
}
}
}
@@ -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)
}
@@ -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<Int, NotificationCompat.Builder>()
/**
* 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)
}
}
@@ -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()
)