feat(android): embed python runtime and boot proxy service inside foreground service

This commit is contained in:
Dark-Avery
2026-03-16 21:13:35 +03:00
parent 47e5c6241d
commit ec6de3afb3
13 changed files with 366 additions and 14 deletions

View File

@@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
@@ -89,21 +90,41 @@ class MainActivity : AppCompatActivity() {
private fun observeServiceState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.isRunning.collect { isRunning ->
combine(
ProxyServiceState.isStarting,
ProxyServiceState.isRunning,
) { isStarting, isRunning ->
isStarting to isRunning
}.collect { (isStarting, isRunning) ->
binding.statusValue.text = getString(
if (isRunning) R.string.status_running else R.string.status_stopped,
when {
isStarting -> R.string.status_starting
isRunning -> R.string.status_running
else -> R.string.status_stopped
},
)
binding.startButton.isEnabled = !isRunning
binding.stopButton.isEnabled = isRunning
binding.startButton.isEnabled = !isStarting && !isRunning
binding.stopButton.isEnabled = isStarting || isRunning
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.activeConfig.collect { config ->
combine(
ProxyServiceState.activeConfig,
ProxyServiceState.isStarting,
) { config, isStarting ->
config to isStarting
}.collect { (config, isStarting) ->
binding.serviceHint.text = if (config == null) {
getString(R.string.service_hint_idle)
} else if (isStarting) {
getString(
R.string.service_hint_starting,
config.host,
config.port,
)
} else {
getString(
R.string.service_hint_running,
@@ -114,6 +135,22 @@ class MainActivity : AppCompatActivity() {
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ProxyServiceState.lastError.collect { error ->
if (error.isNullOrBlank()) {
if (!binding.errorText.isVisible) {
return@collect
}
binding.errorText.isVisible = false
} else {
binding.errorText.text = error
binding.errorText.isVisible = true
}
}
}
}
}
private fun requestNotificationPermissionIfNeeded() {

View File

@@ -9,9 +9,15 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
@@ -22,19 +28,31 @@ class ProxyForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return when (intent?.action) {
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
ProxyServiceState.clearError()
serviceScope.launch {
stopProxyRuntime(removeNotification = true, stopService = true)
}
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.markStarted(config)
startForeground(NOTIFICATION_ID, buildNotification(config))
ProxyServiceState.markStarting(config)
startForeground(
NOTIFICATION_ID,
buildNotification(
getString(R.string.notification_starting, config.host, config.port),
),
)
serviceScope.launch {
startProxyRuntime(config)
}
START_STICKY
}
}
@@ -42,14 +60,15 @@ class ProxyForegroundService : Service() {
}
override fun onDestroy() {
serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(config: NormalizedProxyConfig): Notification {
val contentText = "SOCKS5 ${config.host}:${config.port} • service shell active"
private fun buildNotification(contentText: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(contentText)
@@ -59,6 +78,40 @@ class ProxyForegroundService : Service() {
.build()
}
private suspend fun startProxyRuntime(config: NormalizedProxyConfig) {
val result = runCatching {
PythonProxyBridge.start(this, config)
}
result.onSuccess {
ProxyServiceState.markStarted(config)
updateNotification(getString(R.string.notification_running, config.host, config.port))
}.onFailure { error ->
ProxyServiceState.markFailed(
error.message ?: getString(R.string.proxy_start_failed_generic),
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
if (removeNotification) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
if (stopService) {
stopSelf()
}
}
private fun updateNotification(contentText: String) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(contentText))
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return

View File

@@ -7,16 +7,43 @@ object ProxyServiceState {
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning
private val _isStarting = MutableStateFlow(false)
val isStarting: StateFlow<Boolean> = _isStarting
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError
fun markStarting(config: NormalizedProxyConfig) {
_activeConfig.value = config
_isStarting.value = true
_isRunning.value = false
_lastError.value = null
}
fun markStarted(config: NormalizedProxyConfig) {
_activeConfig.value = config
_isStarting.value = false
_isRunning.value = true
_lastError.value = null
}
fun markFailed(message: String) {
_activeConfig.value = null
_isStarting.value = false
_isRunning.value = false
_lastError.value = message
}
fun markStopped() {
_activeConfig.value = null
_isStarting.value = false
_isRunning.value = false
}
fun clearError() {
_lastError.value = null
}
}

View File

@@ -0,0 +1,39 @@
package org.flowseal.tgwsproxy
import android.content.Context
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
import java.io.File
object PythonProxyBridge {
private const val MODULE_NAME = "android_proxy_bridge"
fun start(context: Context, config: NormalizedProxyConfig): String {
val module = getModule(context)
return module.callAttr(
"start_proxy",
File(context.filesDir, "tg-ws-proxy").absolutePath,
config.host,
config.port,
config.dcIpList,
config.verbose,
).toString()
}
fun stop(context: Context) {
if (!Python.isStarted()) {
return
}
getModule(context).callAttr("stop_proxy")
}
private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME)
private fun getPython(context: Context): Python {
if (!Python.isStarted()) {
Python.start(AndroidPlatform(context))
}
return Python.getInstance()
}
}

View File

@@ -0,0 +1,92 @@
import os
import threading
import time
from pathlib import Path
from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime
_RUNTIME_LOCK = threading.RLock()
_RUNTIME: Optional[ProxyAppRuntime] = None
_LAST_ERROR: Optional[str] = None
def _remember_error(message: str) -> None:
global _LAST_ERROR
_LAST_ERROR = message
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()]
def start_proxy(app_dir: str, host: str, port: int,
dc_ip_list: Iterable[object], verbose: bool = False) -> str:
global _RUNTIME, _LAST_ERROR
with _RUNTIME_LOCK:
if _RUNTIME is not None:
_RUNTIME.stop_proxy()
_RUNTIME = None
_LAST_ERROR = None
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
runtime = ProxyAppRuntime(
Path(app_dir),
logger_name="tg-ws-android",
on_error=_remember_error,
)
runtime.reset_log_file()
runtime.setup_logging(verbose=verbose)
config = {
"host": host,
"port": int(port),
"dc_ip": _normalize_dc_ip_list(dc_ip_list),
"verbose": bool(verbose),
}
runtime.save_config(config)
if not runtime.start_proxy(config):
_RUNTIME = None
raise RuntimeError(_LAST_ERROR or "Failed to start proxy runtime.")
_RUNTIME = runtime
# Give the proxy thread a short warm-up window so immediate bind failures
# surface before Kotlin reports the service as running.
for _ in range(10):
time.sleep(0.1)
with _RUNTIME_LOCK:
if _LAST_ERROR:
runtime.stop_proxy()
_RUNTIME = None
raise RuntimeError(_LAST_ERROR)
if runtime.is_proxy_running():
return str(runtime.log_file)
with _RUNTIME_LOCK:
runtime.stop_proxy()
_RUNTIME = None
raise RuntimeError("Proxy runtime did not become ready in time.")
def stop_proxy() -> None:
global _RUNTIME, _LAST_ERROR
with _RUNTIME_LOCK:
_LAST_ERROR = None
if _RUNTIME is not None:
_RUNTIME.stop_proxy()
_RUNTIME = None
def is_running() -> bool:
with _RUNTIME_LOCK:
return bool(_RUNTIME and _RUNTIME.is_proxy_running())
def get_last_error() -> Optional[str]:
return _LAST_ERROR

View File

@@ -1,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TG WS Proxy</string>
<string name="subtitle">Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit.</string>
<string name="subtitle">Android app for the local Telegram SOCKS5 proxy.</string>
<string name="status_label">Foreground service</string>
<string name="status_starting">Starting</string>
<string name="status_running">Running</string>
<string name="status_stopped">Stopped</string>
<string name="service_hint_idle">The Android service shell is ready. Save settings, then start the service.</string>
<string name="service_hint_running">Foreground service active for %1$s:%2$d. Python proxy bootstrap will be connected next.</string>
<string name="service_hint_idle">Configure the proxy settings, then start the foreground service.</string>
<string name="service_hint_starting">Starting embedded Python proxy for %1$s:%2$d.</string>
<string name="service_hint_running">Foreground service active for %1$s:%2$d.</string>
<string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
@@ -19,4 +21,8 @@
<string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</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="saved_config_invalid">Saved proxy settings are invalid.</string>
<string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>
</resources>