mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-05-26 09:21:43 +03:00
feat(android): embed python runtime and boot proxy service inside foreground service
This commit is contained in:
@@ -1,8 +1,19 @@
|
||||
import org.gradle.api.tasks.Sync
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.chaquo.python")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python")
|
||||
val stagePythonSources by tasks.registering(Sync::class) {
|
||||
from(rootProject.projectDir.resolve("../proxy")) {
|
||||
into("proxy")
|
||||
}
|
||||
into(stagedPythonSourcesDir)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.flowseal.tgwsproxy"
|
||||
compileSdk = 34
|
||||
@@ -15,6 +26,10 @@ android {
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -41,6 +56,18 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
chaquopy {
|
||||
defaultConfig {
|
||||
version = "3.12"
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
srcDir("src/main/python")
|
||||
srcDir(stagePythonSources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
92
android/app/src/main/python/android_proxy_bridge.py
Normal file
92
android/app/src/main/python/android_proxy_bridge.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user