From 336602e93aac64a0e5a225678e7391502e75aa97 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Sat, 28 Mar 2026 21:13:41 +0300 Subject: [PATCH] feat(android): harden update checks, ci, and direct-only ui --- .github/workflows/build.yml | 26 +++-- android/app/build.gradle.kts | 14 ++- .../org/flowseal/tgwsproxy/MainActivity.kt | 37 ++++++- .../org/flowseal/tgwsproxy/ProxyConfig.kt | 2 +- .../flowseal/tgwsproxy/ProxySettingsStore.kt | 2 +- .../flowseal/tgwsproxy/PythonProxyBridge.kt | 18 +++- .../src/main/python/android_proxy_bridge.py | 19 +++- android/app/src/main/res/values/strings.xml | 4 +- android/build-local-debug.sh | 17 +++ proxy/app_runtime.py | 33 +----- tests/test_android_proxy_bridge.py | 100 +++++++++++++++--- tests/test_update_check.py | 53 ++++++++++ 12 files changed, 256 insertions(+), 69 deletions(-) create mode 100644 tests/test_update_check.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 52f753f..878da76 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-windows: runs-on: windows-latest @@ -329,7 +332,7 @@ jobs: working-directory: android steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Validate Android release signing secrets env: @@ -349,9 +352,14 @@ jobs: distribution: temurin java-version: "17" cache: gradle + cache-dependency-path: | + android/settings.gradle.kts + android/build.gradle.kts + android/gradle.properties + android/app/build.gradle.kts - name: Set up Python 3.12 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -390,13 +398,19 @@ jobs: cp app/build/outputs/apk/legacy32/release/app-legacy32-release.apk \ "app/build/outputs/apk/legacy32/release/$ANDROID_APK_LEGACY32_NAME" + - name: Stage Android release artifacts + run: | + mkdir -p dist + cp "app/build/outputs/apk/standard/release/$ANDROID_APK_STANDARD_NAME" "dist/$ANDROID_APK_STANDARD_NAME" + cp "app/build/outputs/apk/legacy32/release/$ANDROID_APK_LEGACY32_NAME" "dist/$ANDROID_APK_LEGACY32_NAME" + - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: TgWsProxy-android-release path: | - android/app/build/outputs/apk/standard/release/${{ env.ANDROID_APK_STANDARD_NAME }} - android/app/build/outputs/apk/legacy32/release/${{ env.ANDROID_APK_LEGACY32_NAME }} + android/dist/${{ env.ANDROID_APK_STANDARD_NAME }} + android/dist/${{ env.ANDROID_APK_LEGACY32_NAME }} release: needs: [build-windows, build-win7, build-macos, build-linux, build-android] @@ -410,7 +424,7 @@ jobs: merge-multiple: true - name: Download Android build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: TgWsProxy-android-release path: dist diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 216b0e5..bf3d97d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,6 +8,14 @@ plugins { id("org.jetbrains.kotlin.android") } +fun loadProxyVersionName(): String { + val versionFile = rootProject.projectDir.resolve("../proxy/__init__.py") + val match = Regex("""__version__\s*=\s*"([^"]+)"""") + .find(versionFile.readText()) + ?: throw GradleException("Failed to parse proxy version from ${versionFile.absolutePath}") + return match.groupValues[1] +} + data class ReleaseSigningEnv( val keystoreFile: File, val storePassword: String, @@ -51,12 +59,16 @@ val stagePythonSources by tasks.registering(Sync::class) { from(rootProject.projectDir.resolve("../proxy")) { into("proxy") } + from(rootProject.projectDir.resolve("../utils")) { + into("utils") + } into(stagedPythonSourcesDir) } val releaseSigningRequested = gradle.startParameter.taskNames.any { it.contains("release", ignoreCase = true) } val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested) +val appVersionName = loadProxyVersionName() android { namespace = "org.flowseal.tgwsproxy" @@ -67,7 +79,7 @@ android { minSdk = 26 targetSdk = 34 versionCode = 1 - versionName = "0.1.0" + versionName = appVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 e5429b7..a090c0c 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -63,7 +63,12 @@ class MainActivity : AppCompatActivity() { val config = settingsStore.load() renderConfig(config) - refreshUpdateStatus(checkNow = config.checkUpdates) + if (config.checkUpdates) { + refreshUpdateStatus(checkNow = true) + } else { + currentUpdateStatus = null + renderUpdateStatus(null, false) + } requestNotificationPermissionIfNeeded() observeServiceState() renderSystemStatus() @@ -88,7 +93,12 @@ class MainActivity : AppCompatActivity() { if (showMessage) { Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show() } - refreshUpdateStatus(checkNow = config.checkUpdates) + if (config.checkUpdates) { + refreshUpdateStatus(checkNow = true) + } else { + currentUpdateStatus = null + renderUpdateStatus(null, false) + } return config } @@ -152,8 +162,15 @@ class MainActivity : AppCompatActivity() { private fun refreshUpdateStatus(checkNow: Boolean) { lifecycleScope.launch { - val status = withContext(Dispatchers.IO) { - PythonProxyBridge.getUpdateStatus(this@MainActivity, checkNow) + val status = runCatching { + withContext(Dispatchers.IO) { + PythonProxyBridge.getUpdateStatus(this@MainActivity, checkNow) + } + }.getOrElse { exc -> + ProxyUpdateStatus( + currentVersion = "unknown", + error = exc.message ?: exc.javaClass.simpleName, + ) } currentUpdateStatus = status renderUpdateStatus(status, binding.checkUpdatesSwitch.isChecked) @@ -161,7 +178,7 @@ class MainActivity : AppCompatActivity() { } private fun renderUpdateStatus(status: ProxyUpdateStatus?, checkUpdatesEnabled: Boolean) { - val currentVersion = status?.currentVersion ?: "unknown" + val currentVersion = status?.currentVersion?.takeIf { it.isNotBlank() } ?: currentAppVersionName() binding.currentVersionValue.text = getString( R.string.updates_current_version_format, currentVersion, @@ -176,6 +193,9 @@ class MainActivity : AppCompatActivity() { !status.error.isNullOrBlank() -> { getString(R.string.updates_status_error, status.error) } + !status.checked -> { + getString(R.string.updates_status_idle) + } status.hasUpdate && !status.latestVersion.isNullOrBlank() -> { getString( R.string.updates_status_available, @@ -192,6 +212,13 @@ class MainActivity : AppCompatActivity() { } } + private fun currentAppVersionName(): String { + return runCatching { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0).versionName + }.getOrNull().orEmpty().ifBlank { "unknown" } + } + private fun observeServiceState() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt index c2bb797..bc8075a 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -7,7 +7,7 @@ data class ProxyConfig( val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB), val bufferKbText: String = DEFAULT_BUFFER_KB.toString(), val poolSizeText: String = DEFAULT_POOL_SIZE.toString(), - val checkUpdates: Boolean = true, + val checkUpdates: Boolean = false, val verbose: Boolean = false, ) { fun validate(): ValidationResult { diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt index 3414e66..6d726e9 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -27,7 +27,7 @@ class ProxySettingsStore(context: Context) { KEY_POOL_SIZE, ProxyConfig.DEFAULT_POOL_SIZE, ).toString(), - checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, true), + checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, false), verbose = preferences.getBoolean(KEY_VERBOSE, false), ) } diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt index 1d38614..1923300 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -8,6 +8,7 @@ import org.json.JSONObject object PythonProxyBridge { private const val MODULE_NAME = "android_proxy_bridge" + private val pythonStartLock = Any() fun start(context: Context, config: NormalizedProxyConfig): String { val module = getModule(context) @@ -54,6 +55,7 @@ object PythonProxyBridge { latestVersion = json.optString("latest").ifBlank { null }, hasUpdate = json.optBoolean("has_update", false), aheadOfRelease = json.optBoolean("ahead_of_release", false), + checked = json.optBoolean("checked", false), htmlUrl = json.optString("html_url").ifBlank { null }, error = json.optString("error").ifBlank { null }, ) @@ -63,8 +65,19 @@ object PythonProxyBridge { getPython(context.applicationContext).getModule(MODULE_NAME) private fun getPython(context: Context): Python { - if (!Python.isStarted()) { - Python.start(AndroidPlatform(context)) + if (Python.isStarted()) { + return Python.getInstance() + } + synchronized(pythonStartLock) { + if (!Python.isStarted()) { + try { + Python.start(AndroidPlatform(context)) + } catch (exc: IllegalStateException) { + if (!Python.isStarted()) { + throw exc + } + } + } } return Python.getInstance() } @@ -82,6 +95,7 @@ data class ProxyUpdateStatus( val latestVersion: String? = null, val hasUpdate: Boolean = false, val aheadOfRelease: Boolean = false, + val checked: Boolean = false, val htmlUrl: String? = null, val error: String? = null, ) diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index b99218b..72fd272 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -8,7 +8,9 @@ from typing import Iterable, Optional from proxy.app_runtime import ProxyAppRuntime from proxy import __version__ import proxy.tg_ws_proxy as tg_ws_proxy -from utils.update_check import RELEASES_PAGE_URL, get_status, run_check + + +RELEASES_PAGE_URL = "https://github.com/Dark-Avery/tg-ws-proxy/releases/latest" _RUNTIME_LOCK = threading.RLock() @@ -129,21 +131,30 @@ def get_runtime_stats_json() -> str: return json.dumps(payload) +def _load_update_check(): + from utils import update_check + return update_check + + def get_update_status_json(check_now: bool = False) -> str: payload = { "current_version": __version__, "latest": "", "has_update": False, "ahead_of_release": False, + "checked": False, "html_url": RELEASES_PAGE_URL, "error": "", } try: + update_check = _load_update_check() if check_now: - run_check(__version__) - payload.update(get_status()) + update_check.run_check(__version__) + payload.update(update_check.get_status()) payload["current_version"] = __version__ - payload["html_url"] = payload.get("html_url") or RELEASES_PAGE_URL + payload["latest"] = payload.get("latest") or "" + payload["html_url"] = payload.get("html_url") or update_check.RELEASES_PAGE_URL + payload["error"] = payload.get("error") or "" except Exception as exc: payload["error"] = str(exc) return json.dumps(payload) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4436f31..fe7e7c9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -18,13 +18,13 @@ Background restriction: enabled, Android may block long-running work. Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode. Proxy endpoint - Routing Advanced Actions Updates v%1$s Check for updates on launch - Status will appear after background check. + Checking GitHub release… + Not checked yet Automatic update checks are disabled. Installed version %1$s matches the latest GitHub release. Installed version %1$s is newer than the latest GitHub release. diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 5f26367..51e2881 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -121,6 +121,22 @@ prefetch_chaquopy_runtime() { done } +cleanup_stale_build_state() { + local stale_dirs=( + "$ROOT_DIR/app/build/python/env" + "$ROOT_DIR/app/build/intermediates/project_dex_archive" + "$ROOT_DIR/app/build/intermediates/desugar_graph" + "$ROOT_DIR/app/build/tmp/kotlin-classes" + "$ROOT_DIR/app/build/snapshot/kotlin" + ) + + for stale_dir in "${stale_dirs[@]}"; do + if [[ -d "$stale_dir" ]]; then + rm -rf "$stale_dir" + fi + done +} + prefetch_chaquopy_runtime for attempt in $(seq 1 "$ATTEMPTS"); do @@ -130,6 +146,7 @@ for attempt in $(seq 1 "$ATTEMPTS"); do fi if [[ "$attempt" -lt "$ATTEMPTS" ]]; then + cleanup_stale_build_state echo "Build failed, retrying in ${SLEEP_SECONDS}s..." sleep "$SLEEP_SECONDS" fi diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 0433da8..9cac781 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -17,17 +17,10 @@ DEFAULT_CONFIG = { "port": 1080, "host": "127.0.0.1", "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "upstream_mode": "telegram_ws_direct", - "relay_url": "", - "relay_token": "", - "direct_ws_timeout_seconds": 10.0, "log_max_mb": 5, "buf_kb": 256, "pool_size": 4, "verbose": False, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, } @@ -132,11 +125,7 @@ class ProxyAppRuntime: self.on_error(text) def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str], - host: str = "127.0.0.1", - upstream_mode: str = "telegram_ws_direct", - relay_url: str = "", - relay_token: str = "", - direct_ws_timeout_seconds: float = 10.0): + host: str = "127.0.0.1"): loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) stop_ev = _asyncio.Event() @@ -144,12 +133,7 @@ class ProxyAppRuntime: try: loop.run_until_complete( - self.run_proxy( - port, dc_opt, stop_event=stop_ev, host=host, - upstream_mode=upstream_mode, - relay_url=relay_url or None, - relay_token=relay_token, - direct_ws_timeout_seconds=direct_ws_timeout_seconds)) + self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host)) except Exception as exc: self.log.error("Proxy thread crashed: %s", exc) if ("10048" in str(exc) or @@ -173,15 +157,6 @@ class ProxyAppRuntime: port = active_cfg.get("port", self.default_config["port"]) host = active_cfg.get("host", self.default_config["host"]) dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"]) - upstream_mode = active_cfg.get( - "upstream_mode", self.default_config["upstream_mode"]) - relay_url = active_cfg.get( - "relay_url", self.default_config["relay_url"]) - relay_token = active_cfg.get( - "relay_token", self.default_config["relay_token"]) - direct_ws_timeout_seconds = active_cfg.get( - "direct_ws_timeout_seconds", - self.default_config["direct_ws_timeout_seconds"]) buf_kb = active_cfg.get("buf_kb", self.default_config["buf_kb"]) pool_size = active_cfg.get( "pool_size", self.default_config["pool_size"]) @@ -203,10 +178,6 @@ class ProxyAppRuntime: port, dc_opt, host, - upstream_mode, - relay_url, - relay_token, - direct_ws_timeout_seconds, ), daemon=True, name="proxy") diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 5ccacec..3ddadce 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -125,37 +125,105 @@ class AndroidProxyBridgeTests(unittest.TestCase): self.assertTrue(captured["verbose"]) def test_get_update_status_json_merges_python_update_state(self): - original_run_check = android_proxy_bridge.run_check - original_get_status = android_proxy_bridge.get_status + original_load_update_check = android_proxy_bridge._load_update_check try: captured = {} - def fake_run_check(version): - captured["run_check_version"] = version + class FakeUpdateCheck: + RELEASES_PAGE_URL = "https://example.com/releases/latest" - def fake_get_status(): - return { - "latest": "1.3.1", - "has_update": True, - "ahead_of_release": False, - "html_url": "https://example.com/release", - "error": "", - } + @staticmethod + def run_check(version): + captured["run_check_version"] = version - android_proxy_bridge.run_check = fake_run_check - android_proxy_bridge.get_status = fake_get_status + @staticmethod + def get_status(): + return { + "checked": True, + "latest": "1.3.1", + "has_update": True, + "ahead_of_release": False, + "html_url": "https://example.com/release", + "error": "", + } + android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck result = json.loads(android_proxy_bridge.get_update_status_json(True)) finally: - android_proxy_bridge.run_check = original_run_check - android_proxy_bridge.get_status = original_get_status + android_proxy_bridge._load_update_check = original_load_update_check self.assertEqual(captured["run_check_version"], android_proxy_bridge.__version__) self.assertEqual(result["current_version"], android_proxy_bridge.__version__) self.assertEqual(result["latest"], "1.3.1") self.assertTrue(result["has_update"]) + self.assertTrue(result["checked"]) self.assertEqual(result["html_url"], "https://example.com/release") + def test_get_update_status_json_reports_unchecked_state(self): + original_load_update_check = android_proxy_bridge._load_update_check + try: + class FakeUpdateCheck: + RELEASES_PAGE_URL = "https://example.com/releases/latest" + + @staticmethod + def get_status(): + return { + "checked": False, + "latest": "", + "has_update": False, + "ahead_of_release": False, + "html_url": "", + "error": "", + } + + android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck + result = json.loads(android_proxy_bridge.get_update_status_json(False)) + finally: + android_proxy_bridge._load_update_check = original_load_update_check + + self.assertFalse(result["checked"]) + self.assertEqual(result["current_version"], android_proxy_bridge.__version__) + + def test_get_update_status_json_reports_import_error_without_breaking_bridge(self): + original_load_update_check = android_proxy_bridge._load_update_check + try: + def fail(): + raise ModuleNotFoundError("No module named 'utils'") + + android_proxy_bridge._load_update_check = fail + result = json.loads(android_proxy_bridge.get_update_status_json(True)) + finally: + android_proxy_bridge._load_update_check = original_load_update_check + + self.assertFalse(result["checked"]) + self.assertIn("No module named 'utils'", result["error"]) + + def test_get_update_status_json_normalizes_none_fields_for_kotlin(self): + original_load_update_check = android_proxy_bridge._load_update_check + try: + class FakeUpdateCheck: + RELEASES_PAGE_URL = "https://example.com/releases/latest" + + @staticmethod + def get_status(): + return { + "checked": True, + "latest": None, + "has_update": False, + "ahead_of_release": True, + "html_url": None, + "error": None, + } + + android_proxy_bridge._load_update_check = lambda: FakeUpdateCheck + result = json.loads(android_proxy_bridge.get_update_status_json(False)) + finally: + android_proxy_bridge._load_update_check = original_load_update_check + + self.assertEqual(result["latest"], "") + self.assertEqual(result["error"], "") + self.assertEqual(result["html_url"], "https://example.com/releases/latest") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000..4947c3f --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,53 @@ +import unittest + +from utils import update_check + + +class UpdateCheckTests(unittest.TestCase): + def setUp(self): + self._orig_state = dict(update_check._state) + + def tearDown(self): + update_check._state.clear() + update_check._state.update(self._orig_state) + + def test_apply_release_tag_marks_update_available(self): + update_check._apply_release_tag( + tag="v1.3.1", + html_url="https://example.com/release", + current_version="1.3.0", + ) + + status = update_check.get_status() + self.assertTrue(status["has_update"]) + self.assertFalse(status["ahead_of_release"]) + self.assertEqual(status["latest"], "1.3.1") + self.assertEqual(status["html_url"], "https://example.com/release") + + def test_apply_release_tag_marks_ahead_of_release(self): + update_check._apply_release_tag( + tag="v1.1.2-relay", + html_url="https://example.com/release", + current_version="1.3.0", + ) + + status = update_check.get_status() + self.assertFalse(status["has_update"]) + self.assertTrue(status["ahead_of_release"]) + self.assertEqual(status["latest"], "1.1.2-relay") + + def test_apply_release_tag_marks_latest_when_versions_match(self): + update_check._apply_release_tag( + tag="v1.3.0", + html_url="https://example.com/release", + current_version="1.3.0", + ) + + status = update_check.get_status() + self.assertFalse(status["has_update"]) + self.assertFalse(status["ahead_of_release"]) + self.assertEqual(status["latest"], "1.3.0") + + +if __name__ == "__main__": + unittest.main()