From 934eb345a2a23133a96e07f80a930c2ee3518937 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Sat, 28 Mar 2026 20:53:15 +0300 Subject: [PATCH] feat(android): add update checks using shared python release checker --- .../org/flowseal/tgwsproxy/MainActivity.kt | 68 +++- .../org/flowseal/tgwsproxy/ProxyConfig.kt | 3 + .../flowseal/tgwsproxy/ProxySettingsStore.kt | 3 + .../flowseal/tgwsproxy/PythonProxyBridge.kt | 22 ++ .../src/main/python/android_proxy_bridge.py | 22 ++ .../app/src/main/res/layout/activity_main.xml | 330 ++++++++++++------ android/app/src/main/res/values/strings.xml | 15 + tests/test_android_proxy_bridge.py | 32 ++ utils/update_check.py | 2 +- 9 files changed, 385 insertions(+), 112 deletions(-) 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 727be4f..e5429b7 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -3,6 +3,7 @@ package org.flowseal.tgwsproxy import android.Manifest import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.widget.Toast @@ -14,13 +15,16 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.flowseal.tgwsproxy.databinding.ActivityMainBinding class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var settingsStore: ProxySettingsStore + private var currentUpdateStatus: ProxyUpdateStatus? = null private val notificationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission(), @@ -46,6 +50,10 @@ class MainActivity : AppCompatActivity() { binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } binding.openLogsButton.setOnClickListener { onOpenLogsClicked() } binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } + binding.openReleasePageButton.setOnClickListener { onOpenReleasePageClicked() } + binding.checkUpdatesSwitch.setOnCheckedChangeListener { _, _ -> + renderUpdateStatus(currentUpdateStatus, binding.checkUpdatesSwitch.isChecked) + } binding.disableBatteryOptimizationButton.setOnClickListener { AndroidSystemStatus.openBatteryOptimizationSettings(this) } @@ -53,7 +61,9 @@ class MainActivity : AppCompatActivity() { AndroidSystemStatus.openAppSettings(this) } - renderConfig(settingsStore.load()) + val config = settingsStore.load() + renderConfig(config) + refreshUpdateStatus(checkNow = config.checkUpdates) requestNotificationPermissionIfNeeded() observeServiceState() renderSystemStatus() @@ -78,6 +88,7 @@ class MainActivity : AppCompatActivity() { if (showMessage) { Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show() } + refreshUpdateStatus(checkNow = config.checkUpdates) return config } @@ -111,7 +122,9 @@ class MainActivity : AppCompatActivity() { binding.logMaxMbInput.setText(config.logMaxMbText) binding.bufferKbInput.setText(config.bufferKbText) binding.poolSizeInput.setText(config.poolSizeText) + binding.checkUpdatesSwitch.isChecked = config.checkUpdates binding.verboseSwitch.isChecked = config.verbose + renderUpdateStatus(currentUpdateStatus, config.checkUpdates) } private fun collectConfigFromForm(): ProxyConfig { @@ -122,10 +135,63 @@ class MainActivity : AppCompatActivity() { logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(), bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(), poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(), + checkUpdates = binding.checkUpdatesSwitch.isChecked, verbose = binding.verboseSwitch.isChecked, ) } + private fun onOpenReleasePageClicked() { + val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Dark-Avery/tg-ws-proxy/releases/latest" + val opened = runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }.isSuccess + if (!opened) { + Snackbar.make(binding.root, R.string.release_page_open_failed, Snackbar.LENGTH_LONG).show() + } + } + + private fun refreshUpdateStatus(checkNow: Boolean) { + lifecycleScope.launch { + val status = withContext(Dispatchers.IO) { + PythonProxyBridge.getUpdateStatus(this@MainActivity, checkNow) + } + currentUpdateStatus = status + renderUpdateStatus(status, binding.checkUpdatesSwitch.isChecked) + } + } + + private fun renderUpdateStatus(status: ProxyUpdateStatus?, checkUpdatesEnabled: Boolean) { + val currentVersion = status?.currentVersion ?: "unknown" + binding.currentVersionValue.text = getString( + R.string.updates_current_version_format, + currentVersion, + ) + binding.updateStatusValue.text = when { + !checkUpdatesEnabled -> { + getString(R.string.updates_status_disabled) + } + status == null -> { + getString(R.string.updates_status_initial) + } + !status.error.isNullOrBlank() -> { + getString(R.string.updates_status_error, status.error) + } + status.hasUpdate && !status.latestVersion.isNullOrBlank() -> { + getString( + R.string.updates_status_available, + status.latestVersion, + status.currentVersion, + ) + } + status.aheadOfRelease -> { + getString(R.string.updates_status_newer, status.currentVersion) + } + else -> { + getString(R.string.updates_status_latest, status.currentVersion) + } + } + } + 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 39cf065..c2bb797 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -7,6 +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 verbose: Boolean = false, ) { fun validate(): ValidationResult { @@ -78,6 +79,7 @@ data class ProxyConfig( logMaxMb = logMaxMbValue, bufferKb = bufferKbValue, poolSize = poolSizeValue, + checkUpdates = checkUpdates, verbose = verbose, ) ) @@ -130,5 +132,6 @@ data class NormalizedProxyConfig( val logMaxMb: Double, val bufferKb: Int, val poolSize: Int, + val checkUpdates: Boolean, val verbose: Boolean, ) 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 9a08cc9..3414e66 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -27,6 +27,7 @@ class ProxySettingsStore(context: Context) { KEY_POOL_SIZE, ProxyConfig.DEFAULT_POOL_SIZE, ).toString(), + checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, true), verbose = preferences.getBoolean(KEY_VERBOSE, false), ) } @@ -39,6 +40,7 @@ class ProxySettingsStore(context: Context) { .putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat()) .putInt(KEY_BUFFER_KB, config.bufferKb) .putInt(KEY_POOL_SIZE, config.poolSize) + .putBoolean(KEY_CHECK_UPDATES, config.checkUpdates) .putBoolean(KEY_VERBOSE, config.verbose) .apply() } @@ -51,6 +53,7 @@ class ProxySettingsStore(context: Context) { private const val KEY_LOG_MAX_MB = "log_max_mb" private const val KEY_BUFFER_KB = "buf_kb" private const val KEY_POOL_SIZE = "pool_size" + private const val KEY_CHECK_UPDATES = "check_updates" private const val KEY_VERBOSE = "verbose" } } 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 e6e4ce9..1d38614 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -46,6 +46,19 @@ object PythonProxyBridge { ) } + fun getUpdateStatus(context: Context, checkNow: Boolean = false): ProxyUpdateStatus { + val payload = getModule(context).callAttr("get_update_status_json", checkNow).toString() + val json = JSONObject(payload) + return ProxyUpdateStatus( + currentVersion = json.optString("current_version").ifBlank { "unknown" }, + latestVersion = json.optString("latest").ifBlank { null }, + hasUpdate = json.optBoolean("has_update", false), + aheadOfRelease = json.optBoolean("ahead_of_release", false), + htmlUrl = json.optString("html_url").ifBlank { null }, + error = json.optString("error").ifBlank { null }, + ) + } + private fun getModule(context: Context) = getPython(context.applicationContext).getModule(MODULE_NAME) @@ -63,3 +76,12 @@ data class ProxyTrafficStats( val running: Boolean = false, val lastError: String? = null, ) + +data class ProxyUpdateStatus( + val currentVersion: String = "unknown", + val latestVersion: String? = null, + val hasUpdate: Boolean = false, + val aheadOfRelease: 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 b83201e..b99218b 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -6,7 +6,9 @@ from pathlib import Path 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 _RUNTIME_LOCK = threading.RLock() @@ -125,3 +127,23 @@ def get_runtime_stats_json() -> str: payload["running"] = running payload["last_error"] = _LAST_ERROR return json.dumps(payload) + + +def get_update_status_json(check_now: bool = False) -> str: + payload = { + "current_version": __version__, + "latest": "", + "has_update": False, + "ahead_of_release": False, + "html_url": RELEASES_PAGE_URL, + "error": "", + } + try: + if check_now: + run_check(__version__) + payload.update(get_status()) + payload["current_version"] = __version__ + payload["html_url"] = payload.get("html_url") or RELEASES_PAGE_URL + except Exception as exc: + payload["error"] = str(exc) + return json.dumps(payload) diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index db5aed8..ec2c29f 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -95,6 +95,56 @@ + + + + + + + + + + + + + + + + - + app:cardCornerRadius="20dp"> - - + android:orientation="vertical" + android:padding="18dp"> - + + + + + + + + + + + + + + + + + + + + app:cardCornerRadius="20dp"> - - + android:orientation="vertical" + android:padding="18dp"> - + - - + - + - + + - - + - + + - - + - - - - + + + + - - - + app:cardCornerRadius="20dp"> - + - + - + - + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4431061..4436f31 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -17,6 +17,20 @@ Background restriction: not detected. 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. + 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. + Version %1$s is available on GitHub (installed: %2$s). + Update check failed: %1$s + Open Release Page Proxy host Proxy port DC to IP mappings (one DC:IP per line) @@ -33,6 +47,7 @@ Disable Battery Optimization Open App Settings Last service error + Failed to open release page. Settings saved Foreground service start requested Foreground service restart requested diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 75cdb2b..5ccacec 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -124,6 +124,38 @@ class AndroidProxyBridgeTests(unittest.TestCase): self.assertEqual(captured["log_max_mb"], 7.0) 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 + try: + captured = {} + + def fake_run_check(version): + captured["run_check_version"] = version + + def fake_get_status(): + return { + "latest": "1.3.1", + "has_update": True, + "ahead_of_release": False, + "html_url": "https://example.com/release", + "error": "", + } + + android_proxy_bridge.run_check = fake_run_check + android_proxy_bridge.get_status = fake_get_status + + 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 + + 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.assertEqual(result["html_url"], "https://example.com/release") + if __name__ == "__main__": unittest.main() diff --git a/utils/update_check.py b/utils/update_check.py index 026dd41..86653fe 100644 --- a/utils/update_check.py +++ b/utils/update_check.py @@ -16,7 +16,7 @@ from typing import Any, Dict, Optional, Tuple from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen -REPO = "Flowseal/tg-ws-proxy" +REPO = "Dark-Avery/tg-ws-proxy" RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"