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 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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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] = {}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue