feat(android): show proxy traffic stats in foreground notification
This commit is contained in:
parent
8d43fa25fa
commit
da15296f66
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue