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",