feat(android): add update checks using shared python release checker

This commit is contained in:
Dark_Avery 2026-03-28 20:53:15 +03:00
parent 54b86cd9e2
commit 934eb345a2
9 changed files with 385 additions and 112 deletions

View File

@ -3,6 +3,7 @@ package org.flowseal.tgwsproxy
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
@ -14,13 +15,16 @@ 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.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.flowseal.tgwsproxy.databinding.ActivityMainBinding import org.flowseal.tgwsproxy.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var settingsStore: ProxySettingsStore private lateinit var settingsStore: ProxySettingsStore
private var currentUpdateStatus: ProxyUpdateStatus? = null
private val notificationPermissionLauncher = registerForActivityResult( private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
@ -46,6 +50,10 @@ class MainActivity : AppCompatActivity() {
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) }
binding.openLogsButton.setOnClickListener { onOpenLogsClicked() } binding.openLogsButton.setOnClickListener { onOpenLogsClicked() }
binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() }
binding.openReleasePageButton.setOnClickListener { onOpenReleasePageClicked() }
binding.checkUpdatesSwitch.setOnCheckedChangeListener { _, _ ->
renderUpdateStatus(currentUpdateStatus, binding.checkUpdatesSwitch.isChecked)
}
binding.disableBatteryOptimizationButton.setOnClickListener { binding.disableBatteryOptimizationButton.setOnClickListener {
AndroidSystemStatus.openBatteryOptimizationSettings(this) AndroidSystemStatus.openBatteryOptimizationSettings(this)
} }
@ -53,7 +61,9 @@ class MainActivity : AppCompatActivity() {
AndroidSystemStatus.openAppSettings(this) AndroidSystemStatus.openAppSettings(this)
} }
renderConfig(settingsStore.load()) val config = settingsStore.load()
renderConfig(config)
refreshUpdateStatus(checkNow = config.checkUpdates)
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
observeServiceState() observeServiceState()
renderSystemStatus() renderSystemStatus()
@ -78,6 +88,7 @@ class MainActivity : AppCompatActivity() {
if (showMessage) { if (showMessage) {
Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show() Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show()
} }
refreshUpdateStatus(checkNow = config.checkUpdates)
return config return config
} }
@ -111,7 +122,9 @@ class MainActivity : AppCompatActivity() {
binding.logMaxMbInput.setText(config.logMaxMbText) binding.logMaxMbInput.setText(config.logMaxMbText)
binding.bufferKbInput.setText(config.bufferKbText) binding.bufferKbInput.setText(config.bufferKbText)
binding.poolSizeInput.setText(config.poolSizeText) binding.poolSizeInput.setText(config.poolSizeText)
binding.checkUpdatesSwitch.isChecked = config.checkUpdates
binding.verboseSwitch.isChecked = config.verbose binding.verboseSwitch.isChecked = config.verbose
renderUpdateStatus(currentUpdateStatus, config.checkUpdates)
} }
private fun collectConfigFromForm(): ProxyConfig { private fun collectConfigFromForm(): ProxyConfig {
@ -122,10 +135,63 @@ class MainActivity : AppCompatActivity() {
logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(), logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(),
bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(), bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(),
poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(), poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(),
checkUpdates = binding.checkUpdatesSwitch.isChecked,
verbose = binding.verboseSwitch.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() { private fun observeServiceState() {
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.STARTED) {

View File

@ -7,6 +7,7 @@ data class ProxyConfig(
val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB), val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB),
val bufferKbText: String = DEFAULT_BUFFER_KB.toString(), val bufferKbText: String = DEFAULT_BUFFER_KB.toString(),
val poolSizeText: String = DEFAULT_POOL_SIZE.toString(), val poolSizeText: String = DEFAULT_POOL_SIZE.toString(),
val checkUpdates: Boolean = true,
val verbose: Boolean = false, val verbose: Boolean = false,
) { ) {
fun validate(): ValidationResult { fun validate(): ValidationResult {
@ -78,6 +79,7 @@ data class ProxyConfig(
logMaxMb = logMaxMbValue, logMaxMb = logMaxMbValue,
bufferKb = bufferKbValue, bufferKb = bufferKbValue,
poolSize = poolSizeValue, poolSize = poolSizeValue,
checkUpdates = checkUpdates,
verbose = verbose, verbose = verbose,
) )
) )
@ -130,5 +132,6 @@ data class NormalizedProxyConfig(
val logMaxMb: Double, val logMaxMb: Double,
val bufferKb: Int, val bufferKb: Int,
val poolSize: Int, val poolSize: Int,
val checkUpdates: Boolean,
val verbose: Boolean, val verbose: Boolean,
) )

View File

@ -27,6 +27,7 @@ class ProxySettingsStore(context: Context) {
KEY_POOL_SIZE, KEY_POOL_SIZE,
ProxyConfig.DEFAULT_POOL_SIZE, ProxyConfig.DEFAULT_POOL_SIZE,
).toString(), ).toString(),
checkUpdates = preferences.getBoolean(KEY_CHECK_UPDATES, true),
verbose = preferences.getBoolean(KEY_VERBOSE, false), verbose = preferences.getBoolean(KEY_VERBOSE, false),
) )
} }
@ -39,6 +40,7 @@ class ProxySettingsStore(context: Context) {
.putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat()) .putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat())
.putInt(KEY_BUFFER_KB, config.bufferKb) .putInt(KEY_BUFFER_KB, config.bufferKb)
.putInt(KEY_POOL_SIZE, config.poolSize) .putInt(KEY_POOL_SIZE, config.poolSize)
.putBoolean(KEY_CHECK_UPDATES, config.checkUpdates)
.putBoolean(KEY_VERBOSE, config.verbose) .putBoolean(KEY_VERBOSE, config.verbose)
.apply() .apply()
} }
@ -51,6 +53,7 @@ class ProxySettingsStore(context: Context) {
private const val KEY_LOG_MAX_MB = "log_max_mb" private const val KEY_LOG_MAX_MB = "log_max_mb"
private const val KEY_BUFFER_KB = "buf_kb" private const val KEY_BUFFER_KB = "buf_kb"
private const val KEY_POOL_SIZE = "pool_size" private const val KEY_POOL_SIZE = "pool_size"
private const val KEY_CHECK_UPDATES = "check_updates"
private const val KEY_VERBOSE = "verbose" private const val KEY_VERBOSE = "verbose"
} }
} }

