Merge pull request #3 from Dark-Avery/android_migration

add restart action, log viewer, service error state, tray icon
This commit is contained in:
Dark-Avery 2026-03-17 00:59:36 +03:00 committed by GitHub
commit ee4d41ab9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 333 additions and 41 deletions

View File

@ -15,6 +15,10 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TgWsProxy"> android:theme="@style/Theme.TgWsProxy">
<activity
android:name=".LogViewerActivity"
android:exported="false" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">

View File

@ -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)
}
}

View File

@ -5,7 +5,6 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -43,7 +42,9 @@ class MainActivity : AppCompatActivity() {
binding.startButton.setOnClickListener { onStartClicked() } binding.startButton.setOnClickListener { onStartClicked() }
binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) }
binding.restartButton.setOnClickListener { onRestartClicked() }
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) }
binding.openLogsButton.setOnClickListener { onOpenLogsClicked() }
binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() }
binding.disableBatteryOptimizationButton.setOnClickListener { binding.disableBatteryOptimizationButton.setOnClickListener {
AndroidSystemStatus.openBatteryOptimizationSettings(this) AndroidSystemStatus.openBatteryOptimizationSettings(this)
@ -86,6 +87,16 @@ class MainActivity : AppCompatActivity() {
Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() 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() { private fun onOpenTelegramClicked() {
val config = onSaveClicked(showMessage = false) ?: return val config = onSaveClicked(showMessage = false) ?: return
if (!TelegramProxyIntent.open(this, config)) { if (!TelegramProxyIntent.open(this, config)) {
@ -127,6 +138,7 @@ class MainActivity : AppCompatActivity() {
) )
binding.startButton.isEnabled = !isStarting && !isRunning binding.startButton.isEnabled = !isStarting && !isRunning
binding.stopButton.isEnabled = isStarting || isRunning binding.stopButton.isEnabled = isStarting || isRunning
binding.restartButton.isEnabled = !isStarting
} }
} }
} }
@ -162,13 +174,10 @@ class MainActivity : AppCompatActivity() {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.lastError.collect { error -> ProxyServiceState.lastError.collect { error ->
if (error.isNullOrBlank()) { if (error.isNullOrBlank()) {
if (!binding.errorText.isVisible) { binding.lastErrorCard.isVisible = false
return@collect
}
binding.errorText.isVisible = false
} else { } else {
binding.errorText.text = error binding.lastErrorValue.text = error
binding.errorText.isVisible = true binding.lastErrorCard.isVisible = true
} }
} }
} }

View File

