From c5f8b40570e2285f512c1fd304ca8d158684560f Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:39:04 +0300 Subject: [PATCH 1/6] fix(android): normalize Chaquopy Java list inputs for proxy config --- .../src/main/python/android_proxy_bridge.py | 19 ++++++- tests/test_android_proxy_bridge.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/test_android_proxy_bridge.py diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index 1ade094..144854e 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -18,7 +18,24 @@ def _remember_error(message: str) -> None: def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]: - return [str(item).strip() for item in dc_ip_list if str(item).strip()] + if dc_ip_list is None: + return [] + + values: list[object] + try: + values = list(dc_ip_list) + except TypeError: + # Chaquopy may expose Kotlin's List as java.util.ArrayList, + # which isn't always directly iterable from Python. + if hasattr(dc_ip_list, "toArray"): + values = list(dc_ip_list.toArray()) + elif hasattr(dc_ip_list, "size") and hasattr(dc_ip_list, "get"): + size = int(dc_ip_list.size()) + values = [dc_ip_list.get(i) for i in range(size)] + else: + values = [dc_ip_list] + + return [str(item).strip() for item in values if str(item).strip()] def start_proxy(app_dir: str, host: str, port: int, diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py new file mode 100644 index 0000000..590ea29 --- /dev/null +++ b/tests/test_android_proxy_bridge.py @@ -0,0 +1,50 @@ +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str( + Path(__file__).resolve().parents[1] / "android" / "app" / "src" / "main" / "python" +)) + +import android_proxy_bridge # noqa: E402 + + +class FakeJavaArrayList: + def __init__(self, items): + self._items = list(items) + + def size(self): + return len(self._items) + + def get(self, index): + return self._items[index] + + +class AndroidProxyBridgeTests(unittest.TestCase): + def test_normalize_dc_ip_list_with_python_iterable(self): + result = android_proxy_bridge._normalize_dc_ip_list([ + "2:149.154.167.220", + " ", + "4:149.154.167.220 ", + ]) + + self.assertEqual(result, [ + "2:149.154.167.220", + "4:149.154.167.220", + ]) + + 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", + "4:149.154.167.220", + ])) + + self.assertEqual(result, [ + "2:149.154.167.220", + "4:149.154.167.220", + ]) + + +if __name__ == "__main__": + unittest.main() From db5a6cc696219938fbacdf635377a867df75a94b Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 22:18:06 +0300 Subject: [PATCH 2/6] feat(android): add Telegram proxy intent and background-limit status checks --- android/app/src/main/AndroidManifest.xml | 1 + .../flowseal/tgwsproxy/AndroidSystemStatus.kt | 62 +++++++++++++++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 51 +++++++++++++++ .../flowseal/tgwsproxy/TelegramProxyIntent.kt | 23 +++++++ .../app/src/main/res/layout/activity_main.xml | 60 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 12 ++++ 6 files changed, 209 insertions(+) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/AndroidSystemStatus.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 132b7ef..989d4c9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + = Build.VERSION_CODES.M) { + powerManager.isIgnoringBatteryOptimizations(context.packageName) + } else { + true + } + + val backgroundRestricted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activityManager.isBackgroundRestricted + } else { + false + } + + return AndroidSystemStatus( + ignoringBatteryOptimizations = ignoringBatteryOptimizations, + backgroundRestricted = backgroundRestricted, + ) + } + + fun openBatteryOptimizationSettings(context: Context) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt index fe311c0..4768752 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -1,9 +1,11 @@ package org.flowseal.tgwsproxy import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.provider.Settings import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -42,10 +44,23 @@ class MainActivity : AppCompatActivity() { binding.startButton.setOnClickListener { onStartClicked() } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } + binding.disableBatteryOptimizationButton.setOnClickListener { + AndroidSystemStatus.openBatteryOptimizationSettings(this) + } + binding.openAppSettingsButton.setOnClickListener { + AndroidSystemStatus.openAppSettings(this) + } renderConfig(settingsStore.load()) requestNotificationPermissionIfNeeded() observeServiceState() + renderSystemStatus() + } + + override fun onResume() { + super.onResume() + renderSystemStatus() } private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? { @@ -71,6 +86,13 @@ class MainActivity : AppCompatActivity() { Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() } + private fun onOpenTelegramClicked() { + val config = onSaveClicked(showMessage = false) ?: return + if (!TelegramProxyIntent.open(this, config)) { + Snackbar.make(binding.root, R.string.telegram_not_found, Snackbar.LENGTH_LONG).show() + } + } + private fun renderConfig(config: ProxyConfig) { binding.hostInput.setText(config.host) binding.portInput.setText(config.portText) @@ -153,6 +175,35 @@ class MainActivity : AppCompatActivity() { } } + private fun renderSystemStatus() { + val status = AndroidSystemStatus.read(this) + + binding.systemStatusValue.text = getString( + if (status.canKeepRunningReliably) { + R.string.system_status_ready + } else { + R.string.system_status_attention + }, + ) + + val lines = mutableListOf() + lines += if (status.ignoringBatteryOptimizations) { + getString(R.string.system_check_battery_ignored) + } else { + getString(R.string.system_check_battery_active) + } + lines += if (status.backgroundRestricted) { + getString(R.string.system_check_background_restricted) + } else { + getString(R.string.system_check_background_ok) + } + lines += getString(R.string.system_check_oem_note) + binding.systemStatusHint.text = lines.joinToString("\n") + + binding.disableBatteryOptimizationButton.isVisible = !status.ignoringBatteryOptimizations + binding.openAppSettingsButton.isVisible = status.backgroundRestricted || !status.ignoringBatteryOptimizations + } + private fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { return diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt new file mode 100644 index 0000000..213126e --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt @@ -0,0 +1,23 @@ +package org.flowseal.tgwsproxy + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri + +object TelegramProxyIntent { + fun open(context: Context, config: NormalizedProxyConfig): Boolean { + val uri = Uri.parse( + "tg://socks?server=${Uri.encode(config.host)}&port=${config.port}" + ) + val intent = Intent(Intent.ACTION_VIEW, uri) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + } +} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 04e35a2..e1dad84 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -64,6 +64,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 85b1c1c..b1f6041 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,6 +9,14 @@ Configure the proxy settings, then start the foreground service. Starting embedded Python proxy for %1$s:%2$d. Foreground service active for %1$s:%2$d. + Android background limits + Ready + Needs attention + Battery optimization: disabled for this app. + Battery optimization: still enabled, Android may stop the proxy in background. + Background restriction: not detected. + Background restriction: enabled, Android may block long-running work. + Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode. Proxy host Proxy port DC to IP mappings (one DC:IP per line) @@ -16,8 +24,12 @@ Save Settings Start Service Stop Service + Open in Telegram + Disable Battery Optimization + Open App Settings Settings saved Foreground service start requested + Telegram app was not found for tg://socks. TG WS Proxy Proxy service Keeps the Telegram proxy service alive in the foreground. From 8d43fa25fa300937ce0d5c46e8b0712bb05c681b Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 22:58:43 +0300 Subject: [PATCH 3/6] feat(android): add richer service notification --- .../tgwsproxy/ProxyForegroundService.kt | 112 +++++++++++++++++- android/app/src/main/res/values/strings.xml | 5 + 2 files changed, 111 insertions(+), 6 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 8d060bc..cba1138 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -3,11 +3,13 @@ package org.flowseal.tgwsproxy import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder +import androidx.core.app.TaskStackBuilder import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,7 +49,14 @@ class ProxyForegroundService : Service() { startForeground( NOTIFICATION_ID, buildNotification( - getString(R.string.notification_starting, config.host, config.port), + buildNotificationPayload( + config = config, + statusText = getString( + R.string.notification_starting, + config.host, + config.port, + ), + ), ), ) serviceScope.launch { @@ -68,11 +77,21 @@ class ProxyForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null - private fun buildNotification(contentText: String): Notification { + private fun buildNotification(payload: NotificationPayload): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.notification_title)) - .setContentText(contentText) + .setContentText(payload.statusText) + .setSubText(payload.endpointText) + .setStyle( + NotificationCompat.BigTextStyle().bigText(payload.detailsText), + ) .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(createOpenAppPendingIntent()) + .addAction( + 0, + getString(R.string.notification_action_stop), + createStopPendingIntent(), + ) .setOngoing(true) .setOnlyAlertOnce(true) .build() @@ -85,7 +104,16 @@ class ProxyForegroundService : Service() { result.onSuccess { ProxyServiceState.markStarted(config) - updateNotification(getString(R.string.notification_running, config.host, config.port)) + updateNotification( + buildNotificationPayload( + config = config, + statusText = getString( + R.string.notification_running, + config.host, + config.port, + ), + ), + ) }.onFailure { error -> ProxyServiceState.markFailed( error.message ?: getString(R.string.proxy_start_failed_generic), @@ -107,9 +135,75 @@ class ProxyForegroundService : Service() { } } - private fun updateNotification(contentText: String) { + private fun updateNotification(payload: NotificationPayload) { val manager = getSystemService(NotificationManager::class.java) - manager.notify(NOTIFICATION_ID, buildNotification(contentText)) + manager.notify(NOTIFICATION_ID, buildNotification(payload)) + } + + private fun buildNotificationPayload( + config: NormalizedProxyConfig, + statusText: String, + ): NotificationPayload { + 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) + }, + ) + return NotificationPayload( + statusText = statusText, + endpointText = endpointText, + detailsText = detailsText, + ) + } + + private fun createOpenAppPendingIntent(): PendingIntent { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + ?: Intent(this, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + + return TaskStackBuilder.create(this) + .addNextIntentWithParentStack(launchIntent) + .getPendingIntent( + 1, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ?: PendingIntent.getActivity( + this, + 1, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createStopPendingIntent(): PendingIntent { + val intent = Intent(this, ProxyForegroundService::class.java).apply { + action = ACTION_STOP + } + return PendingIntent.getService( + this, + 2, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun createNotificationChannel() { @@ -149,3 +243,9 @@ class ProxyForegroundService : Service() { } } } + +private data class NotificationPayload( + val statusText: String, + val endpointText: String, + val detailsText: String, +) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b1f6041..a3d52c8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -35,6 +35,11 @@ Keeps the Telegram proxy service alive in the foreground. 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 + Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. From da15296f66a92d934449f99a8e570113dc50b957 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:15:12 +0300 Subject: [PATCH 4/6] 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", From 61713703f83c9d222e1abe50b73fea220d37dfbd Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:30:08 +0300 Subject: [PATCH 5/6] build(android): add env-based release signing config --- .gitignore | 3 ++ android/app/build.gradle.kts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/.gitignore b/.gitignore index 28c6140..d523b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ local.properties android/.idea/ android/build/ android/app/build/ +android/*.jks +android/*.keystore +android/*.keystore.properties # OS Thumbs.db diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 18b3f4a..7fdacff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,4 +1,6 @@ import org.gradle.api.tasks.Sync +import org.gradle.api.GradleException +import java.io.File plugins { id("com.android.application") @@ -6,6 +8,44 @@ plugins { id("org.jetbrains.kotlin.android") } +data class ReleaseSigningEnv( + val keystoreFile: File, + val storePassword: String, + val keyAlias: String, + val keyPassword: String, +) + +fun requiredEnv(name: String): String { + return System.getenv(name)?.takeIf { it.isNotBlank() } + ?: throw GradleException("Missing required environment variable: $name") +} + +fun loadReleaseSigningEnv(releaseSigningRequested: Boolean): ReleaseSigningEnv? { + val keystorePath = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } + val anySigningEnvProvided = listOf( + keystorePath, + System.getenv("ANDROID_KEYSTORE_PASSWORD"), + System.getenv("ANDROID_KEY_ALIAS"), + System.getenv("ANDROID_KEY_PASSWORD"), + ).any { !it.isNullOrBlank() } + + if (!releaseSigningRequested && !anySigningEnvProvided) { + return null + } + + val keystoreFile = File(requiredEnv("ANDROID_KEYSTORE_FILE")) + if (!keystoreFile.isFile) { + throw GradleException("ANDROID_KEYSTORE_FILE does not exist: ${keystoreFile.absolutePath}") + } + + return ReleaseSigningEnv( + keystoreFile = keystoreFile, + storePassword = requiredEnv("ANDROID_KEYSTORE_PASSWORD"), + keyAlias = requiredEnv("ANDROID_KEY_ALIAS"), + keyPassword = requiredEnv("ANDROID_KEY_PASSWORD"), + ) +} + val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python") val stagePythonSources by tasks.registering(Sync::class) { from(rootProject.projectDir.resolve("../proxy")) { @@ -13,6 +53,10 @@ val stagePythonSources by tasks.registering(Sync::class) { } into(stagedPythonSourcesDir) } +val releaseSigningRequested = gradle.startParameter.taskNames.any { + it.contains("release", ignoreCase = true) +} +val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested) android { namespace = "org.flowseal.tgwsproxy" @@ -32,6 +76,17 @@ android { } } + signingConfigs { + if (releaseSigningEnv != null) { + create("release") { + storeFile = releaseSigningEnv.keystoreFile + storePassword = releaseSigningEnv.storePassword + keyAlias = releaseSigningEnv.keyAlias + keyPassword = releaseSigningEnv.keyPassword + } + } + } + buildTypes { release { isMinifyEnabled = false @@ -39,6 +94,9 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + if (releaseSigningEnv != null) { + signingConfig = signingConfigs.getByName("release") + } } } From c61e2e84ed25944a2fb9b1fddf2e7d4901d804f6 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:48:29 +0300 Subject: [PATCH 6/6] feat(ci): build and publish signed Android release APK --- .github/workflows/build.yml | 41 ++++++++++++++++++++++++++++++------- .gitignore | 2 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c7f113..225018f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,8 @@ jobs: build-android: runs-on: ubuntu-latest timeout-minutes: 30 + env: + ANDROID_APK_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk defaults: run: working-directory: android @@ -50,6 +52,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Validate Android release signing secrets + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + test -n "$ANDROID_KEYSTORE_BASE64" || { echo "Missing secret: ANDROID_KEYSTORE_BASE64"; exit 1; } + test -n "$ANDROID_KEYSTORE_PASSWORD" || { echo "Missing secret: ANDROID_KEYSTORE_PASSWORD"; exit 1; } + test -n "$ANDROID_KEY_ALIAS" || { echo "Missing secret: ANDROID_KEY_ALIAS"; exit 1; } + test -n "$ANDROID_KEY_PASSWORD" || { echo "Missing secret: ANDROID_KEY_PASSWORD"; exit 1; } + - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -71,19 +85,32 @@ jobs: - name: Install Android SDK packages run: sdkmanager "platforms;android-34" "build-tools;34.0.0" - - name: Build Android debug APK + - name: Prepare Android release keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/android-release.keystore" + test -s "$RUNNER_TEMP/android-release.keystore" + + - name: Build Android release APK + env: + LOCAL_CHAQUOPY_REPO: ${{ github.workspace }}/android/.m2-chaquopy-ci + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/android-release.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x gradlew build-local-debug.sh - LOCAL_CHAQUOPY_REPO="$GITHUB_WORKSPACE/android/.m2-chaquopy-ci" ./build-local-debug.sh + ./build-local-debug.sh assembleRelease - name: Rename APK - run: cp app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + run: cp app/build/outputs/apk/release/app-release.apk "app/build/outputs/apk/release/$ANDROID_APK_NAME" - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: TgWsProxy-android-debug - path: android/app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + name: TgWsProxy-android-release + path: android/app/build/outputs/apk/release/${{ env.ANDROID_APK_NAME }} build-win7: runs-on: windows-latest @@ -134,7 +161,7 @@ jobs: - name: Download Android build uses: actions/download-artifact@v4 with: - name: TgWsProxy-android-debug + name: TgWsProxy-android-release path: dist - name: Create GitHub Release @@ -147,7 +174,7 @@ jobs: files: | dist/TgWsProxy.exe dist/TgWsProxy-win7.exe - dist/tg-ws-proxy-android-debug.apk + dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk draft: false prerelease: false env: diff --git a/.gitignore b/.gitignore index d523b6d..f083934 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ android/.idea/ android/build/ android/app/build/ android/*.jks -android/*.keystore +*.keystore android/*.keystore.properties # OS