View File

@ -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) = private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME) getPython(context.applicationContext).getModule(MODULE_NAME)
@ -63,3 +76,12 @@ data class ProxyTrafficStats(
val running: Boolean = false, val running: Boolean = false,
val lastError: String? = null, 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,
)

View File

@ -6,7 +6,9 @@ from pathlib import Path
from typing import Iterable, Optional from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime from proxy.app_runtime import ProxyAppRuntime
from proxy import __version__
import proxy.tg_ws_proxy as tg_ws_proxy 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() _RUNTIME_LOCK = threading.RLock()
@ -125,3 +127,23 @@ def get_runtime_stats_json() -> str:
payload["running"] = running payload["running"] = running
payload["last_error"] = _LAST_ERROR payload["last_error"] = _LAST_ERROR
return json.dumps(payload) 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)

View File

@ -95,6 +95,56 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/updates_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/currentVersionValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/checkUpdatesSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/updates_check_label" />
<TextView
android:id="@+id/updateStatusValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/updates_status_initial"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openReleasePageButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/updates_open_release_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -147,10 +197,28 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/endpoint_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:hint="@string/host_hint"> android:hint="@string/host_hint">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
@ -189,12 +257,32 @@
android:inputType="textMultiLine" android:inputType="textMultiLine"
android:minLines="5" /> android:minLines="5" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/advanced_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.materialswitch.MaterialSwitch <com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/verboseSwitch" android:id="@+id/verboseSwitch"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="14dp"
android:text="@string/verbose_label" /> android:text="@string/verbose_label" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
@ -238,6 +326,8 @@
android:inputType="number" android:inputType="number"
android:maxLines="1" /> android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView <TextView
android:id="@+id/errorText" android:id="@+id/errorText"
@ -248,12 +338,30 @@
android:textColor="?attr/colorError" android:textColor="?attr/colorError"
android:visibility="gone" /> android:visibility="gone" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/actions_section_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/saveButton" android:id="@+id/saveButton"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="18dp" android:layout_marginTop="14dp"
android:text="@string/save_button" /> android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
@ -295,6 +403,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@string/open_logs_button" /> android:text="@string/open_logs_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -17,6 +17,20 @@
<string name="system_check_background_ok">Background restriction: not detected.</string> <string name="system_check_background_ok">Background restriction: not detected.</string>
<string name="system_check_background_restricted">Background restriction: enabled, Android may block long-running work.</string> <string name="system_check_background_restricted">Background restriction: enabled, Android may block long-running work.</string>
<string name="system_check_oem_note">Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode.</string> <string name="system_check_oem_note">Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode.</string>
<string name="endpoint_section_label">Proxy endpoint</string>
<string name="routing_section_label">Routing</string>
<string name="advanced_section_label">Advanced</string>
<string name="actions_section_label">Actions</string>
<string name="updates_label">Updates</string>
<string name="updates_current_version_format">v%1$s</string>
<string name="updates_check_label">Check for updates on launch</string>
<string name="updates_status_initial">Status will appear after background check.</string>
<string name="updates_status_disabled">Automatic update checks are disabled.</string>
<string name="updates_status_latest">Installed version %1$s matches the latest GitHub release.</string>
<string name="updates_status_newer">Installed version %1$s is newer than the latest GitHub release.</string>
<string name="updates_status_available">Version %1$s is available on GitHub (installed: %2$s).</string>
<string name="updates_status_error">Update check failed: %1$s</string>
<string name="updates_open_release_button">Open Release Page</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>
@ -33,6 +47,7 @@
<string name="disable_battery_optimization_button">Disable Battery Optimization</string> <string name="disable_battery_optimization_button">Disable Battery Optimization</string>
<string name="open_app_settings_button">Open App Settings</string> <string name="open_app_settings_button">Open App Settings</string>
<string name="last_error_label">Last service error</string> <string name="last_error_label">Last service error</string>
<string name="release_page_open_failed">Failed to open release page.</string>
<string name="settings_saved">Settings saved</string> <string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string> <string name="service_start_requested">Foreground service start requested</string>
<string name="service_restart_requested">Foreground service restart requested</string> <string name="service_restart_requested">Foreground service restart requested</string>

View File

@ -124,6 +124,38 @@ class AndroidProxyBridgeTests(unittest.TestCase):
self.assertEqual(captured["log_max_mb"], 7.0) self.assertEqual(captured["log_max_mb"], 7.0)
self.assertTrue(captured["verbose"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -16,7 +16,7 @@ from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen 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_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest" RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"