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()