diff --git a/.gitignore b/.gitignore index 139f38e..28c6140 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ .gradle/ .gradle-local/ android/.gradle-local/ +android/.m2-chaquopy*/ local.properties android/.idea/ android/build/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3929a27..18b3f4a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") 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 b017348..fe311c0 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -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() { 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 fa71c27..8d060bc 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -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 diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt index 1488c9c..ff08fc3 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt @@ -7,16 +7,43 @@ object ProxyServiceState { private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning + private val _isStarting = MutableStateFlow(false) + val isStarting: StateFlow = _isStarting + private val _activeConfig = MutableStateFlow(null) val activeConfig: StateFlow = _activeConfig + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _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 + } } diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt new file mode 100644 index 0000000..b5b9f52 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -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() + } +} diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py new file mode 100644 index 0000000..1ade094 --- /dev/null +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -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 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d124aa2..85b1c1c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,12 +1,14 @@ TG WS Proxy - Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit. + Android app for the local Telegram SOCKS5 proxy. Foreground service + Starting Running Stopped - The Android service shell is ready. Save settings, then start the service. - Foreground service active for %1$s:%2$d. Python proxy bootstrap will be connected next. + Configure the proxy settings, then start the foreground service. + Starting embedded Python proxy for %1$s:%2$d. + Foreground service active for %1$s:%2$d. Proxy host Proxy port DC to IP mappings (one DC:IP per line) @@ -19,4 +21,8 @@ TG WS Proxy Proxy service Keeps the Telegram proxy service alive in the foreground. + SOCKS5 %1$s:%2$d • starting embedded Python + SOCKS5 %1$s:%2$d • proxy active + Saved proxy settings are invalid. + Failed to start embedded Python proxy. diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 139e58c..46312b0 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -44,6 +44,51 @@ fi ATTEMPTS="${ATTEMPTS:-5}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}" 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 echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 017d909..bbaf05f 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,5 @@ plugins { 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 } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 94a30ff..050f305 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,5 +1,10 @@ pluginManagement { repositories { + val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy") + if (localChaquopyRepo.isDirectory) { + maven(url = localChaquopyRepo.toURI()) + } + maven("https://chaquo.com/maven") gradlePluginPortal() google() mavenCentral() @@ -9,6 +14,11 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy") + if (localChaquopyRepo.isDirectory) { + maven(url = localChaquopyRepo.toURI()) + } + maven("https://chaquo.com/maven") google() mavenCentral() } diff --git a/proxy/__init__.py b/proxy/__init__.py new file mode 100644 index 0000000..868f333 --- /dev/null +++ b/proxy/__init__.py @@ -0,0 +1 @@ +"""TG WS Proxy core package.""" diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 3710723..bdeaa34 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -80,11 +80,20 @@ class ProxyAppRuntime: root = logging.getLogger() 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.setLevel(logging.DEBUG) fh.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-5s %(name)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) + fh._tg_ws_proxy_runtime_handler = True root.addHandler(fh) if not getattr(sys, "frozen", False): @@ -93,6 +102,7 @@ class ProxyAppRuntime: ch.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S")) + ch._tg_ws_proxy_runtime_handler = True root.addHandler(ch) def prepare(self) -> dict: @@ -168,3 +178,6 @@ class ProxyAppRuntime: self.stop_proxy() time.sleep(delay_seconds) return self.start_proxy() + + def is_proxy_running(self) -> bool: + return bool(self._proxy_thread and self._proxy_thread.is_alive())