From 2ed06728e35840b89cdf807ae60cbab244b932cd Mon Sep 17 00:00:00 2001 From: Max Grakov Date: Sun, 8 Mar 2026 05:58:32 +0300 Subject: [PATCH] feat: show consumed traffic in notification (#1165) --- .../core/notification/NotificationManager.kt | 2 + .../notification/WireGuardNotification.kt | 308 ++++++++-------- .../service/BaseTunnelForegroundService.kt | 344 +++++++++++------- app/src/main/res/values-ru/strings.xml | 4 +- 4 files changed, 368 insertions(+), 290 deletions(-) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt index 7a5c8c25..7471fb4a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/NotificationManager.kt @@ -14,6 +14,7 @@ interface NotificationManager { fun createNotification( channel: NotificationChannels, title: String = "", + subText: String? = null, actions: Collection = emptyList(), description: String = "", showTimestamp: Boolean = true, @@ -27,6 +28,7 @@ interface NotificationManager { fun createNotification( channel: NotificationChannels, title: StringValue, + subText: String? = null, actions: Collection = emptyList(), description: StringValue, showTimestamp: Boolean = true, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt index 91814f13..c5062511 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/notification/WireGuardNotification.kt @@ -18,155 +18,161 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction import com.zaneschepke.wireguardautotunnel.util.StringValue class WireGuardNotification(override val context: Context) : NotificationManager { - - enum class NotificationChannels { - VPN, - AUTO_TUNNEL, - } - - private val notificationManager = NotificationManagerCompat.from(context) - - override fun createNotification( - channel: NotificationChannels, - title: String, - actions: Collection, - description: String, - showTimestamp: Boolean, - importance: Int, - onGoing: Boolean, - onlyAlertOnce: Boolean, - groupKey: String?, - isGroupSummary: Boolean, - ): Notification { - notificationManager.createNotificationChannel(channel.asChannel(importance)) - return channel - .asBuilder() - .apply { - actions.forEach { addAction(it) } - setContentTitle(title) - setContentIntent( - PendingIntent.getActivity( - context, - 0, - Intent(context, MainActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), - PendingIntent.FLAG_IMMUTABLE, - ) - ) - setContentText(description) - setOnlyAlertOnce(onlyAlertOnce) - setOngoing(onGoing) - setPriority(NotificationCompat.PRIORITY_LOW) - setShowWhen(showTimestamp) - setSmallIcon(R.drawable.ic_notification) - if (groupKey != null) { - setGroup(groupKey) - if (isGroupSummary) { - setGroupSummary(true) - } - } - } - .build() - } - - override fun createNotification( - channel: NotificationChannels, - title: StringValue, - actions: Collection, - description: StringValue, - showTimestamp: Boolean, - importance: Int, - onGoing: Boolean, - onlyAlertOnce: Boolean, - groupKey: String?, - isGroupSummary: Boolean, - ): Notification { - return createNotification( - channel, - title.asString(context), - actions, - description.asString(context), - showTimestamp, - importance, - onGoing, - onlyAlertOnce, - ) - } - - override fun createNotificationAction( - notificationAction: NotificationAction, - extraId: Int?, - ): NotificationCompat.Action { - val pendingIntent = - PendingIntent.getBroadcast( - context, - extraId ?: 0, - Intent(context, NotificationActionReceiver::class.java).apply { - action = notificationAction.name - if (extraId != null) putExtra(EXTRA_ID, extraId) - }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat.Action.Builder( - R.drawable.ic_notification, - notificationAction.title(context).uppercase(), - pendingIntent, - ) - .build() - } - - override fun remove(notificationId: Int) { - notificationManager.cancel(notificationId) - } - - override fun show(notificationId: Int, notification: Notification) { - with(notificationManager) { - if ( - ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) != PackageManager.PERMISSION_GRANTED - ) { - return - } - notify(notificationId, notification) - } - } - - private fun NotificationChannels.asBuilder(): NotificationCompat.Builder { - return when (this) { - NotificationChannels.AUTO_TUNNEL -> { - NotificationCompat.Builder( - context, - context.getString(R.string.auto_tunnel_channel_id), - ) - } - NotificationChannels.VPN -> { - NotificationCompat.Builder(context, context.getString(R.string.vpn_channel_id)) - } - } - } - - private fun NotificationChannels.asChannel(importance: Int): NotificationChannel { - return when (this) { - NotificationChannels.VPN -> { - NotificationChannel( - context.getString(R.string.vpn_channel_id), - context.getString(R.string.vpn_channel_name), - importance, - ) - .apply { description = context.getString(R.string.vpn_channel_description) } - } - NotificationChannels.AUTO_TUNNEL -> { - NotificationChannel( - context.getString(R.string.auto_tunnel_channel_id), - context.getString(R.string.auto_tunnel_channel_name), - importance, - ) - .apply { - description = context.getString(R.string.auto_tunnel_channel_description) - } - } - } - } + + enum class NotificationChannels { + VPN, + AUTO_TUNNEL, + } + + private val notificationManager = NotificationManagerCompat.from(context) + + override fun createNotification( + channel: NotificationChannels, + title: String, + subText: String?, + actions: Collection, + description: String, + showTimestamp: Boolean, + importance: Int, + onGoing: Boolean, + onlyAlertOnce: Boolean, + groupKey: String?, + isGroupSummary: Boolean, + ): Notification { + notificationManager.createNotificationChannel(channel.asChannel(importance)) + return channel + .asBuilder() + .apply { + actions.forEach { addAction(it) } + setContentTitle(title) + setSubText(subText) + setContentIntent( + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + ) + setContentText(description) + setOnlyAlertOnce(onlyAlertOnce) + setOngoing(onGoing) + setPriority(NotificationCompat.PRIORITY_LOW) + setShowWhen(showTimestamp) + setSmallIcon(R.drawable.ic_notification) + if (groupKey != null) { + setGroup(groupKey) + if (isGroupSummary) { + setGroupSummary(true) + } + } + } + .build() + } + + override fun createNotification( + channel: NotificationChannels, + title: StringValue, + subText: String?, + actions: Collection, + description: StringValue, + showTimestamp: Boolean, + importance: Int, + onGoing: Boolean, + onlyAlertOnce: Boolean, + groupKey: String?, + isGroupSummary: Boolean, + ): Notification { + return createNotification( + channel, + title.asString(context), + subText, + actions, + description.asString(context), + showTimestamp, + importance, + onGoing, + onlyAlertOnce, + ) + } + + override fun createNotificationAction( + notificationAction: NotificationAction, + extraId: Int?, + ): NotificationCompat.Action { + val pendingIntent = + PendingIntent.getBroadcast( + context, + extraId ?: 0, + Intent(context, NotificationActionReceiver::class.java).apply { + action = notificationAction.name + if (extraId != null) putExtra(EXTRA_ID, extraId) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + return NotificationCompat.Action.Builder( + R.drawable.ic_notification, + notificationAction.title(context), + pendingIntent, + ) + .build() + } + + override fun remove(notificationId: Int) { + notificationManager.cancel(notificationId) + } + + override fun show(notificationId: Int, notification: Notification) { + with(notificationManager) { + if ( + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + notify(notificationId, notification) + } + } + + private fun NotificationChannels.asBuilder(): NotificationCompat.Builder { + return when (this) { + NotificationChannels.AUTO_TUNNEL -> { + NotificationCompat.Builder( + context, + context.getString(R.string.auto_tunnel_channel_id), + ) + } + + NotificationChannels.VPN -> { + NotificationCompat.Builder(context, context.getString(R.string.vpn_channel_id)) + } + } + } + + private fun NotificationChannels.asChannel(importance: Int): NotificationChannel { + return when (this) { + NotificationChannels.VPN -> { + NotificationChannel( + context.getString(R.string.vpn_channel_id), + context.getString(R.string.vpn_channel_name), + importance, + ) + .apply { description = context.getString(R.string.vpn_channel_description) } + } + + NotificationChannels.AUTO_TUNNEL -> { + NotificationChannel( + context.getString(R.string.auto_tunnel_channel_id), + context.getString(R.string.auto_tunnel_channel_name), + importance, + ) + .apply { + description = context.getString(R.string.auto_tunnel_channel_description) + } + } + } + } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt index 84479ddf..086a7c08 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/BaseTunnelForegroundService.kt @@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.core.service import android.app.Notification import android.content.Intent import android.os.IBinder +import android.text.format.Formatter import androidx.core.app.ServiceCompat import androidx.lifecycle.LifecycleService import androidx.lifecycle.lifecycleScope @@ -17,147 +18,216 @@ import com.zaneschepke.wireguardautotunnel.domain.repository.GeneralSettingRepos import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.core.qualifier.named import timber.log.Timber +import java.text.NumberFormat +import java.util.Locale abstract class BaseTunnelForegroundService : LifecycleService(), TunnelService { - - private val notificationManager: NotificationManager by inject() - - private val serviceManager: ServiceManager by inject() - - private val tunnelManager: TunnelManager by inject() - - private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) - - private val settingsRepository: GeneralSettingRepository by inject() - - private val tunnelsRepository: TunnelRepository by inject() - - protected abstract val fgsType: Int - - override fun onBind(intent: Intent): IBinder { - super.onBind(intent) - return LocalBinder(this) - } - - override fun onCreate() { - super.onCreate() - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - onCreateNotification(), - fgsType, - ) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - onCreateNotification(), - fgsType, - ) - if ( - intent == null || - intent.component == null || - (intent.component?.packageName != this.packageName) - ) { - Timber.d("Service started by Always-on VPN feature") - lifecycleScope.launch { - val settings = settingsRepository.getGeneralSettings() - if (settings.isAlwaysOnVpnEnabled) { - val tunnel = tunnelsRepository.getDefaultTunnel() - tunnel?.let { tunnelManager.startTunnel(it) } - } else { - Timber.w("Always-on VPN is not enabled in app settings") - } - } - } else { - start() - } - return START_STICKY - } - - override fun start() { - lifecycleScope.launch(ioDispatcher) { - tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels -> - val activeTunConfigs = activeTunnels.keys - val tunnels = tunnelsRepository.getAll() - val activeConfigs = tunnels.filter { activeTunConfigs.contains(it.id) } - updateServiceNotification(activeConfigs) - } - } - } - - // TODO Would be cool to have this include kill switch - private fun updateServiceNotification(activeConfigs: List) { - val notification = - when (activeConfigs.size) { - 0 -> onCreateNotification() - 1 -> createTunnelNotification(activeConfigs.first()) - else -> createTunnelsNotification() - } - ServiceCompat.startForeground( - this, - NotificationManager.VPN_NOTIFICATION_ID, - notification, - fgsType, - ) - } - - override fun stop() { - Timber.d("Stop called") - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onDestroy() { - serviceManager.handleTunnelServiceDestroy() - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - Timber.d("onDestroy") - super.onDestroy() - } - - private fun createTunnelNotification(tunnelConfig: TunnelConfig): Notification { - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = "${getString(R.string.tunnel_running)} - ${tunnelConfig.name}", - actions = - listOf( - notificationManager.createNotificationAction( - NotificationAction.TUNNEL_OFF, - tunnelConfig.id, - ) - ), - onGoing = true, - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } - - private fun createTunnelsNotification(): Notification { - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = "${getString(R.string.tunnel_running)} - ${getString(R.string.multiple)}", - actions = - listOf( - notificationManager.createNotificationAction(NotificationAction.TUNNEL_OFF, 0) - ), - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } - - private fun onCreateNotification(): Notification { - return notificationManager.createNotification( - WireGuardNotification.NotificationChannels.VPN, - title = getString(R.string.tunnel_starting), - groupKey = NotificationManager.VPN_GROUP_KEY, - isGroupSummary = true, - ) - } + + private val notificationManager: NotificationManager by inject() + private val serviceManager: ServiceManager by inject() + private val tunnelManager: TunnelManager by inject() + private val ioDispatcher: CoroutineDispatcher by inject(named(Dispatcher.IO)) + private val settingsRepository: GeneralSettingRepository by inject() + private val tunnelsRepository: TunnelRepository by inject() + + protected abstract val fgsType: Int + + private var currentSingleTunnelId: Int? = null + + private var statsJob: Job? = null + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return LocalBinder(this) + } + + override fun onCreate() { + super.onCreate() + ServiceCompat.startForeground( + this, + NotificationManager.VPN_NOTIFICATION_ID, + onCreateNotification(), + fgsType, + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + + ServiceCompat.startForeground( + this, + NotificationManager.VPN_NOTIFICATION_ID, + onCreateNotification(), + fgsType, + ) + + if ( + intent == null || + intent.component == null || + (intent.component?.packageName != this.packageName) + ) { + Timber.d("Service started by Always-on VPN feature") + lifecycleScope.launch { + val settings = settingsRepository.getGeneralSettings() + if (settings.isAlwaysOnVpnEnabled) { + val tunnel = tunnelsRepository.getDefaultTunnel() + tunnel?.let { tunnelManager.startTunnel(it) } + } else { + Timber.w("Always-on VPN is not enabled in app settings") + } + } + } else { + start() + } + + return START_STICKY + } + + override fun start() { + lifecycleScope.launch(ioDispatcher) { + tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels -> + val activeTunIds = activeTunnels.keys + val tunnels = tunnelsRepository.getAll() + val activeConfigs = tunnels.filter { activeTunIds.contains(it.id) } + + updateServiceNotification(activeConfigs) + restartStatsUpdaterIfNeeded(activeConfigs) + } + } + } + + private fun restartStatsUpdaterIfNeeded(activeConfigs: List) { + val single = activeConfigs.singleOrNull() + + if (single == null) { + statsJob?.cancel() + statsJob = null + currentSingleTunnelId = null + return + } + + if (currentSingleTunnelId == single.id && statsJob?.isActive == true) return + + statsJob?.cancel() + statsJob = null + currentSingleTunnelId = single.id + + statsJob = lifecycleScope.launch(ioDispatcher) { + while (isActive) { + val traffic = readTraffic(single.id) + + notificationManager + .show( + NotificationManager.VPN_NOTIFICATION_ID, + createTunnelNotification(single, consumedTraffic = traffic), + ) + + delay(1000) + } + } + } + + private fun readTraffic(tunnelId: Int): Pair? { + val active = tunnelManager.activeTunnels.value[tunnelId] ?: return null + val stats = active.statistics ?: return null + return stats.rx() to stats.tx() + } + + private fun updateServiceNotification(activeConfigs: List) { + val notification = + when (activeConfigs.size) { + 0 -> onCreateNotification() + 1 -> createTunnelNotification(activeConfigs.first(), consumedTraffic = null) + else -> createTunnelsNotification() + } + + ServiceCompat.startForeground( + this, + NotificationManager.VPN_NOTIFICATION_ID, + notification, + fgsType, + ) + } + + override fun stop() { + Timber.d("Stop called") + statsJob?.cancel() + statsJob = null + currentSingleTunnelId = null + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + serviceManager.handleTunnelServiceDestroy() + + statsJob?.cancel() + statsJob = null + currentSingleTunnelId = null + + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + Timber.d("onDestroy") + super.onDestroy() + } + + private fun createTunnelNotification( + tunnelConfig: TunnelConfig, + consumedTraffic: Pair?, + ): Notification { + + val subText = consumedTraffic?.let { traffic -> + val formattedRx = "↓ ${formatBytes(traffic.first)}" + val formattedTx = "↑ ${formatBytes(traffic.second)}" + "$formattedRx $formattedTx" + } + + return notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = tunnelConfig.name, + description = getString(R.string.tunnel_running), + subText = subText, + actions = listOf( + notificationManager.createNotificationAction( + NotificationAction.TUNNEL_OFF, + tunnelConfig.id, + ) + ), + onGoing = true, + groupKey = NotificationManager.VPN_GROUP_KEY, + isGroupSummary = true, + ) + } + + + private fun createTunnelsNotification(): Notification { + return notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = "${getString(R.string.tunnel_running)} - ${getString(R.string.multiple)}", + actions = + listOf( + notificationManager.createNotificationAction(NotificationAction.TUNNEL_OFF, 0) + ), + groupKey = NotificationManager.VPN_GROUP_KEY, + isGroupSummary = true, + ) + } + + private fun onCreateNotification(): Notification { + return notificationManager.createNotification( + WireGuardNotification.NotificationChannels.VPN, + title = getString(R.string.tunnel_starting), + groupKey = NotificationManager.VPN_GROUP_KEY, + isGroupSummary = true, + ) + } + + private fun formatBytes(bytes: Long) = Formatter.formatFileSize(this, bytes) } diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index eeeda053..0d75ff50 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -119,7 +119,7 @@ Настройки запуска приложения Использовать root-доступ для получения информации о Wi-Fi без необходимости разрешения на определение местоположения Запустить автотуннель - Работающий туннель + Туннель работает Отслеживание изменений состояния Добавить из буфера обмена Канал уведомлений автотуннеля @@ -133,7 +133,7 @@ Разрешать трафик LAN при экстренном отключении Канал уведомлений о состоянии VPN Канал уведомлений о состоянии автотуннеля - Стоп + Отключиться Раздельное туннелирование До активации После активации