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