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:
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user