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

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ build/
.gradle/ .gradle/
.gradle-local/ .gradle-local/
android/.gradle-local/ android/.gradle-local/
android/.m2-chaquopy*/
local.properties local.properties
android/.idea/ android/.idea/
android/build/ android/build/

View File

@ -1,8 +1,19 @@
import org.gradle.api.tasks.Sync
plugins { plugins {
id("com.android.application") id("com.android.application")
id("com.chaquo.python")
id("org.jetbrains.kotlin.android") 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 { android {
namespace = "org.flowseal.tgwsproxy" namespace = "org.flowseal.tgwsproxy"
compileSdk = 34 compileSdk = 34
@ -15,6 +26,10 @@ android {
versionName = "0.1.0" versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
} }
buildTypes { buildTypes {
@ -41,6 +56,18 @@ android {
} }
} }
chaquopy {
defaultConfig {
version = "3.12"
}
sourceSets {
getByName("main") {
srcDir("src/main/python")
srcDir(stagePythonSources)
}
}
}
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")

View File

@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
@ -89,21 +90,41 @@ class MainActivity : AppCompatActivity() {
private fun observeServiceState() { private fun observeServiceState() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { 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( 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.startButton.isEnabled = !isStarting && !isRunning
binding.stopButton.isEnabled = isRunning binding.stopButton.isEnabled = isStarting || isRunning
} }
} }
} }
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { 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) { binding.serviceHint.text = if (config == null) {
getString(R.string.service_hint_idle) getString(R.string.service_hint_idle)
} else if (isStarting) {
getString(
R.string.service_hint_starting,
config.host,
config.port,
)
} else { } else {
getString( getString(
R.string.service_hint_running, 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() { private fun requestNotificationPermissionIfNeeded() {

View File

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

View File

@ -7,16 +7,43 @@ object ProxyServiceState {
private val _isRunning = MutableStateFlow(false) private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning val isRunning: StateFlow<Boolean> = _isRunning
private val _isStarting = MutableStateFlow(false)
val isStarting: StateFlow<Boolean> = _isStarting
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null) private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig 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) { fun markStarted(config: NormalizedProxyConfig) {
_activeConfig.value = config _activeConfig.value = config
_isStarting.value = false
_isRunning.value = true _isRunning.value = true
_lastError.value = null
}
fun markFailed(message: String) {
_activeConfig.value = null
_isStarting.value = false
_isRunning.value = false
_lastError.value = message
} }
fun markStopped() { fun markStopped() {
_activeConfig.value = null _activeConfig.value = null
_isStarting.value = false
_isRunning.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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">TG WS Proxy</string> <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_label">Foreground service</string>
<string name="status_starting">Starting</string>
<string name="status_running">Running</string> <string name="status_running">Running</string>
<string name="status_stopped">Stopped</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_idle">Configure the proxy settings, then start the foreground 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_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="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string> <string name="port_hint">Proxy port</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</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_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</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_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> </resources>

View File

@ -44,6 +44,51 @@ fi
ATTEMPTS="${ATTEMPTS:-5}" ATTEMPTS="${ATTEMPTS:-5}"
SLEEP_SECONDS="${SLEEP_SECONDS:-15}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}"
TASK="${1:-assembleDebug}" TASK="${1:-assembleDebug}"
LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}"
CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}"
prefetch_artifact() {
local relative_path="$1"
local destination="$LOCAL_CHAQUOPY_REPO/$relative_path"
if [[ -f "$destination" ]]; then
return 0
fi
mkdir -p "$(dirname "$destination")"
echo "Prefetching $relative_path"
curl \
--fail \
--location \
--retry 8 \
--retry-all-errors \
--continue-at - \
--connect-timeout 15 \
--speed-limit 1024 \
--speed-time 20 \
--max-time 90 \
--output "$destination" \
"$CHAQUOPY_MAVEN_BASE/$relative_path"
}
prefetch_chaquopy_runtime() {
local artifacts=(
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.pom"
"com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.jar"
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0.pom"
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-arm64-v8a.so"
"com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-x86_64.so"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0.pom"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-arm64-v8a.zip"
"com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip"
)
for artifact in "${artifacts[@]}"; do
prefetch_artifact "$artifact"
done
}
prefetch_chaquopy_runtime
for attempt in $(seq 1 "$ATTEMPTS"); do for attempt in $(seq 1 "$ATTEMPTS"); do
echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)"

View File

@ -1,4 +1,5 @@
plugins { plugins {
id("com.android.application") version "8.5.2" apply false id("com.android.application") version "8.5.2" apply false
id("com.chaquo.python") version "17.0.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false
} }

View File

@ -1,5 +1,10 @@
pluginManagement { pluginManagement {
repositories { repositories {
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
if (localChaquopyRepo.isDirectory) {
maven(url = localChaquopyRepo.toURI())
}
maven("https://chaquo.com/maven")
gradlePluginPortal() gradlePluginPortal()
google() google()
mavenCentral() mavenCentral()
@ -9,6 +14,11 @@ pluginManagement {
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy")
if (localChaquopyRepo.isDirectory) {
maven(url = localChaquopyRepo.toURI())
}
maven("https://chaquo.com/maven")
google() google()
mavenCentral() mavenCentral()
} }

1
proxy/__init__.py Normal file
View File

@ -0,0 +1 @@
"""TG WS Proxy core package."""

View File

@ -80,11 +80,20 @@ class ProxyAppRuntime:
root = logging.getLogger() root = logging.getLogger()
root.setLevel(logging.DEBUG if verbose else logging.INFO) root.setLevel(logging.DEBUG if verbose else logging.INFO)
for handler in list(root.handlers):
if getattr(handler, "_tg_ws_proxy_runtime_handler", False):
root.removeHandler(handler)
try:
handler.close()
except Exception:
pass
fh = logging.FileHandler(str(self.log_file), encoding="utf-8") fh = logging.FileHandler(str(self.log_file), encoding="utf-8")
fh.setLevel(logging.DEBUG) fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter( fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(name)s %(message)s", "%(asctime)s %(levelname)-5s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")) datefmt="%Y-%m-%d %H:%M:%S"))
fh._tg_ws_proxy_runtime_handler = True
root.addHandler(fh) root.addHandler(fh)
if not getattr(sys, "frozen", False): if not getattr(sys, "frozen", False):
@ -93,6 +102,7 @@ class ProxyAppRuntime:
ch.setFormatter(logging.Formatter( ch.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-5s %(message)s", "%(asctime)s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S")) datefmt="%H:%M:%S"))
ch._tg_ws_proxy_runtime_handler = True
root.addHandler(ch) root.addHandler(ch)
def prepare(self) -> dict: def prepare(self) -> dict:
@ -168,3 +178,6 @@ class ProxyAppRuntime:
self.stop_proxy() self.stop_proxy()
time.sleep(delay_seconds) time.sleep(delay_seconds)
return self.start_proxy() return self.start_proxy()
def is_proxy_running(self) -> bool:
return bool(self._proxy_thread and self._proxy_thread.is_alive())