feat(android): show proxy traffic stats in foreground notification

This commit is contained in:
Dark-Avery
2026-03-16 23:15:12 +03:00
parent 8d43fa25fa
commit da15296f66
6 changed files with 171 additions and 10 deletions

View File

@@ -13,13 +13,19 @@ import androidx.core.app.TaskStackBuilder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Locale
class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var trafficJob: Job? = null
private var lastTrafficSample: TrafficSample? = null
override fun onCreate() {
super.onCreate()
@@ -69,6 +75,7 @@ class ProxyForegroundService : Service() {
}
override fun onDestroy() {
stopTrafficUpdates()
serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
@@ -104,6 +111,7 @@ class ProxyForegroundService : Service() {
result.onSuccess {
ProxyServiceState.markStarted(config)
lastTrafficSample = null
updateNotification(
buildNotificationPayload(
config = config,
@@ -114,16 +122,19 @@ class ProxyForegroundService : Service() {
),
),
)
startTrafficUpdates(config)
}.onFailure { error ->
ProxyServiceState.markFailed(
error.message ?: getString(R.string.proxy_start_failed_generic),
)
stopTrafficUpdates()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
stopTrafficUpdates()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
@@ -144,17 +155,15 @@ class ProxyForegroundService : Service() {
config: NormalizedProxyConfig,
statusText: String,
): NotificationPayload {
val trafficState = readTrafficState()
val endpointText = getString(R.string.notification_endpoint, config.host, config.port)
val detailsText = getString(
R.string.notification_details,
config.host,
config.port,
config.dcIpList.size,
if (config.verbose) {
getString(R.string.notification_verbose_on)
} else {
getString(R.string.notification_verbose_off)
},
formatRate(trafficState.upBytesPerSecond),
formatRate(trafficState.downBytesPerSecond),
formatBytes(trafficState.totalBytesUp),
formatBytes(trafficState.totalBytesDown),
)
return NotificationPayload(
statusText = statusText,
@@ -163,6 +172,80 @@ class ProxyForegroundService : Service() {
)
}
private fun startTrafficUpdates(config: NormalizedProxyConfig) {
stopTrafficUpdates()
trafficJob = serviceScope.launch {
while (isActive && ProxyServiceState.isRunning.value) {
updateNotification(
buildNotificationPayload(
config = config,
statusText = getString(
R.string.notification_running,
config.host,
config.port,
),
),
)
delay(1000)
}
}
}
private fun stopTrafficUpdates() {
trafficJob?.cancel()
trafficJob = null
lastTrafficSample = null
}
private fun readTrafficState(): TrafficState {
val nowMillis = System.currentTimeMillis()
val current = PythonProxyBridge.getTrafficStats(this)
val previous = lastTrafficSample
lastTrafficSample = TrafficSample(
bytesUp = current.bytesUp,
bytesDown = current.bytesDown,
timestampMillis = nowMillis,
)
if (!current.running || previous == null) {
return TrafficState(
upBytesPerSecond = 0L,
downBytesPerSecond = 0L,
totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown,
)
}
val elapsedMillis = (nowMillis - previous.timestampMillis).coerceAtLeast(1L)
val upDelta = (current.bytesUp - previous.bytesUp).coerceAtLeast(0L)
val downDelta = (current.bytesDown - previous.bytesDown).coerceAtLeast(0L)
return TrafficState(
upBytesPerSecond = (upDelta * 1000L) / elapsedMillis,
downBytesPerSecond = (downDelta * 1000L) / elapsedMillis,
totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown,
)
}
private fun formatRate(bytesPerSecond: Long): String = formatBytes(bytesPerSecond)
private fun formatBytes(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var value = bytes.toDouble().coerceAtLeast(0.0)
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex += 1
}
return if (unitIndex == 0) {
String.format(Locale.US, "%.0f %s", value, units[unitIndex])
} else {
String.format(Locale.US, "%.1f %s", value, units[unitIndex])
}
}
private fun createOpenAppPendingIntent(): PendingIntent {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?.apply {
@@ -249,3 +332,16 @@ private data class NotificationPayload(
val endpointText: String,
val detailsText: String,
)
private data class TrafficSample(
val bytesUp: Long,
val bytesDown: Long,
val timestampMillis: Long,
)
private data class TrafficState(
val upBytesPerSecond: Long,
val downBytesPerSecond: Long,
val totalBytesUp: Long,
val totalBytesDown: Long,
)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
import org.json.JSONObject
object PythonProxyBridge {
private const val MODULE_NAME = "android_proxy_bridge"
@@ -27,6 +28,20 @@ object PythonProxyBridge {
getModule(context).callAttr("stop_proxy")
}
fun getTrafficStats(context: Context): ProxyTrafficStats {
if (!Python.isStarted()) {
return ProxyTrafficStats()
}
val payload = getModule(context).callAttr("get_runtime_stats_json").toString()
val json = JSONObject(payload)
return ProxyTrafficStats(
bytesUp = json.optLong("bytes_up", 0L),
bytesDown = json.optLong("bytes_down", 0L),
running = json.optBoolean("running", false),
)
}
private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME)
@@ -37,3 +52,9 @@ object PythonProxyBridge {
return Python.getInstance()
}
}
data class ProxyTrafficStats(
val bytesUp: Long = 0L,
val bytesDown: Long = 0L,
val running: Boolean = false,
)

View File

@@ -1,10 +1,12 @@
import os
import threading
import time
import json
from pathlib import Path
from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime
import proxy.tg_ws_proxy as tg_ws_proxy
_RUNTIME_LOCK = threading.RLock()
@@ -49,6 +51,7 @@ def start_proxy(app_dir: str, host: str, port: int,
_LAST_ERROR = None
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
tg_ws_proxy.reset_stats()
runtime = ProxyAppRuntime(
Path(app_dir),
@@ -107,3 +110,12 @@ def is_running() -> bool:
def get_last_error() -> Optional[str]:
return _LAST_ERROR
def get_runtime_stats_json() -> str:
with _RUNTIME_LOCK:
running = bool(_RUNTIME and _RUNTIME.is_proxy_running())
payload = dict(tg_ws_proxy.get_stats_snapshot())
payload["running"] = running
return json.dumps(payload)

View File

@@ -36,9 +36,7 @@
<string name="notification_starting">SOCKS5 %1$s:%2$d • starting embedded Python</string>
<string name="notification_running">SOCKS5 %1$s:%2$d • proxy active</string>
<string name="notification_endpoint">%1$s:%2$d</string>
<string name="notification_details">SOCKS5 endpoint: %1$s:%2$d\nDC mappings: %3$d\nVerbose logging: %4$s\nTap to open the app, or stop the service from this notification.</string>
<string name="notification_verbose_on">enabled</string>
<string name="notification_verbose_off">disabled</string>
<string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
<string name="notification_action_stop">Stop</string>
<string name="saved_config_invalid">Saved proxy settings are invalid.</string>
<string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>