From da15296f66a92d934449f99a8e570113dc50b957 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:15:12 +0300 Subject: [PATCH] feat(android): show proxy traffic stats in foreground notification --- .../tgwsproxy/ProxyForegroundService.kt | 110 ++++++++++++++++-- .../flowseal/tgwsproxy/PythonProxyBridge.kt | 21 ++++ .../src/main/python/android_proxy_bridge.py | 12 ++ android/app/src/main/res/values/strings.xml | 4 +- proxy/tg_ws_proxy.py | 15 +++ tests/test_android_proxy_bridge.py | 19 +++ 6 files changed, 171 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt index cba1138..8e5bb25 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -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, +) diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt index b5b9f52..95c95fd 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -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, +) diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index 144854e..c1dc246 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -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) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3d52c8..7795a5f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -36,9 +36,7 @@ SOCKS5 %1$s:%2$d • starting embedded Python SOCKS5 %1$s:%2$d • proxy active %1$s:%2$d - 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. - enabled - disabled + DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 35cc3e7..b70b483 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -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] = {} diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 590ea29..2d314e7 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -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",