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>

View File

@ -492,6 +492,21 @@ class Stats:
_stats = Stats()
def reset_stats() -> None:
global _stats
_stats = Stats()
def get_stats_snapshot() -> Dict[str, int]:
return {
"bytes_up": _stats.bytes_up,
"bytes_down": _stats.bytes_down,
"connections_total": _stats.connections_total,
"connections_ws": _stats.connections_ws,
"connections_tcp_fallback": _stats.connections_tcp_fallback,
}
class _WsPool:
def __init__(self):
self._idle: Dict[Tuple[int, bool], list] = {}

View File

@ -1,5 +1,6 @@
import sys
import unittest
import json
from pathlib import Path
@ -8,6 +9,7 @@ sys.path.insert(0, str(
))
import android_proxy_bridge # noqa: E402
import proxy.tg_ws_proxy as tg_ws_proxy # noqa: E402
class FakeJavaArrayList:
@ -22,6 +24,9 @@ class FakeJavaArrayList:
class AndroidProxyBridgeTests(unittest.TestCase):
def tearDown(self):
tg_ws_proxy.reset_stats()
def test_normalize_dc_ip_list_with_python_iterable(self):
result = android_proxy_bridge._normalize_dc_ip_list([
"2:149.154.167.220",
@ -34,6 +39,20 @@ class AndroidProxyBridgeTests(unittest.TestCase):
"4:149.154.167.220",
])
def test_get_runtime_stats_json_reports_proxy_counters(self):
tg_ws_proxy.reset_stats()
snapshot = tg_ws_proxy.get_stats_snapshot()
snapshot["bytes_up"] = 1536
snapshot["bytes_down"] = 4096
tg_ws_proxy._stats.bytes_up = snapshot["bytes_up"]
tg_ws_proxy._stats.bytes_down = snapshot["bytes_down"]
result = json.loads(android_proxy_bridge.get_runtime_stats_json())
self.assertEqual(result["bytes_up"], 1536)
self.assertEqual(result["bytes_down"], 4096)
self.assertFalse(result["running"])
def test_normalize_dc_ip_list_with_java_array_list_shape(self):
result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([
"2:149.154.167.220",