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([