From 6cbec903608f2da272886e0cc2f4c1bad1e1b258 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 00:40:56 +0300 Subject: [PATCH] feat(android): add restart action, log viewer, and persistent service error state --- android/app/src/main/AndroidManifest.xml | 4 + .../flowseal/tgwsproxy/LogViewerActivity.kt | 53 ++++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 23 ++-- .../tgwsproxy/ProxyForegroundService.kt | 113 +++++++++++++----- .../flowseal/tgwsproxy/PythonProxyBridge.kt | 2 + .../src/main/python/android_proxy_bridge.py | 1 + .../main/res/layout/activity_log_viewer.xml | 94 +++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 47 ++++++++ android/app/src/main/res/values/strings.xml | 13 ++ tests/test_android_proxy_bridge.py | 9 ++ 10 files changed, 319 insertions(+), 40 deletions(-) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt create mode 100644 android/app/src/main/res/layout/activity_log_viewer.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 989d4c9..4f8e290 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ android:supportsRtl="true" android:theme="@style/Theme.TgWsProxy"> + + diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt new file mode 100644 index 0000000..63af385 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt @@ -0,0 +1,53 @@ +package org.flowseal.tgwsproxy + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.flowseal.tgwsproxy.databinding.ActivityLogViewerBinding +import java.io.File + +class LogViewerActivity : AppCompatActivity() { + private lateinit var binding: ActivityLogViewerBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityLogViewerBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.refreshLogsButton.setOnClickListener { renderLog() } + binding.closeLogsButton.setOnClickListener { finish() } + + renderLog() + } + + override fun onResume() { + super.onResume() + renderLog() + } + + private fun renderLog() { + val logFile = File(filesDir, "tg-ws-proxy/proxy.log") + binding.logPathValue.text = logFile.absolutePath + binding.logContentValue.text = readLogTail(logFile) + } + + private fun readLogTail(logFile: File, maxChars: Int = 40000): String { + if (!logFile.isFile) { + return getString(R.string.logs_empty) + } + + val text = runCatching { + logFile.readText(Charsets.UTF_8) + }.getOrElse { error -> + return getString(R.string.logs_read_failed, error.message ?: error.javaClass.simpleName) + } + + if (text.isBlank()) { + return getString(R.string.logs_empty) + } + if (text.length <= maxChars) { + return text + } + + return getString(R.string.logs_truncated_prefix) + "\n\n" + text.takeLast(maxChars) + } +} 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 4768752..2a178b3 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -5,7 +5,6 @@ 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 @@ -43,7 +42,9 @@ class MainActivity : AppCompatActivity() { binding.startButton.setOnClickListener { onStartClicked() } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } + binding.restartButton.setOnClickListener { onRestartClicked() } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + binding.openLogsButton.setOnClickListener { onOpenLogsClicked() } binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } binding.disableBatteryOptimizationButton.setOnClickListener { AndroidSystemStatus.openBatteryOptimizationSettings(this) @@ -86,6 +87,16 @@ class MainActivity : AppCompatActivity() { Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() } + private fun onRestartClicked() { + onSaveClicked(showMessage = false) ?: return + ProxyForegroundService.restart(this) + Snackbar.make(binding.root, R.string.service_restart_requested, Snackbar.LENGTH_SHORT).show() + } + + private fun onOpenLogsClicked() { + startActivity(Intent(this, LogViewerActivity::class.java)) + } + private fun onOpenTelegramClicked() { val config = onSaveClicked(showMessage = false) ?: return if (!TelegramProxyIntent.open(this, config)) { @@ -127,6 +138,7 @@ class MainActivity : AppCompatActivity() { ) binding.startButton.isEnabled = !isStarting && !isRunning binding.stopButton.isEnabled = isStarting || isRunning + binding.restartButton.isEnabled = !isStarting } } } @@ -162,13 +174,10 @@ class MainActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { ProxyServiceState.lastError.collect { error -> if (error.isNullOrBlank()) { - if (!binding.errorText.isVisible) { - return@collect - } - binding.errorText.isVisible = false + binding.lastErrorCard.isVisible = false } else { - binding.errorText.text = error - binding.errorText.isVisible = true + binding.lastErrorValue.text = error + binding.lastErrorCard.isVisible = true } } } 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 8e5bb25..1e888ba 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -43,33 +43,24 @@ class ProxyForegroundService : Service() { START_NOT_STICKY } - else -> { - val config = settingsStore.load().validate().normalized - if (config == null) { - ProxyServiceState.markFailed(getString(R.string.saved_config_invalid)) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - START_NOT_STICKY - } else { - ProxyServiceState.markStarting(config) - startForeground( - NOTIFICATION_ID, - buildNotification( - buildNotificationPayload( - config = config, - statusText = getString( - R.string.notification_starting, - config.host, - config.port, - ), - ), - ), - ) - serviceScope.launch { - startProxyRuntime(config) - } - START_STICKY + ACTION_RESTART -> { + val config = loadValidatedConfig() ?: return START_NOT_STICKY + ProxyServiceState.clearError() + beginProxyStart(config) + serviceScope.launch { + stopRuntimeOnly() + startProxyRuntime(config) } + START_STICKY + } + + else -> { + val config = loadValidatedConfig() ?: return START_NOT_STICKY + beginProxyStart(config) + serviceScope.launch { + startProxyRuntime(config) + } + START_STICKY } } } @@ -133,9 +124,36 @@ class ProxyForegroundService : Service() { } } + private fun loadValidatedConfig(): NormalizedProxyConfig? { + val config = settingsStore.load().validate().normalized + if (config == null) { + ProxyServiceState.markFailed(getString(R.string.saved_config_invalid)) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return config + } + + private fun beginProxyStart(config: NormalizedProxyConfig) { + ProxyServiceState.markStarting(config) + startForeground( + NOTIFICATION_ID, + buildNotification( + buildNotificationPayload( + config = config, + trafficState = TrafficState(), + statusText = getString( + R.string.notification_starting, + config.host, + config.port, + ), + ), + ), + ) + } + private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { - stopTrafficUpdates() - runCatching { PythonProxyBridge.stop(this) } + stopRuntimeOnly() ProxyServiceState.markStopped() if (removeNotification) { @@ -146,6 +164,11 @@ class ProxyForegroundService : Service() { } } + private fun stopRuntimeOnly() { + stopTrafficUpdates() + runCatching { PythonProxyBridge.stop(this) } + } + private fun updateNotification(payload: NotificationPayload) { val manager = getSystemService(NotificationManager::class.java) manager.notify(NOTIFICATION_ID, buildNotification(payload)) @@ -153,9 +176,9 @@ class ProxyForegroundService : Service() { private fun buildNotificationPayload( config: NormalizedProxyConfig, + trafficState: TrafficState, statusText: String, ): NotificationPayload { - val trafficState = readTrafficState() val endpointText = getString(R.string.notification_endpoint, config.host, config.port) val detailsText = getString( R.string.notification_details, @@ -176,9 +199,19 @@ class ProxyForegroundService : Service() { stopTrafficUpdates() trafficJob = serviceScope.launch { while (isActive && ProxyServiceState.isRunning.value) { + val trafficState = readTrafficState() + if (!trafficState.running) { + ProxyServiceState.markFailed( + trafficState.lastError ?: getString(R.string.proxy_runtime_stopped_unexpectedly), + ) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + break + } updateNotification( buildNotificationPayload( config = config, + trafficState = trafficState, statusText = getString( R.string.notification_running, config.host, @@ -213,6 +246,8 @@ class ProxyForegroundService : Service() { downBytesPerSecond = 0L, totalBytesUp = current.bytesUp, totalBytesDown = current.bytesDown, + running = current.running, + lastError = current.lastError, ) } @@ -224,6 +259,8 @@ class ProxyForegroundService : Service() { downBytesPerSecond = (downDelta * 1000L) / elapsedMillis, totalBytesUp = current.bytesUp, totalBytesDown = current.bytesDown, + running = current.running, + lastError = current.lastError, ) } @@ -310,6 +347,7 @@ class ProxyForegroundService : Service() { private const val NOTIFICATION_ID = 1001 private const val ACTION_START = "org.flowseal.tgwsproxy.action.START" private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP" + private const val ACTION_RESTART = "org.flowseal.tgwsproxy.action.RESTART" fun start(context: Context) { val intent = Intent(context, ProxyForegroundService::class.java).apply { @@ -324,6 +362,13 @@ class ProxyForegroundService : Service() { } context.startService(intent) } + + fun restart(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_RESTART + } + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } } } @@ -340,8 +385,10 @@ private data class TrafficSample( ) private data class TrafficState( - val upBytesPerSecond: Long, - val downBytesPerSecond: Long, - val totalBytesUp: Long, - val totalBytesDown: Long, + val upBytesPerSecond: Long = 0L, + val downBytesPerSecond: Long = 0L, + val totalBytesUp: Long = 0L, + val totalBytesDown: Long = 0L, + val running: Boolean = false, + val lastError: String? = null, ) 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 95c95fd..55ff549 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -39,6 +39,7 @@ object PythonProxyBridge { bytesUp = json.optLong("bytes_up", 0L), bytesDown = json.optLong("bytes_down", 0L), running = json.optBoolean("running", false), + lastError = json.optString("last_error").ifBlank { null }, ) } @@ -57,4 +58,5 @@ data class ProxyTrafficStats( val bytesUp: Long = 0L, val bytesDown: Long = 0L, val running: Boolean = false, + val lastError: String? = null, ) diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index c1dc246..910d1fb 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -118,4 +118,5 @@ def get_runtime_stats_json() -> str: payload = dict(tg_ws_proxy.get_stats_snapshot()) payload["running"] = running + payload["last_error"] = _LAST_ERROR return json.dumps(payload) diff --git a/android/app/src/main/res/layout/activity_log_viewer.xml b/android/app/src/main/res/layout/activity_log_viewer.xml new file mode 100644 index 0000000..5880535 --- /dev/null +++ b/android/app/src/main/res/layout/activity_log_viewer.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index e1dad84..ead3c2a 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -64,6 +64,37 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7795a5f..5a3ef8c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -24,11 +24,15 @@ Save Settings Start Service Stop Service + Restart Proxy Open in Telegram + Open Logs Disable Battery Optimization Open App Settings + Last service error Settings saved Foreground service start requested + Foreground service restart requested Telegram app was not found for tg://socks. TG WS Proxy Proxy service @@ -40,4 +44,13 @@ Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. + Proxy runtime stopped unexpectedly. + Proxy Logs + Shows the latest lines from the embedded Python proxy log. + Log file + Refresh Logs + Close + The log file is empty or has not been created yet. + Failed to read log file: %1$s + Showing the last part of the log file. diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 2d314e7..d7f159b 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -26,6 +26,7 @@ class FakeJavaArrayList: class AndroidProxyBridgeTests(unittest.TestCase): def tearDown(self): tg_ws_proxy.reset_stats() + android_proxy_bridge._LAST_ERROR = None def test_normalize_dc_ip_list_with_python_iterable(self): result = android_proxy_bridge._normalize_dc_ip_list([ @@ -52,6 +53,14 @@ class AndroidProxyBridgeTests(unittest.TestCase): self.assertEqual(result["bytes_up"], 1536) self.assertEqual(result["bytes_down"], 4096) self.assertFalse(result["running"]) + self.assertIsNone(result["last_error"]) + + def test_get_runtime_stats_json_includes_last_error(self): + android_proxy_bridge._LAST_ERROR = "boom" + + result = json.loads(android_proxy_bridge.get_runtime_stats_json()) + + self.assertEqual(result["last_error"], "boom") def test_normalize_dc_ip_list_with_java_array_list_shape(self): result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([