@ -43,33 +43,24 @@ class ProxyForegroundService : Service() {
START_NOT_STICKY START_NOT_STICKY
} }
else -> { ACTION_RESTART -> {
val config = settingsStore.load().validate().normalized val config = loadValidatedConfig() ?: return START_NOT_STICKY
if (config == null) { ProxyServiceState.clearError()
ProxyServiceState.markFailed(getString(R.string.saved_config_invalid)) beginProxyStart(config)
stopForeground(STOP_FOREGROUND_REMOVE) serviceScope.launch {
stopSelf() stopRuntimeOnly()
START_NOT_STICKY startProxyRuntime(config)
} 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
} }
START_STICKY
}
else -> {
val config = loadValidatedConfig() ?: return START_NOT_STICKY
beginProxyStart(config)
serviceScope.launch {
startProxyRuntime(config)
}
START_STICKY
} }
} }
} }
@ -92,7 +83,7 @@ class ProxyForegroundService : Service() {
.setStyle( .setStyle(
NotificationCompat.BigTextStyle().bigText(payload.detailsText), NotificationCompat.BigTextStyle().bigText(payload.detailsText),
) )
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(R.drawable.ic_proxy_notification)
.setContentIntent(createOpenAppPendingIntent()) .setContentIntent(createOpenAppPendingIntent())
.addAction( .addAction(
0, 0,
@ -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) { private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
stopTrafficUpdates() stopRuntimeOnly()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped() ProxyServiceState.markStopped()
if (removeNotification) { if (removeNotification) {
@ -146,6 +164,11 @@ class ProxyForegroundService : Service() {
} }
} }
private fun stopRuntimeOnly() {
stopTrafficUpdates()
runCatching { PythonProxyBridge.stop(this) }
}
private fun updateNotification(payload: NotificationPayload) { private fun updateNotification(payload: NotificationPayload) {
val manager = getSystemService(NotificationManager::class.java) val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(payload)) manager.notify(NOTIFICATION_ID, buildNotification(payload))
@ -153,9 +176,9 @@ class ProxyForegroundService : Service() {
private fun buildNotificationPayload( private fun buildNotificationPayload(
config: NormalizedProxyConfig, config: NormalizedProxyConfig,
trafficState: TrafficState,
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,
@ -176,9 +199,19 @@ class ProxyForegroundService : Service() {
stopTrafficUpdates() stopTrafficUpdates()
trafficJob = serviceScope.launch { trafficJob = serviceScope.launch {
while (isActive && ProxyServiceState.isRunning.value) { 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( updateNotification(
buildNotificationPayload( buildNotificationPayload(
config = config, config = config,
trafficState = trafficState,
statusText = getString( statusText = getString(
R.string.notification_running, R.string.notification_running,
config.host, config.host,
@ -213,6 +246,8 @@ class ProxyForegroundService : Service() {
downBytesPerSecond = 0L, downBytesPerSecond = 0L,
totalBytesUp = current.bytesUp, totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown, totalBytesDown = current.bytesDown,
running = current.running,
lastError = current.lastError,
) )
} }
@ -224,6 +259,8 @@ class ProxyForegroundService : Service() {
downBytesPerSecond = (downDelta * 1000L) / elapsedMillis, downBytesPerSecond = (downDelta * 1000L) / elapsedMillis,
totalBytesUp = current.bytesUp, totalBytesUp = current.bytesUp,
totalBytesDown = current.bytesDown, 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 NOTIFICATION_ID = 1001
private const val ACTION_START = "org.flowseal.tgwsproxy.action.START" private const val ACTION_START = "org.flowseal.tgwsproxy.action.START"
private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP" private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP"
private const val ACTION_RESTART = "org.flowseal.tgwsproxy.action.RESTART"
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, ProxyForegroundService::class.java).apply { val intent = Intent(context, ProxyForegroundService::class.java).apply {
@ -324,6 +362,13 @@ class ProxyForegroundService : Service() {
} }
context.startService(intent) 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( private data class TrafficState(
val upBytesPerSecond: Long, val upBytesPerSecond: Long = 0L,
val downBytesPerSecond: Long, val downBytesPerSecond: Long = 0L,
val totalBytesUp: Long, val totalBytesUp: Long = 0L,
val totalBytesDown: Long, val totalBytesDown: Long = 0L,
val running: Boolean = false,
val lastError: String? = null,
) )

View File

@ -39,6 +39,7 @@ object PythonProxyBridge {
bytesUp = json.optLong("bytes_up", 0L), bytesUp = json.optLong("bytes_up", 0L),
bytesDown = json.optLong("bytes_down", 0L), bytesDown = json.optLong("bytes_down", 0L),
running = json.optBoolean("running", false), running = json.optBoolean("running", false),
lastError = json.optString("last_error").ifBlank { null },
) )
} }
@ -57,4 +58,5 @@ data class ProxyTrafficStats(
val bytesUp: Long = 0L, val bytesUp: Long = 0L,
val bytesDown: Long = 0L, val bytesDown: Long = 0L,
val running: Boolean = false, val running: Boolean = false,
val lastError: String? = null,
) )

View File

@ -118,4 +118,5 @@ def get_runtime_stats_json() -> str:
payload = dict(tg_ws_proxy.get_stats_snapshot()) payload = dict(tg_ws_proxy.get_stats_snapshot())
payload["running"] = running payload["running"] = running
payload["last_error"] = _LAST_ERROR
return json.dumps(payload) return json.dumps(payload)

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,4h14v3h-5v12h-4V7H5z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8,20h8v2H8z" />
</vector>

View File

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/logs_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/logs_subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/logs_path_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/logPathValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:id="@+id/logContentValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textIsSelectable="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/refreshLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/refresh_logs_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/closeLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/close_logs_button" />
</LinearLayout>
</ScrollView>

View File

@ -64,6 +64,37 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:id="@+id/lastErrorCard"
android:visibility="gone"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/last_error_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorError" />
<TextView
android:id="@+id/lastErrorValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorError" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -199,6 +230,14 @@
android:enabled="false" android:enabled="false"
android:text="@string/stop_button" /> android:text="@string/stop_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/restartButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/restart_button" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/openTelegramButton" android:id="@+id/openTelegramButton"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
@ -207,5 +246,13 @@
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@string/open_telegram_button" /> android:text="@string/open_telegram_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openLogsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_logs_button" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -24,11 +24,15 @@
<string name="save_button">Save Settings</string> <string name="save_button">Save Settings</string>
<string name="start_button">Start Service</string> <string name="start_button">Start Service</string>
<string name="stop_button">Stop Service</string> <string name="stop_button">Stop Service</string>
<string name="restart_button">Restart Proxy</string>
<string name="open_telegram_button">Open in Telegram</string> <string name="open_telegram_button">Open in Telegram</string>
<string name="open_logs_button">Open Logs</string>
<string name="disable_battery_optimization_button">Disable Battery Optimization</string> <string name="disable_battery_optimization_button">Disable Battery Optimization</string>
<string name="open_app_settings_button">Open App Settings</string> <string name="open_app_settings_button">Open App Settings</string>
<string name="last_error_label">Last service error</string>
<string name="settings_saved">Settings saved</string> <string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string> <string name="service_start_requested">Foreground service start requested</string>
<string name="service_restart_requested">Foreground service restart requested</string>
<string name="telegram_not_found">Telegram app was not found for tg://socks.</string> <string name="telegram_not_found">Telegram app was not found for tg://socks.</string>
<string name="notification_title">TG WS Proxy</string> <string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string> <string name="notification_channel_name">Proxy service</string>
@ -40,4 +44,13 @@
<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>
<string name="proxy_runtime_stopped_unexpectedly">Proxy runtime stopped unexpectedly.</string>
<string name="logs_title">Proxy Logs</string>
<string name="logs_subtitle">Shows the latest lines from the embedded Python proxy log.</string>
<string name="logs_path_label">Log file</string>
<string name="refresh_logs_button">Refresh Logs</string>
<string name="close_logs_button">Close</string>
<string name="logs_empty">The log file is empty or has not been created yet.</string>
<string name="logs_read_failed">Failed to read log file: %1$s</string>
<string name="logs_truncated_prefix">Showing the last part of the log file.</string>
</resources> </resources>

View File

@ -26,6 +26,7 @@ class FakeJavaArrayList:
class AndroidProxyBridgeTests(unittest.TestCase): class AndroidProxyBridgeTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
tg_ws_proxy.reset_stats() tg_ws_proxy.reset_stats()
android_proxy_bridge._LAST_ERROR = None
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([
@ -52,6 +53,14 @@ class AndroidProxyBridgeTests(unittest.TestCase):
self.assertEqual(result["bytes_up"], 1536) self.assertEqual(result["bytes_up"], 1536)
self.assertEqual(result["bytes_down"], 4096) self.assertEqual(result["bytes_down"], 4096)
self.assertFalse(result["running"]) 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): 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([