feat: show consumed traffic in notification (#1165)

This commit is contained in:
Max Grakov
2026-03-08 05:58:32 +03:00
committed by GitHub
parent 2d9c5ece4a
commit 2ed06728e3
4 changed files with 368 additions and 290 deletions
@@ -14,6 +14,7 @@ interface NotificationManager {
fun createNotification(
channel: NotificationChannels,
title: String = "",
subText: String? = null,
actions: Collection<NotificationCompat.Action> = emptyList(),
description: String = "",
showTimestamp: Boolean = true,
@@ -27,6 +28,7 @@ interface NotificationManager {
fun createNotification(
channel: NotificationChannels,
title: StringValue,
subText: String? = null,
actions: Collection<NotificationCompat.Action> = emptyList(),
description: StringValue,
showTimestamp: Boolean = true,
@@ -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<NotificationCompat.Action>,
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<NotificationCompat.Action>,
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<NotificationCompat.Action>,
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<NotificationCompat.Action>,
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)
}
}
}
}
}
@@ -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<TunnelConfig>) {
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<TunnelConfig>) {
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<Long, Long>? {
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<TunnelConfig>) {
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<Long, Long>?,
): 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)
}
+2 -2
View File
@@ -119,7 +119,7 @@
<string name="launch_app_settings">Настройки запуска приложения</string>
<string name="use_root_shell_for_wifi">Использовать root-доступ для получения информации о Wi-Fi без необходимости разрешения на определение местоположения</string>
<string name="start_auto">Запустить автотуннель</string>
<string name="tunnel_running">Работающий туннель</string>
<string name="tunnel_running">Туннель работает</string>
<string name="monitoring_state_changes">Отслеживание изменений состояния</string>
<string name="add_from_clipboard">Добавить из буфера обмена</string>
<string name="auto_tunnel_channel_name">Канал уведомлений автотуннеля</string>
@@ -133,7 +133,7 @@
<string name="bypass_lan_for_kill_switch">Разрешать трафик LAN при экстренном отключении</string>
<string name="vpn_channel_description">Канал уведомлений о состоянии VPN</string>
<string name="auto_tunnel_channel_description">Канал уведомлений о состоянии автотуннеля</string>
<string name="stop">Стоп</string>
<string name="stop">Отключиться</string>
<string name="splt_tunneling">Раздельное туннелирование</string>
<string name="pre_up">До активации</string>
<string name="post_up">После активации</string>