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 androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
class ProxyForegroundService : Service() { class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore private lateinit var settingsStore: ProxySettingsStore
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var trafficJob: Job? = null
private var lastTrafficSample: TrafficSample? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -69,6 +75,7 @@ class ProxyForegroundService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
stopTrafficUpdates()
serviceScope.cancel() serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) } runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped() ProxyServiceState.markStopped()
@ -104,6 +111,7 @@ class ProxyForegroundService : Service() {
result.onSuccess { result.onSuccess {
ProxyServiceState.markStarted(config) ProxyServiceState.markStarted(config)
lastTrafficSample = null
updateNotification( updateNotification(
buildNotificationPayload( buildNotificationPayload(
config = config, config = config,
@ -114,16 +122,19 @@ class ProxyForegroundService : Service() {
), ),
), ),
) )
startTrafficUpdates(config)
}.onFailure { error -> }.onFailure { error ->
ProxyServiceState.markFailed( ProxyServiceState.markFailed(
error.message ?: getString(R.string.proxy_start_failed_generic), error.message ?: getString(R.string.proxy_start_failed_generic),
) )
stopTrafficUpdates()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
stopTrafficUpdates()
runCatching { PythonProxyBridge.stop(this) } runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped() ProxyServiceState.markStopped()
@ -144,17 +155,15 @@ class ProxyForegroundService : Service() {
config: NormalizedProxyConfig, config: NormalizedProxyConfig,
statusText: String, statusText: String,
): NotificationPayload { ): NotificationPayload {
val trafficState = readTrafficState()
val endpointText = getString(R.string.notification_endpoint, config.host, config.port) val endpointText = getString(R.string.notification_endpoint, config.host, config.port)
val detailsText = getString( val detailsText = getString(
R.string.notification_details, R.string.notification_details,
config.host,
config.port,
config.dcIpList.size, config.dcIpList.size,
if (config.verbose) { formatRate(trafficState.upBytesPerSecond),
getString(R.string.notification_verbose_on) formatRate(trafficState.downBytesPerSecond),
} else { formatBytes(trafficState.totalBytesUp),
getString(R.string.notification_verbose_off) formatBytes(trafficState.totalBytesDown),
},
) )
return NotificationPayload( return NotificationPayload(
statusText = statusText, 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 { private fun createOpenAppPendingIntent(): PendingIntent {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?.apply { ?.apply {
@ -249,3 +332,16 @@ private data class NotificationPayload(
val endpointText: String, val endpointText: String,
val detailsText: 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.Python
import com.chaquo.python.android.AndroidPlatform import com.chaquo.python.android.AndroidPlatform
import java.io.File import java.io.File
import org.json.JSONObject
object PythonProxyBridge { object PythonProxyBridge {
private const val MODULE_NAME = "android_proxy_bridge" private const val MODULE_NAME = "android_proxy_bridge"
@ -27,6 +28,20 @@ object PythonProxyBridge {
getModule(context).callAttr("stop_proxy") 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) = private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME) getPython(context.applicationContext).getModule(MODULE_NAME)
@ -37,3 +52,9 @@ object PythonProxyBridge {
return Python.getInstance() 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 os
import threading import threading
import time import time
import json
from pathlib import Path from pathlib import Path
from typing import Iterable, Optional from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime from proxy.app_runtime import ProxyAppRuntime
import proxy.tg_ws_proxy as tg_ws_proxy
_RUNTIME_LOCK = threading.RLock() _RUNTIME_LOCK = threading.RLock()
@ -49,6 +51,7 @@ def start_proxy(app_dir: str, host: str, port: int,
_LAST_ERROR = None _LAST_ERROR = None
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python" os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
tg_ws_proxy.reset_stats()
runtime = ProxyAppRuntime( runtime = ProxyAppRuntime(
Path(app_dir), Path(app_dir),
@ -107,3 +110,12 @@ def is_running() -> bool:
def get_last_error() -> Optional[str]: def get_last_error() -> Optional[str]:
return _LAST_ERROR 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_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_running">SOCKS5 %1$s:%2$d • proxy active</string>
<string name="notification_endpoint">%1$s:%2$d</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_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
<string name="notification_verbose_on">enabled</string>
<string name="notification_verbose_off">disabled</string>
<string name="notification_action_stop">Stop</string> <string name="notification_action_stop">Stop</string>
<string name="saved_config_invalid">Saved proxy settings are invalid.</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> <string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>

View File

@ -492,6 +492,21 @@ class Stats:
_stats = 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: class _WsPool:
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, bool], list] = {} self._idle: Dict[Tuple[int, bool], list] = {}

View File

@ -1,5 +1,6 @@
import sys import sys
import unittest import unittest
import json
from pathlib import Path from pathlib import Path
@ -8,6 +9,7 @@ sys.path.insert(0, str(
)) ))
import android_proxy_bridge # noqa: E402 import android_proxy_bridge # noqa: E402
import proxy.tg_ws_proxy as tg_ws_proxy # noqa: E402
class FakeJavaArrayList: class FakeJavaArrayList:
@ -22,6 +24,9 @@ class FakeJavaArrayList:
class AndroidProxyBridgeTests(unittest.TestCase): class AndroidProxyBridgeTests(unittest.TestCase):
def tearDown(self):
tg_ws_proxy.reset_stats()
def test_normalize_dc_ip_list_with_python_iterable(self): def test_normalize_dc_ip_list_with_python_iterable(self):
result = android_proxy_bridge._normalize_dc_ip_list([ result = android_proxy_bridge._normalize_dc_ip_list([
"2:149.154.167.220", "2:149.154.167.220",
@ -34,6 +39,20 @@ class AndroidProxyBridgeTests(unittest.TestCase):
"4:149.154.167.220", "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): def test_normalize_dc_ip_list_with_java_array_list_shape(self):
result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([ result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([
"2:149.154.167.220", "2:149.154.167.220",