From 1a12548dafbb39e8017655e3a670fc441792d361 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:36:22 +0300 Subject: [PATCH 01/37] refactor(crypto): extract AES-CTR backend adapter and keep cryptography as desktop backend --- proxy/crypto_backend.py | 37 +++++++++++++++++++++++++++++++++++++ proxy/tg_ws_proxy.py | 12 +++++------- 2 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 proxy/crypto_backend.py diff --git a/proxy/crypto_backend.py b/proxy/crypto_backend.py new file mode 100644 index 0000000..d7a99a2 --- /dev/null +++ b/proxy/crypto_backend.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import os +from typing import Protocol + + +class AesCtrTransform(Protocol): + def update(self, data: bytes) -> bytes: + ... + + def finalize(self) -> bytes: + ... + + +def _create_cryptography_transform(key: bytes, + iv: bytes) -> AesCtrTransform: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) + return cipher.encryptor() + + +def create_aes_ctr_transform(key: bytes, iv: bytes, + backend: str | None = None) -> AesCtrTransform: + """ + Create a stateful AES-CTR transform. + + The backend name is configurable so Android can supply an alternative + implementation later without touching proxy logic. + """ + selected = backend or os.environ.get( + 'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography') + + if selected == 'cryptography': + return _create_cryptography_transform(key, iv) + + raise ValueError(f"Unsupported AES-CTR backend: {selected}") diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 7912fd8..35cc3e7 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -11,7 +11,8 @@ import struct import sys import time from typing import Dict, List, Optional, Set, Tuple -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from proxy.crypto_backend import create_aes_ctr_transform DEFAULT_PORT = 1080 @@ -365,8 +366,7 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: try: key = bytes(data[8:40]) iv = bytes(data[40:56]) - cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) - encryptor = cipher.encryptor() + encryptor = create_aes_ctr_transform(key, iv) keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize() plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64])) proto = struct.unpack(' bytes: try: key_raw = bytes(data[8:40]) iv = bytes(data[40:56]) - cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv)) - enc = cipher.encryptor() + enc = create_aes_ctr_transform(key_raw, iv) ks = enc.update(b'\x00' * 64) + enc.finalize() patched = bytearray(data[:64]) patched[60] = ks[60] ^ new_dc[0] @@ -424,8 +423,7 @@ class _MsgSplitter: def __init__(self, init_data: bytes): key_raw = bytes(init_data[8:40]) iv = bytes(init_data[40:56]) - cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv)) - self._dec = cipher.encryptor() + self._dec = create_aes_ctr_transform(key_raw, iv) self._dec.update(b'\x00' * 64) # skip init packet def split(self, chunk: bytes) -> List[bytes]: From 7dc9b04016d401218a591a5c5a69ff622a028019 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:43:22 +0300 Subject: [PATCH 02/37] test(crypto): add MTProto init and splitter coverage --- tests/test_crypto_mtproto.py | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_crypto_mtproto.py diff --git a/tests/test_crypto_mtproto.py b/tests/test_crypto_mtproto.py new file mode 100644 index 0000000..7862029 --- /dev/null +++ b/tests/test_crypto_mtproto.py @@ -0,0 +1,85 @@ +import struct +import unittest + +from proxy.crypto_backend import create_aes_ctr_transform +from proxy.tg_ws_proxy import _MsgSplitter, _dc_from_init, _patch_init_dc + + +KEY = bytes(range(32)) +IV = bytes(range(16)) +PROTO_TAG = 0xEFEFEFEF + + +def _xor(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right)) + + +def _keystream(size: int) -> bytes: + transform = create_aes_ctr_transform(KEY, IV) + return transform.update(b"\x00" * size) + transform.finalize() + + +def _build_init_packet(dc_raw: int, proto: int = PROTO_TAG) -> bytes: + packet = bytearray(64) + packet[8:40] = KEY + packet[40:56] = IV + + plain_tail = struct.pack(" bytes: + transform = create_aes_ctr_transform(init_packet[8:40], init_packet[40:56]) + transform.update(b"\x00" * 64) + return transform.update(plaintext) + transform.finalize() + + +class CryptoBackendTests(unittest.TestCase): + def test_unknown_backend_raises_error(self): + with self.assertRaises(ValueError): + create_aes_ctr_transform(KEY, IV, backend="missing") + + +class MtProtoInitTests(unittest.TestCase): + def test_dc_from_init_reads_non_media_dc(self): + init_packet = _build_init_packet(dc_raw=2) + + self.assertEqual(_dc_from_init(init_packet), (2, False)) + + def test_dc_from_init_reads_media_dc(self): + init_packet = _build_init_packet(dc_raw=-4) + + self.assertEqual(_dc_from_init(init_packet), (4, True)) + + def test_patch_init_dc_updates_signed_dc_and_preserves_tail(self): + original = _build_init_packet(dc_raw=99) + b"tail" + + patched = _patch_init_dc(original, -3) + + self.assertEqual(_dc_from_init(patched[:64]), (3, True)) + self.assertEqual(patched[64:], b"tail") + + +class MsgSplitterTests(unittest.TestCase): + def test_splitter_splits_multiple_abridged_messages(self): + init_packet = _build_init_packet(dc_raw=-2) + plain_chunk = b"\x01abcd\x02EFGH1234" + encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) + + parts = _MsgSplitter(init_packet).split(encrypted_chunk) + + self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]]) + + def test_splitter_leaves_single_message_intact(self): + init_packet = _build_init_packet(dc_raw=2) + plain_chunk = b"\x02abcdefgh" + encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) + + parts = _MsgSplitter(init_packet).split(encrypted_chunk) + + self.assertEqual(parts, [encrypted_chunk]) + + +if __name__ == "__main__": + unittest.main() From 5e6fbdffda7f550c64c83c267f1517d87df34ae0 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:46:23 +0300 Subject: [PATCH 03/37] test(socks5): cover handshake, address parsing and connect failures --- tests/test_socks5_protocol.py | 129 ++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/test_socks5_protocol.py diff --git a/tests/test_socks5_protocol.py b/tests/test_socks5_protocol.py new file mode 100644 index 0000000..964cd44 --- /dev/null +++ b/tests/test_socks5_protocol.py @@ -0,0 +1,129 @@ +import asyncio +import socket +import unittest +from unittest.mock import patch + +from proxy.tg_ws_proxy import _handle_client, _socks5_reply + + +class _FakeTransport: + def get_extra_info(self, name): + return None + + def get_write_buffer_size(self): + return 0 + + +class _FakeReader: + def __init__(self, payload: bytes): + self._payload = payload + self._offset = 0 + + async def readexactly(self, n: int) -> bytes: + end = self._offset + n + if end > len(self._payload): + partial = self._payload[self._offset:] + self._offset = len(self._payload) + raise asyncio.IncompleteReadError(partial, n) + chunk = self._payload[self._offset:end] + self._offset = end + return chunk + + +class _FakeWriter: + def __init__(self): + self.transport = _FakeTransport() + self.writes = [] + self.closed = False + self.close_calls = 0 + + def get_extra_info(self, name): + if name == "peername": + return ("127.0.0.1", 50000) + return None + + def write(self, data: bytes): + self.writes.append(data) + + async def drain(self): + return None + + def close(self): + self.closed = True + self.close_calls += 1 + + async def wait_closed(self): + return None + + +def _ipv4_connect_request(ip: str, port: int, cmd: int = 1) -> bytes: + return bytes([0x05, cmd, 0x00, 0x01]) + socket.inet_aton(ip) + port.to_bytes(2, "big") + + +def _domain_connect_request(domain: str, port: int, cmd: int = 1) -> bytes: + encoded = domain.encode("utf-8") + return ( + bytes([0x05, cmd, 0x00, 0x03, len(encoded)]) + + encoded + + port.to_bytes(2, "big") + ) + + +def _ipv6_connect_request(ip: str, port: int) -> bytes: + return ( + bytes([0x05, 0x01, 0x00, 0x04]) + + socket.inet_pton(socket.AF_INET6, ip) + + port.to_bytes(2, "big") + ) + + +class Socks5ProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_non_socks5_greeting(self): + reader = _FakeReader(b"\x04\x01") + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, []) + self.assertTrue(writer.closed) + + async def test_rejects_unsupported_command(self): + reader = _FakeReader(b"\x05\x01\x00" + _ipv4_connect_request("1.1.1.1", 443, cmd=2)) + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x07)]) + self.assertTrue(writer.closed) + + async def test_rejects_unsupported_address_type(self): + reader = _FakeReader(b"\x05\x01\x00" + b"\x05\x01\x00\x02") + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x08)]) + self.assertTrue(writer.closed) + + async def test_rejects_ipv6_destinations(self): + reader = _FakeReader(b"\x05\x01\x00" + _ipv6_connect_request("2001:db8::1", 443)) + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) + self.assertTrue(writer.closed) + + async def test_passthrough_connect_failure_returns_error(self): + reader = _FakeReader(b"\x05\x01\x00" + _domain_connect_request("example.com", 443)) + writer = _FakeWriter() + + with patch("proxy.tg_ws_proxy.asyncio.open_connection", side_effect=OSError("boom")): + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) + self.assertTrue(writer.closed) + + +if __name__ == "__main__": + unittest.main() From ecc89d45d6af42f1768345c7c1056980b4a331fd Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 17:14:23 +0300 Subject: [PATCH 04/37] feat(crypto): add android-compatible pure-python AES-CTR backend --- proxy/crypto_backend.py | 175 ++++++++++++++++++++++++++++++++++- tests/test_crypto_mtproto.py | 22 +++++ 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/proxy/crypto_backend.py b/proxy/crypto_backend.py index d7a99a2..7508516 100644 --- a/proxy/crypto_backend.py +++ b/proxy/crypto_backend.py @@ -4,6 +4,33 @@ import os from typing import Protocol +_SBOX = ( + 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, + 0xFE, 0xD7, 0xAB, 0x76, 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, + 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, 0xB7, 0xFD, 0x93, 0x26, + 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, + 0xEB, 0x27, 0xB2, 0x75, 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, + 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, 0x53, 0xD1, 0x00, 0xED, + 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, + 0x50, 0x3C, 0x9F, 0xA8, 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, + 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, 0xCD, 0x0C, 0x13, 0xEC, + 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, + 0xDE, 0x5E, 0x0B, 0xDB, 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, + 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, 0xE7, 0xC8, 0x37, 0x6D, + 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, + 0x4B, 0xBD, 0x8B, 0x8A, 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, + 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, 0xE1, 0xF8, 0x98, 0x11, + 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, + 0xB0, 0x54, 0xBB, 0x16, +) +_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36) + + class AesCtrTransform(Protocol): def update(self, data: bytes) -> bytes: ... @@ -12,6 +39,147 @@ class AesCtrTransform(Protocol): ... +def _xtime(value: int) -> int: + value <<= 1 + if value & 0x100: + value ^= 0x11B + return value & 0xFF + + +def _mul2(value: int) -> int: + return _xtime(value) + + +def _mul3(value: int) -> int: + return _xtime(value) ^ value + + +def _add_round_key(state: list[int], round_key: bytes): + for idx in range(16): + state[idx] ^= round_key[idx] + + +def _sub_bytes(state: list[int]): + for idx in range(16): + state[idx] = _SBOX[state[idx]] + + +def _shift_rows(state: list[int]): + state[1], state[5], state[9], state[13] = ( + state[5], state[9], state[13], state[1] + ) + state[2], state[6], state[10], state[14] = ( + state[10], state[14], state[2], state[6] + ) + state[3], state[7], state[11], state[15] = ( + state[15], state[3], state[7], state[11] + ) + + +def _mix_columns(state: list[int]): + for offset in range(0, 16, 4): + s0, s1, s2, s3 = state[offset:offset + 4] + state[offset + 0] = _mul2(s0) ^ _mul3(s1) ^ s2 ^ s3 + state[offset + 1] = s0 ^ _mul2(s1) ^ _mul3(s2) ^ s3 + state[offset + 2] = s0 ^ s1 ^ _mul2(s2) ^ _mul3(s3) + state[offset + 3] = _mul3(s0) ^ s1 ^ s2 ^ _mul2(s3) + + +def _rot_word(word: list[int]) -> list[int]: + return word[1:] + word[:1] + + +def _sub_word(word: list[int]) -> list[int]: + return [_SBOX[value] for value in word] + + +def _expand_round_keys(key: bytes) -> tuple[list[bytes], int]: + if len(key) not in (16, 24, 32): + raise ValueError("AES key must be 16, 24, or 32 bytes long") + + nk = len(key) // 4 + nr = {4: 10, 6: 12, 8: 14}[nk] + words = [list(key[idx:idx + 4]) for idx in range(0, len(key), 4)] + total_words = 4 * (nr + 1) + + for idx in range(nk, total_words): + temp = words[idx - 1][:] + if idx % nk == 0: + temp = _sub_word(_rot_word(temp)) + temp[0] ^= _RCON[idx // nk - 1] + elif nk > 6 and idx % nk == 4: + temp = _sub_word(temp) + words.append([ + words[idx - nk][byte_idx] ^ temp[byte_idx] + for byte_idx in range(4) + ]) + + round_keys = [] + for round_idx in range(nr + 1): + start = round_idx * 4 + round_keys.append(bytes(sum(words[start:start + 4], []))) + return round_keys, nr + + +class _PurePythonAesCtrTransform: + def __init__(self, key: bytes, iv: bytes): + if len(iv) != 16: + raise ValueError("AES-CTR IV must be 16 bytes long") + self._round_keys, self._rounds = _expand_round_keys(key) + self._counter = bytearray(iv) + self._buffer = b"" + self._buffer_offset = 0 + + def update(self, data: bytes) -> bytes: + if not data: + return b"" + + out = bytearray(len(data)) + data_offset = 0 + + while data_offset < len(data): + if self._buffer_offset >= len(self._buffer): + self._buffer = self._encrypt_block(bytes(self._counter)) + self._buffer_offset = 0 + self._increment_counter() + + available = len(self._buffer) - self._buffer_offset + chunk_size = min(len(data) - data_offset, available) + for chunk_idx in range(chunk_size): + out[data_offset + chunk_idx] = ( + data[data_offset + chunk_idx] + ^ self._buffer[self._buffer_offset + chunk_idx] + ) + data_offset += chunk_size + self._buffer_offset += chunk_size + + return bytes(out) + + def finalize(self) -> bytes: + return b"" + + def _encrypt_block(self, block: bytes) -> bytes: + state = list(block) + _add_round_key(state, self._round_keys[0]) + + for round_idx in range(1, self._rounds): + _sub_bytes(state) + _shift_rows(state) + _mix_columns(state) + _add_round_key(state, self._round_keys[round_idx]) + + _sub_bytes(state) + _shift_rows(state) + _add_round_key(state, self._round_keys[self._rounds]) + return bytes(state) + + def _increment_counter(self): + for idx in range(15, -1, -1): + self._counter[idx] = (self._counter[idx] + 1) & 0xFF + if self._counter[idx] != 0: + break + + def _create_cryptography_transform(key: bytes, iv: bytes) -> AesCtrTransform: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -25,8 +193,8 @@ def create_aes_ctr_transform(key: bytes, iv: bytes, """ Create a stateful AES-CTR transform. - The backend name is configurable so Android can supply an alternative - implementation later without touching proxy logic. + Windows keeps using `cryptography` by default. Android can select the + pure-Python backend to avoid native build dependencies. """ selected = backend or os.environ.get( 'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography') @@ -34,4 +202,7 @@ def create_aes_ctr_transform(key: bytes, iv: bytes, if selected == 'cryptography': return _create_cryptography_transform(key, iv) + if selected == 'python': + return _PurePythonAesCtrTransform(key, iv) + raise ValueError(f"Unsupported AES-CTR backend: {selected}") diff --git a/tests/test_crypto_mtproto.py b/tests/test_crypto_mtproto.py index 7862029..4c5a18a 100644 --- a/tests/test_crypto_mtproto.py +++ b/tests/test_crypto_mtproto.py @@ -36,6 +36,28 @@ def _encrypt_after_init(init_packet: bytes, plaintext: bytes) -> bytes: class CryptoBackendTests(unittest.TestCase): + def test_python_backend_matches_cryptography_stream(self): + cryptography_transform = create_aes_ctr_transform( + KEY, IV, backend="cryptography") + python_transform = create_aes_ctr_transform(KEY, IV, backend="python") + + chunks = [ + b"", + b"\x00" * 16, + bytes(range(31)), + b"telegram-proxy", + b"\xff" * 64, + ] + + cryptography_out = b"".join( + cryptography_transform.update(chunk) for chunk in chunks + ) + cryptography_transform.finalize() + python_out = b"".join( + python_transform.update(chunk) for chunk in chunks + ) + python_transform.finalize() + + self.assertEqual(python_out, cryptography_out) + def test_unknown_backend_raises_error(self): with self.assertRaises(ValueError): create_aes_ctr_transform(KEY, IV, backend="missing") From ec70188385c6be5c51166c354362a9f81c7c5af4 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 17:22:51 +0300 Subject: [PATCH 05/37] refactor(core): extract cross-platform proxy runtime from windows app --- proxy/app_runtime.py | 170 ++++++++++++++++++++++++++++++++++++++ tests/test_app_runtime.py | 121 +++++++++++++++++++++++++++ windows.py | 134 +++++------------------------- 3 files changed, 310 insertions(+), 115 deletions(-) create mode 100644 proxy/app_runtime.py create mode 100644 tests/test_app_runtime.py diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py new file mode 100644 index 0000000..3710723 --- /dev/null +++ b/proxy/app_runtime.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import asyncio as _asyncio +import json +import logging +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Dict, Optional + +import proxy.tg_ws_proxy as tg_ws_proxy + + +DEFAULT_CONFIG = { + "port": 1080, + "host": "127.0.0.1", + "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], + "verbose": False, +} + + +class ProxyAppRuntime: + def __init__(self, app_dir: Path, + default_config: Optional[dict] = None, + logger_name: str = "tg-ws-runtime", + on_error: Optional[Callable[[str], None]] = None, + parse_dc_ip_list: Optional[ + Callable[[list[str]], Dict[int, str]] + ] = None, + run_proxy: Optional[Callable[..., object]] = None, + thread_factory: Optional[Callable[..., object]] = None): + self.app_dir = Path(app_dir) + self.config_file = self.app_dir / "config.json" + self.log_file = self.app_dir / "proxy.log" + self.default_config = dict(default_config or DEFAULT_CONFIG) + self.log = logging.getLogger(logger_name) + self.on_error = on_error + self.parse_dc_ip_list = parse_dc_ip_list or tg_ws_proxy.parse_dc_ip_list + self.run_proxy = run_proxy or tg_ws_proxy._run + self.thread_factory = thread_factory or threading.Thread + self.config: dict = {} + self._proxy_thread = None + self._async_stop = None + + def ensure_dirs(self): + self.app_dir.mkdir(parents=True, exist_ok=True) + + def load_config(self) -> dict: + self.ensure_dirs() + if self.config_file.exists(): + try: + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + for key, value in self.default_config.items(): + data.setdefault(key, value) + self.config = data + return data + except Exception as exc: + self.log.warning("Failed to load config: %s", exc) + + self.config = dict(self.default_config) + return dict(self.config) + + def save_config(self, cfg: dict): + self.ensure_dirs() + self.config = dict(cfg) + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + def reset_log_file(self): + if self.log_file.exists(): + try: + self.log_file.unlink() + except Exception: + pass + + def setup_logging(self, verbose: bool = False): + self.ensure_dirs() + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + + 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")) + root.addHandler(fh) + + if not getattr(sys, "frozen", False): + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(logging.DEBUG if verbose else logging.INFO) + ch.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S")) + root.addHandler(ch) + + def prepare(self) -> dict: + cfg = self.load_config() + self.save_config(cfg) + return cfg + + def _emit_error(self, text: str): + if self.on_error: + self.on_error(text) + + def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str], + host: str = "127.0.0.1"): + loop = _asyncio.new_event_loop() + _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() + self._async_stop = (loop, stop_ev) + + try: + loop.run_until_complete( + 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 + "Address already in use" in str(exc)): + self._emit_error( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите.") + finally: + loop.close() + self._async_stop = None + + def start_proxy(self, cfg: Optional[dict] = None) -> bool: + if self._proxy_thread and self._proxy_thread.is_alive(): + self.log.info("Proxy already running") + return True + + active_cfg = dict(cfg or self.config or self.default_config) + self.config = dict(active_cfg) + 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"]) + + try: + dc_opt = self.parse_dc_ip_list(dc_ip_list) + except ValueError as exc: + self.log.error("Bad config dc_ip: %s", exc) + self._emit_error("Ошибка конфигурации:\n%s" % exc) + return False + + self.log.info("Starting proxy on %s:%d ...", host, port) + self._proxy_thread = self.thread_factory( + target=self._run_proxy_thread, + args=(port, dc_opt, host), + daemon=True, + name="proxy") + self._proxy_thread.start() + return True + + def stop_proxy(self): + if self._async_stop: + loop, stop_ev = self._async_stop + loop.call_soon_threadsafe(stop_ev.set) + if self._proxy_thread: + self._proxy_thread.join(timeout=2) + self._proxy_thread = None + self.log.info("Proxy stopped") + + def restart_proxy(self, delay_seconds: float = 0.3) -> bool: + self.log.info("Restarting proxy...") + self.stop_proxy() + time.sleep(delay_seconds) + return self.start_proxy() diff --git a/tests/test_app_runtime.py b/tests/test_app_runtime.py new file mode 100644 index 0000000..b6026f9 --- /dev/null +++ b/tests/test_app_runtime.py @@ -0,0 +1,121 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime + + +class _FakeThread: + def __init__(self, target=None, args=(), daemon=None, name=None): + self.target = target + self.args = args + self.daemon = daemon + self.name = name + self.started = False + self.join_timeout = None + self._alive = False + + def start(self): + self.started = True + self._alive = True + + def is_alive(self): + return self._alive + + def join(self, timeout=None): + self.join_timeout = timeout + self._alive = False + + +class ProxyAppRuntimeTests(unittest.TestCase): + def test_load_config_returns_defaults_when_missing(self): + with tempfile.TemporaryDirectory() as tmpdir: + runtime = ProxyAppRuntime(Path(tmpdir)) + + cfg = runtime.load_config() + + self.assertEqual(cfg, DEFAULT_CONFIG) + + def test_load_config_merges_defaults_into_saved_config(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + config_path = app_dir / "config.json" + app_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps({"port": 9050, "host": "127.0.0.2"}), + encoding="utf-8") + runtime = ProxyAppRuntime(app_dir) + + cfg = runtime.load_config() + + self.assertEqual(cfg["port"], 9050) + self.assertEqual(cfg["host"], "127.0.0.2") + self.assertEqual(cfg["dc_ip"], DEFAULT_CONFIG["dc_ip"]) + self.assertEqual(cfg["verbose"], DEFAULT_CONFIG["verbose"]) + + def test_invalid_config_file_falls_back_to_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "config.json").write_text("{broken", encoding="utf-8") + runtime = ProxyAppRuntime(app_dir) + + cfg = runtime.load_config() + + self.assertEqual(cfg, DEFAULT_CONFIG) + + def test_start_proxy_starts_thread_with_parsed_dc_options(self): + with tempfile.TemporaryDirectory() as tmpdir: + captured = {} + thread_holder = {} + + def fake_parse(entries): + captured["dc_ip"] = list(entries) + return {2: "149.154.167.220"} + + def fake_thread_factory(**kwargs): + thread = _FakeThread(**kwargs) + thread_holder["thread"] = thread + return thread + + runtime = ProxyAppRuntime( + Path(tmpdir), + parse_dc_ip_list=fake_parse, + thread_factory=fake_thread_factory) + + started = runtime.start_proxy(dict(DEFAULT_CONFIG)) + + self.assertTrue(started) + self.assertEqual(captured["dc_ip"], DEFAULT_CONFIG["dc_ip"]) + self.assertTrue(thread_holder["thread"].started) + self.assertEqual( + thread_holder["thread"].args, + (DEFAULT_CONFIG["port"], {2: "149.154.167.220"}, + DEFAULT_CONFIG["host"])) + + def test_start_proxy_reports_bad_config(self): + with tempfile.TemporaryDirectory() as tmpdir: + errors = [] + + def fake_parse(entries): + raise ValueError("bad dc mapping") + + runtime = ProxyAppRuntime( + Path(tmpdir), + parse_dc_ip_list=fake_parse, + on_error=errors.append) + + started = runtime.start_proxy({ + "host": "127.0.0.1", + "port": 1080, + "dc_ip": ["broken"], + "verbose": False, + }) + + self.assertFalse(started) + self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows.py b/windows.py index 64e581b..c96531d 100644 --- a/windows.py +++ b/windows.py @@ -11,39 +11,33 @@ import time import webbrowser import pystray import pyperclip -import asyncio as _asyncio import customtkinter as ctk from pathlib import Path -from typing import Dict, Optional +from typing import Optional from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime APP_NAME = "TgWsProxy" APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" FIRST_RUN_MARKER = APP_DIR / ".first_run_done" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = { - "port": 1080, - "host": "127.0.0.1", - "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "verbose": False, -} - - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") +_runtime = ProxyAppRuntime( + APP_DIR, + default_config=DEFAULT_CONFIG, + logger_name="tg-ws-tray", + on_error=lambda text: _show_error(text), +) +CONFIG_FILE = _runtime.config_file +LOG_FILE = _runtime.log_file def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: @@ -120,48 +114,19 @@ def _acquire_lock() -> bool: def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) + _runtime.ensure_dirs() def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return _runtime.load_config() def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) + _runtime.save_config(cfg) def setup_logging(verbose: bool = False): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.FileHandler(str(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")) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) + _runtime.setup_logging(verbose) def _make_icon_image(size: int = 64): @@ -196,71 +161,16 @@ def _load_icon(): pass return _make_icon_image() - - -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - global _async_stop - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "10048" in str(exc) or "Address already in use" in str(exc): - _show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.") - finally: - loop.close() - _async_stop = None - - def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() + _runtime.start_proxy(_config) def stop_proxy(): - global _proxy_thread, _async_stop - if _async_stop: - loop, stop_ev = _async_stop - loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=2) - _proxy_thread = None - log.info("Proxy stopped") + _runtime.stop_proxy() def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() + _runtime.restart_proxy() def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): @@ -642,14 +552,8 @@ def _build_menu(): def run_tray(): global _tray_icon, _config - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass + _config = _runtime.prepare() + _runtime.reset_log_file() setup_logging(_config.get("verbose", False)) log.info("TG WS Proxy tray app starting") From 47e5c6241d7d389b80915541997e70ca22323e3a Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 19:45:57 +0300 Subject: [PATCH 06/37] feat(android): scaffold kotlin app with settings screen and foreground service shell --- .gitignore | 7 + android/app/build.gradle.kts | 55 ++++ android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 34 +++ .../org/flowseal/tgwsproxy/MainActivity.kt | 132 ++++++++++ .../org/flowseal/tgwsproxy/ProxyConfig.kt | 84 ++++++ .../tgwsproxy/ProxyForegroundService.kt | 98 +++++++ .../flowseal/tgwsproxy/ProxyServiceState.kt | 22 ++ .../flowseal/tgwsproxy/ProxySettingsStore.kt | 36 +++ .../src/main/res/drawable/ic_proxy_app.xml | 13 + .../app/src/main/res/layout/activity_main.xml | 151 +++++++++++ android/app/src/main/res/values/colors.xml | 7 + android/app/src/main/res/values/strings.xml | 22 ++ android/app/src/main/res/values/themes.xml | 11 + android/build-local-debug.sh | 61 +++++ android/build.gradle.kts | 4 + android/gradle.properties | 6 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 249 ++++++++++++++++++ android/gradlew.bat | 92 +++++++ android/settings.gradle.kts | 18 ++ 22 files changed, 1110 insertions(+) create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt create mode 100644 android/app/src/main/res/drawable/ic_proxy_app.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build-local-debug.sh create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/.gitignore b/.gitignore index 42a354f..139f38e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,13 @@ build/ .idea/ *.swp *.swo +.gradle/ +.gradle-local/ +android/.gradle-local/ +local.properties +android/.idea/ +android/build/ +android/app/build/ # OS Thumbs.db diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..3929a27 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.flowseal.tgwsproxy" + compileSdk = 34 + + defaultConfig { + applicationId = "org.flowseal.tgwsproxy" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("androidx.lifecycle:lifecycle-service:2.8.6") + implementation("com.google.android.material:material:1.12.0") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..dc67027 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally empty for the initial Android shell. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..132b7ef --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt new file mode 100644 index 0000000..b017348 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -0,0 +1,132 @@ +package org.flowseal.tgwsproxy + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.flowseal.tgwsproxy.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var settingsStore: ProxySettingsStore + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (!granted) { + Toast.makeText( + this, + "Без уведомлений Android может скрыть foreground service.", + Toast.LENGTH_LONG, + ).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + settingsStore = ProxySettingsStore(this) + setContentView(binding.root) + + binding.startButton.setOnClickListener { onStartClicked() } + binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } + binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + + renderConfig(settingsStore.load()) + requestNotificationPermissionIfNeeded() + observeServiceState() + } + + private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? { + val validation = collectConfigFromForm().validate() + val config = validation.normalized + if (config == null) { + binding.errorText.text = validation.errorMessage + binding.errorText.isVisible = true + return null + } + + binding.errorText.isVisible = false + settingsStore.save(config) + if (showMessage) { + Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show() + } + return config + } + + private fun onStartClicked() { + onSaveClicked(showMessage = false) ?: return + ProxyForegroundService.start(this) + Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() + } + + private fun renderConfig(config: ProxyConfig) { + binding.hostInput.setText(config.host) + binding.portInput.setText(config.portText) + binding.dcIpInput.setText(config.dcIpText) + binding.verboseSwitch.isChecked = config.verbose + } + + private fun collectConfigFromForm(): ProxyConfig { + return ProxyConfig( + host = binding.hostInput.text?.toString().orEmpty(), + portText = binding.portInput.text?.toString().orEmpty(), + dcIpText = binding.dcIpInput.text?.toString().orEmpty(), + verbose = binding.verboseSwitch.isChecked, + ) + } + + private fun observeServiceState() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ProxyServiceState.isRunning.collect { isRunning -> + binding.statusValue.text = getString( + if (isRunning) R.string.status_running else R.string.status_stopped, + ) + binding.startButton.isEnabled = !isRunning + binding.stopButton.isEnabled = isRunning + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ProxyServiceState.activeConfig.collect { config -> + binding.serviceHint.text = if (config == null) { + getString(R.string.service_hint_idle) + } else { + getString( + R.string.service_hint_running, + config.host, + config.port, + ) + } + } + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt new file mode 100644 index 0000000..a8ffcde --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -0,0 +1,84 @@ +package org.flowseal.tgwsproxy + +data class ProxyConfig( + val host: String = DEFAULT_HOST, + val portText: String = DEFAULT_PORT.toString(), + val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"), + val verbose: Boolean = false, +) { + fun validate(): ValidationResult { + val hostValue = host.trim() + if (!isIpv4Address(hostValue)) { + return ValidationResult(errorMessage = "IP-адрес прокси указан некорректно.") + } + + val portValue = portText.trim().toIntOrNull() + ?: return ValidationResult(errorMessage = "Порт должен быть числом.") + if (portValue !in 1..65535) { + return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.") + } + + val lines = dcIpText + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + + if (lines.isEmpty()) { + return ValidationResult(errorMessage = "Добавьте хотя бы один DC:IP маппинг.") + } + + for (line in lines) { + val parts = line.split(":", limit = 2) + val dcValue = parts.firstOrNull()?.toIntOrNull() + val ipValue = parts.getOrNull(1)?.trim().orEmpty() + if (parts.size != 2 || dcValue == null || !isIpv4Address(ipValue)) { + return ValidationResult(errorMessage = "Строка \"$line\" должна быть в формате DC:IP.") + } + } + + return ValidationResult( + normalized = NormalizedProxyConfig( + host = hostValue, + port = portValue, + dcIpList = lines, + verbose = verbose, + ) + ) + } + + companion object { + const val DEFAULT_HOST = "127.0.0.1" + const val DEFAULT_PORT = 1080 + val DEFAULT_DC_IP_LINES = listOf( + "2:149.154.167.220", + "4:149.154.167.220", + ) + + private fun isIpv4Address(value: String): Boolean { + val octets = value.split(".") + if (octets.size != 4) { + return false + } + + return octets.all { octet -> + octet.isNotEmpty() && + octet.length <= 3 && + octet.all(Char::isDigit) && + octet.toIntOrNull() in 0..255 + } + } + } +} + +data class ValidationResult( + val normalized: NormalizedProxyConfig? = null, + val errorMessage: String? = null, +) + +data class NormalizedProxyConfig( + val host: String, + val port: Int, + val dcIpList: List, + val verbose: Boolean, +) diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt new file mode 100644 index 0000000..fa71c27 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -0,0 +1,98 @@ +package org.flowseal.tgwsproxy + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class ProxyForegroundService : Service() { + private lateinit var settingsStore: ProxySettingsStore + + override fun onCreate() { + super.onCreate() + settingsStore = ProxySettingsStore(this) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return when (intent?.action) { + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + START_NOT_STICKY + } + + else -> { + val config = settingsStore.load().validate().normalized + if (config == null) { + stopSelf() + START_NOT_STICKY + } else { + ProxyServiceState.markStarted(config) + startForeground(NOTIFICATION_ID, buildNotification(config)) + START_STICKY + } + } + } + } + + override fun onDestroy() { + 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" + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(contentText) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.notification_channel_description) + } + manager.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "proxy_service" + private const val NOTIFICATION_ID = 1001 + private const val ACTION_START = "org.flowseal.tgwsproxy.action.START" + private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP" + + fun start(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_START + } + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_STOP + } + context.startService(intent) + } + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt new file mode 100644 index 0000000..1488c9c --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt @@ -0,0 +1,22 @@ +package org.flowseal.tgwsproxy + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object ProxyServiceState { + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning + + private val _activeConfig = MutableStateFlow(null) + val activeConfig: StateFlow = _activeConfig + + fun markStarted(config: NormalizedProxyConfig) { + _activeConfig.value = config + _isRunning.value = true + } + + fun markStopped() { + _activeConfig.value = null + _isRunning.value = false + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt new file mode 100644 index 0000000..e93249d --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -0,0 +1,36 @@ +package org.flowseal.tgwsproxy + +import android.content.Context + +class ProxySettingsStore(context: Context) { + private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): ProxyConfig { + return ProxyConfig( + host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(), + portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(), + dcIpText = preferences.getString( + KEY_DC_IP_TEXT, + ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"), + ).orEmpty(), + verbose = preferences.getBoolean(KEY_VERBOSE, false), + ) + } + + fun save(config: NormalizedProxyConfig) { + preferences.edit() + .putString(KEY_HOST, config.host) + .putInt(KEY_PORT, config.port) + .putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n")) + .putBoolean(KEY_VERBOSE, config.verbose) + .apply() + } + + companion object { + private const val PREFS_NAME = "proxy_settings" + private const val KEY_HOST = "host" + private const val KEY_PORT = "port" + private const val KEY_DC_IP_TEXT = "dc_ip_text" + private const val KEY_VERBOSE = "verbose" + } +} diff --git a/android/app/src/main/res/drawable/ic_proxy_app.xml b/android/app/src/main/res/drawable/ic_proxy_app.xml new file mode 100644 index 0000000..3608bba --- /dev/null +++ b/android/app/src/main/res/drawable/ic_proxy_app.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..04e35a2 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4317b48 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #1E88E5 + #0B1F33 + #F4F8FC + #FFFFFF + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d124aa2 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + TG WS Proxy + Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit. + Foreground service + 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. + Proxy host + Proxy port + DC to IP mappings (one DC:IP per line) + Verbose logging + Save Settings + Start Service + Stop Service + Settings saved + Foreground service start requested + TG WS Proxy + Proxy service + Keeps the Telegram proxy service alive in the foreground. + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..48198c7 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh new file mode 100644 index 0000000..139e58c --- /dev/null +++ b/android/build-local-debug.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -z "${GRADLE_USER_HOME:-}" ]]; then + if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then + export GRADLE_USER_HOME="$HOME/.gradle" + else + export GRADLE_USER_HOME="$ROOT_DIR/.gradle-local" + fi +fi + +mkdir -p "$GRADLE_USER_HOME" + +if [[ -d "$HOME/.local/jdk" ]]; then + export JAVA_HOME="$HOME/.local/jdk" +fi + +if [[ -d "$HOME/android-sdk" ]]; then + export ANDROID_SDK_ROOT="$HOME/android-sdk" +fi + +if [[ -n "${JAVA_HOME:-}" ]]; then + export PATH="$JAVA_HOME/bin:$PATH" +fi + +if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH" +fi + +if [[ -d "$HOME/.local/gradle/gradle-8.7/bin" ]]; then + export PATH="$HOME/.local/gradle/gradle-8.7/bin:$PATH" +fi + +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy + +GRADLE_BIN="gradle" +if [[ -x "$ROOT_DIR/gradlew" ]]; then + GRADLE_BIN="$ROOT_DIR/gradlew" +fi + +ATTEMPTS="${ATTEMPTS:-5}" +SLEEP_SECONDS="${SLEEP_SECONDS:-15}" +TASK="${1:-assembleDebug}" + +for attempt in $(seq 1 "$ATTEMPTS"); do + echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" + if "$GRADLE_BIN" --no-daemon --console=plain "$TASK"; then + exit 0 + fi + + if [[ "$attempt" -lt "$ATTEMPTS" ]]; then + echo "Build failed, retrying in ${SLEEP_SECONDS}s..." + sleep "$SLEEP_SECONDS" + fi +done + +echo "Android build failed after $ATTEMPTS attempts." +exit 1 diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..017d909 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..84e8962 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +systemProp.org.gradle.internal.http.connectionTimeout=120000 +systemProp.org.gradle.internal.http.socketTimeout=120000 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c z&{(nX_ht=QbJ%DzAya}!&uNu!4V0xI)QE$SY__m)SAKcN0P(&JcoK*Lxr@P zY&P=}&B3*UWNlc|&$Oh{BEqwK2+N2U$4WB7Fd|aIal`FGANUa9E-O)!gV`((ZGCc$ zBJA|FFrlg~9OBp#f7aHodCe{6= zay$6vN~zj1ddMZ9gQ4p32(7wD?(dE>KA2;SOzXRmPBiBc6g`eOsy+pVcHu=;Yd8@{ zSGgXf@%sKKQz~;!J;|2fC@emm#^_rnO0esEn^QxXgJYd`#FPWOUU5b;9eMAF zZhfiZb|gk8aJIw*YLp4!*(=3l8Cp{(%p?ho22*vN9+5NLV0TTazNY$B5L6UKUrd$n zjbX%#m7&F#U?QNOBXkiiWB*_tk+H?N3`vg;1F-I+83{M2!8<^nydGr5XX}tC!10&e z7D36bLaB56WrjL&HiiMVtpff|K%|*{t*ltt^5ood{FOG0<>k&1h95qPio)2`eL${YAGIx(b4VN*~nKn6E~SIQUuRH zQ+5zP6jfnP$S0iJ@~t!Ai3o`X7biohli;E zT#yXyl{bojG@-TGZzpdVDXhbmF%F9+-^YSIv|MT1l3j zrxOFq>gd2%U}?6}8mIj?M zc077Zc9fq(-)4+gXv?Az26IO6eV`RAJz8e3)SC7~>%rlzDwySVx*q$ygTR5kW2ds- z!HBgcq0KON9*8Ff$X0wOq$`T7ml(@TF)VeoF}x1OttjuVHn3~sHrMB++}f7f9H%@f z=|kP_?#+fve@{0MlbkC9tyvQ_R?lRdRJ@$qcB(8*jyMyeME5ns6ypVI1Xm*Zr{DuS zZ!1)rQfa89c~;l~VkCiHI|PCBd`S*2RLNQM8!g9L6?n`^evQNEwfO@&JJRme+uopQX0%Jo zgd5G&#&{nX{o?TQwQvF1<^Cg3?2co;_06=~Hcb6~4XWpNFL!WU{+CK;>gH%|BLOh7@!hsa(>pNDAmpcuVO-?;Bic17R}^|6@8DahH)G z!EmhsfunLL|3b=M0MeK2vqZ|OqUqS8npxwge$w-4pFVXFq$_EKrZY?BuP@Az@(k`L z`ViQBSk`y+YwRT;&W| z2e3UfkCo^uTA4}Qmmtqs+nk#gNr2W4 zTH%hhErhB)pkXR{B!q5P3-OM+M;qu~f>}IjtF%>w{~K-0*jPVLl?Chz&zIdxp}bjx zStp&Iufr58FTQ36AHU)0+CmvaOpKF;W@sMTFpJ`j;3d)J_$tNQI^c<^1o<49Z(~K> z;EZTBaVT%14(bFw2ob@?JLQ2@(1pCdg3S%E4*dJ}dA*v}_a4_P(a`cHnBFJxNobAv zf&Zl-Yt*lhn-wjZsq<9v-IsXxAxMZ58C@e0!rzhJ+D@9^3~?~yllY^s$?&oNwyH!#~6x4gUrfxplCvK#!f z$viuszW>MFEcFL?>ux*((!L$;R?xc*myjRIjgnQX79@UPD$6Dz0jutM@7h_pq z0Zr)#O<^y_K6jfY^X%A-ip>P%3saX{!v;fxT-*0C_j4=UMH+Xth(XVkVGiiKE#f)q z%Jp=JT)uy{&}Iq2E*xr4YsJ5>w^=#-mRZ4vPXpI6q~1aFwi+lQcimO45V-JXP;>(Q zo={U`{=_JF`EQj87Wf}{Qy35s8r1*9Mxg({CvOt}?Vh9d&(}iI-quvs-rm~P;eRA@ zG5?1HO}puruc@S{YNAF3vmUc2B4!k*yi))<5BQmvd3tr}cIs#9)*AX>t`=~{f#Uz0 z0&Nk!7sSZwJe}=)-R^$0{yeS!V`Dh7w{w5rZ9ir!Z7Cd7dwZcK;BT#V0bzTt>;@Cl z#|#A!-IL6CZ@eHH!CG>OO8!%G8&8t4)Ro@}USB*k>oEUo0LsljsJ-%5Mo^MJF2I8- z#v7a5VdJ-Cd%(a+y6QwTmi+?f8Nxtm{g-+WGL>t;s#epv7ug>inqimZCVm!uT5Pf6 ziEgQt7^%xJf#!aPWbuC_3Nxfb&CFbQy!(8ANpkWLI4oSnH?Q3f?0k1t$3d+lkQs{~(>06l&v|MpcFsyAv zin6N!-;pggosR*vV=DO(#+}4ps|5$`udE%Kdmp?G7B#y%H`R|i8skKOd9Xzx8xgR$>Zo2R2Ytktq^w#ul4uicxW#{ zFjG_RNlBroV_n;a7U(KIpcp*{M~e~@>Q#Av90Jc5v%0c>egEdY4v3%|K1XvB{O_8G zkTWLC>OZKf;XguMH2-Pw{BKbFzaY;4v2seZV0>^7Q~d4O=AwaPhP3h|!hw5aqOtT@ z!SNz}$of**Bl3TK209@F=Tn1+mgZa8yh(Png%Zd6Mt}^NSjy)etQrF zme*llAW=N_8R*O~d2!apJnF%(JcN??=`$qs3Y+~xs>L9x`0^NIn!8mMRFA_tg`etw z3k{9JAjnl@ygIiJcNHTy02GMAvBVqEss&t2<2mnw!; zU`J)0>lWiqVqo|ex7!+@0i>B~BSU1A_0w#Ee+2pJx0BFiZ7RDHEvE*ptc9md(B{&+ zKE>TM)+Pd>HEmdJao7U@S>nL(qq*A)#eLOuIfAS@j`_sK0UEY6OAJJ-kOrHG zjHx`g!9j*_jRcJ%>CE9K2MVf?BUZKFHY?EpV6ai7sET-tqk=nDFh-(65rhjtlKEY% z@G&cQ<5BKatfdA1FKuB=i>CCC5(|9TMW%K~GbA4}80I5%B}(gck#Wlq@$nO3%@QP_ z8nvPkJFa|znk>V92cA!K1rKtr)skHEJD;k8P|R8RkCq1Rh^&}Evwa4BUJz2f!2=MH zo4j8Y$YL2313}H~F7@J7mh>u%556Hw0VUOz-Un@ZASCL)y8}4XXS`t1AC*^>PLwIc zUQok5PFS=*#)Z!3JZN&eZ6ZDP^-c@StY*t20JhCnbMxXf=LK#;`4KHEqMZ-Ly9KsS zI2VUJGY&PmdbM+iT)zek)#Qc#_i4uH43 z@T5SZBrhNCiK~~esjsO9!qBpaWK<`>!-`b71Y5ReXQ4AJU~T2Njri1CEp5oKw;Lnm)-Y@Z3sEY}XIgSy%xo=uek(kAAH5MsV$V3uTUsoTzxp_rF=tx zV07vlJNKtJhCu`b}*#m&5LV4TAE&%KtHViDAdv#c^x`J7bg z&N;#I2GkF@SIGht6p-V}`!F_~lCXjl1BdTLIjD2hH$J^YFN`7f{Q?OHPFEM$65^!u zNwkelo*5+$ZT|oQ%o%;rBX$+?xhvjb)SHgNHE_yP%wYkkvXHS{Bf$OiKJ5d1gI0j< zF6N}Aq=(WDo(J{e-uOecxPD>XZ@|u-tgTR<972`q8;&ZD!cep^@B5CaqFz|oU!iFj zU0;6fQX&~15E53EW&w1s9gQQ~Zk16X%6 zjG`j0yq}4deX2?Tr(03kg>C(!7a|b9qFI?jcE^Y>-VhudI@&LI6Qa}WQ>4H_!UVyF z((cm&!3gmq@;BD#5P~0;_2qgZhtJS|>WdtjY=q zLnHH~Fm!cxw|Z?Vw8*~?I$g#9j&uvgm7vPr#&iZgPP~v~BI4jOv;*OQ?jYJtzO<^y z7-#C={r7CO810!^s(MT!@@Vz_SVU)7VBi(e1%1rvS!?PTa}Uv`J!EP3s6Y!xUgM^8 z4f!fq<3Wer_#;u!5ECZ|^c1{|q_lh3m^9|nsMR1#Qm|?4Yp5~|er2?W^7~cl;_r4WSme_o68J9p03~Hc%X#VcX!xAu%1`R!dfGJCp zV*&m47>s^%Ib0~-2f$6oSgn3jg8m%UA;ArcdcRyM5;}|r;)?a^D*lel5C`V5G=c~k zy*w_&BfySOxE!(~PI$*dwG><+-%KT5p?whOUMA*k<9*gi#T{h3DAxzAPxN&Xws8o9Cp*`PA5>d9*Z-ynV# z9yY*1WR^D8|C%I@vo+d8r^pjJ$>eo|j>XiLWvTWLl(^;JHCsoPgem6PvegHb-OTf| zvTgsHSa;BkbG=(NgPO|CZu9gUCGr$8*EoH2_Z#^BnxF0yM~t`|9ws_xZ8X8iZYqh! zAh;HXJ)3P&)Q0(&F>!LN0g#bdbis-cQxyGn9Qgh`q+~49Fqd2epikEUw9caM%V6WgP)532RMRW}8gNS%V%Hx7apSz}tn@bQy!<=lbhmAH=FsMD?leawbnP5BWM0 z5{)@EEIYMu5;u)!+HQWhQ;D3_Cm_NADNeb-f56}<{41aYq8p4=93d=-=q0Yx#knGYfXVt z+kMxlus}t2T5FEyCN~!}90O_X@@PQpuy;kuGz@bWft%diBTx?d)_xWd_-(!LmVrh**oKg!1CNF&LX4{*j|) zIvjCR0I2UUuuEXh<9}oT_zT#jOrJAHNLFT~Ilh9hGJPI1<5`C-WA{tUYlyMeoy!+U zhA#=p!u1R7DNg9u4|QfED-2TuKI}>p#2P9--z;Bbf4Op*;Q9LCbO&aL2i<0O$ByoI z!9;Ght733FC>Pz>$_mw(F`zU?`m@>gE`9_p*=7o=7av`-&ifU(^)UU`Kg3Kw`h9-1 z6`e6+im=|m2v`pN(2dE%%n8YyQz;#3Q-|x`91z?gj68cMrHl}C25|6(_dIGk*8cA3 zRHB|Nwv{@sP4W+YZM)VKI>RlB`n=Oj~Rzx~M+Khz$N$45rLn6k1nvvD^&HtsMA4`s=MmuOJID@$s8Ph4E zAmSV^+s-z8cfv~Yd(40Sh4JG#F~aB>WFoX7ykaOr3JaJ&Lb49=B8Vk-SQT9%7TYhv z?-Pprt{|=Y5ZQ1?od|A<_IJU93|l4oAfBm?3-wk{O<8ea+`}u%(kub(LFo2zFtd?4 zwpN|2mBNywv+d^y_8#<$r>*5+$wRTCygFLcrwT(qc^n&@9r+}Kd_u@Ithz(6Qb4}A zWo_HdBj#V$VE#l6pD0a=NfB0l^6W^g`vm^sta>Tly?$E&{F?TTX~DsKF~poFfmN%2 z4x`Dc{u{Lkqz&y!33;X}weD}&;7p>xiI&ZUb1H9iD25a(gI|`|;G^NwJPv=1S5e)j z;U;`?n}jnY6rA{V^ zxTd{bK)Gi^odL3l989DQlN+Zs39Xe&otGeY(b5>rlIqfc7Ap4}EC?j<{M=hlH{1+d zw|c}}yx88_xQr`{98Z!d^FNH77=u(p-L{W6RvIn40f-BldeF-YD>p6#)(Qzf)lfZj z?3wAMtPPp>vMehkT`3gToPd%|D8~4`5WK{`#+}{L{jRUMt zrFz+O$C7y8$M&E4@+p+oV5c%uYzbqd2Y%SSgYy#xh4G3hQv>V*BnuKQhBa#=oZB~w{azUB+q%bRe_R^ z>fHBilnRTUfaJ201czL8^~Ix#+qOHSO)A|xWLqOxB$dT2W~)e-r9;bm=;p;RjYahB z*1hegN(VKK+ztr~h1}YP@6cfj{e#|sS`;3tJhIJK=tVJ-*h-5y9n*&cYCSdg#EHE# zSIx=r#qOaLJoVVf6v;(okg6?*L_55atl^W(gm^yjR?$GplNP>BZsBYEf_>wM0Lc;T zhf&gpzOWNxS>m+mN92N0{;4uw`P+9^*|-1~$uXpggj4- z^SFc4`uzj2OwdEVT@}Q`(^EcQ_5(ZtXTql*yGzdS&vrS_w>~~ra|Nb5abwf}Y!uq6R5f&6g2ge~2p(%c< z@O)cz%%rr4*cRJ5f`n@lvHNk@lE1a*96Kw6lJ~B-XfJW%?&-y?;E&?1AacU@`N`!O z6}V>8^%RZ7SQnZ-z$(jsX`amu*5Fj8g!3RTRwK^`2_QHe;_2y_n|6gSaGyPmI#kA0sYV<_qOZc#-2BO%hX)f$s-Z3xlI!ub z^;3ru11DA`4heAu%}HIXo&ctujzE2!6DIGE{?Zs>2}J+p&C$rc7gJC35gxhflorvsb%sGOxpuWhF)dL_&7&Z99=5M0b~Qa;Mo!j&Ti_kXW!86N%n= zSC@6Lw>UQ__F&+&Rzv?gscwAz8IP!n63>SP)^62(HK98nGjLY2*e^OwOq`3O|C92? z;TVhZ2SK%9AGW4ZavTB9?)mUbOoF`V7S=XM;#3EUpR+^oHtdV!GK^nXzCu>tpR|89 zdD{fnvCaN^^LL%amZ^}-E+214g&^56rpdc@yv0b<3}Ys?)f|fXN4oHf$six)-@<;W&&_kj z-B}M5U*1sb4)77aR=@%I?|Wkn-QJVuA96an25;~!gq(g1@O-5VGo7y&E_srxL6ZfS z*R%$gR}dyONgju*D&?geiSj7SZ@ftyA|}(*Y4KbvU!YLsi1EDQQCnb+-cM=K1io78o!v*);o<XwjaQH%)uIP&Zm?)Nfbfn;jIr z)d#!$gOe3QHp}2NBak@yYv3m(CPKkwI|{;d=gi552u?xj9ObCU^DJFQp4t4e1tPzM zvsRIGZ6VF+{6PvqsplMZWhz10YwS={?`~O0Ec$`-!klNUYtzWA^f9m7tkEzCy<_nS z=&<(awFeZvt51>@o_~>PLs05CY)$;}Oo$VDO)?l-{CS1Co=nxjqben*O1BR>#9`0^ zkwk^k-wcLCLGh|XLjdWv0_Hg54B&OzCE^3NCP}~OajK-LuRW53CkV~Su0U>zN%yQP zH8UH#W5P3-!ToO-2k&)}nFe`t+mdqCxxAHgcifup^gKpMObbox9LFK;LP3}0dP-UW z?Zo*^nrQ6*$FtZ(>kLCc2LY*|{!dUn$^RW~m9leoF|@Jy|M5p-G~j%+P0_#orRKf8 zvuu5<*XO!B?1E}-*SY~MOa$6c%2cM+xa8}_8x*aVn~57v&W(0mqN1W`5a7*VN{SUH zXz98DDyCnX2EPl-`Lesf`=AQT%YSDb`$%;(jUTrNen$NPJrlpPDP}prI>Ml!r6bCT;mjsg@X^#&<}CGf0JtR{Ecwd&)2zuhr#nqdgHj+g2n}GK9CHuwO zk>oZxy{vcOL)$8-}L^iVfJHAGfwN$prHjYV0ju}8%jWquw>}_W6j~m<}Jf!G?~r5&Rx)!9JNX!ts#SGe2HzobV5); zpj@&`cNcO&q+%*<%D7za|?m5qlmFK$=MJ_iv{aRs+BGVrs)98BlN^nMr{V_fcl_;jkzRju+c-y?gqBC_@J0dFLq-D9@VN&-`R9U;nv$Hg?>$oe4N&Ht$V_(JR3TG^! zzJsbQbi zFE6-{#9{G{+Z}ww!ycl*7rRdmU#_&|DqPfX3CR1I{Kk;bHwF6jh0opI`UV2W{*|nn zf_Y@%wW6APb&9RrbEN=PQRBEpM(N1w`81s=(xQj6 z-eO0k9=Al|>Ej|Mw&G`%q8e$2xVz1v4DXAi8G};R$y)ww638Y=9y$ZYFDM$}vzusg zUf+~BPX>(SjA|tgaFZr_e0{)+z9i6G#lgt=F_n$d=beAt0Sa0a7>z-?vcjl3e+W}+ z1&9=|vC=$co}-Zh*%3588G?v&U7%N1Qf-wNWJ)(v`iO5KHSkC5&g7CrKu8V}uQGcfcz zmBz#Lbqwqy#Z~UzHgOQ;Q-rPxrRNvl(&u6ts4~0=KkeS;zqURz%!-ERppmd%0v>iRlEf+H$yl{_8TMJzo0 z>n)`On|7=WQdsqhXI?#V{>+~}qt-cQbokEbgwV3QvSP7&hK4R{Z{aGHVS3;+h{|Hz z6$Js}_AJr383c_+6sNR|$qu6dqHXQTc6?(XWPCVZv=)D#6_;D_8P-=zOGEN5&?~8S zl5jQ?NL$c%O)*bOohdNwGIKM#jSAC?BVY={@A#c9GmX0=T(0G}xs`-%f3r=m6-cpK z!%waekyAvm9C3%>sixdZj+I(wQlbB4wv9xKI*T13DYG^T%}zZYJ|0$Oj^YtY+d$V$ zAVudSc-)FMl|54n=N{BnZTM|!>=bhaja?o7s+v1*U$!v!qQ%`T-6fBvmdPbVmro&d zk07TOp*KuxRUSTLRrBj{mjsnF8`d}rMViY8j`jo~Hp$fkv9F_g(jUo#Arp;Xw0M$~ zRIN!B22~$kx;QYmOkos@%|5k)!QypDMVe}1M9tZfkpXKGOxvKXB!=lo`p?|R1l=tA zp(1}c6T3Fwj_CPJwVsYtgeRKg?9?}%oRq0F+r+kdB=bFUdVDRPa;E~~>2$w}>O>v=?|e>#(-Lyx?nbg=ckJ#5U6;RT zNvHhXk$P}m9wSvFyU3}=7!y?Y z=fg$PbV8d7g25&-jOcs{%}wTDKm>!Vk);&rr;O1nvO0VrU&Q?TtYVU=ir`te8SLlS zKSNmV=+vF|ATGg`4$N1uS|n??f}C_4Sz!f|4Ly8#yTW-FBfvS48Tef|-46C(wEO_%pPhUC5$-~Y?!0vFZ^Gu`x=m7X99_?C-`|h zfmMM&Y@zdfitA@KPw4Mc(YHcY1)3*1xvW9V-r4n-9ZuBpFcf{yz+SR{ zo$ZSU_|fgwF~aakGr(9Be`~A|3)B=9`$M-TWKipq-NqRDRQc}ABo*s_5kV%doIX7LRLRau_gd@Rd_aLFXGSU+U?uAqh z8qusWWcvgQ&wu{|sRXmv?sl=xc<$6AR$+cl& zFNh5q1~kffG{3lDUdvEZu5c(aAG~+64FxdlfwY^*;JSS|m~CJusvi-!$XR`6@XtY2 znDHSz7}_Bx7zGq-^5{stTRy|I@N=>*y$zz>m^}^{d&~h;0kYiq8<^Wq7Dz0w31ShO^~LUfW6rfitR0(=3;Uue`Y%y@ex#eKPOW zO~V?)M#AeHB2kovn1v=n^D?2{2jhIQd9t|_Q+c|ZFaWt+r&#yrOu-!4pXAJuxM+Cx z*H&>eZ0v8Y`t}8{TV6smOj=__gFC=eah)mZt9gwz>>W$!>b3O;Rm^Ig*POZP8Rl0f zT~o=Nu1J|lO>}xX&#P58%Yl z83`HRs5#32Qm9mdCrMlV|NKNC+Z~ z9OB8xk5HJ>gBLi+m@(pvpw)1(OaVJKs*$Ou#@Knd#bk+V@y;YXT?)4eP9E5{J%KGtYinNYJUH9PU3A}66c>Xn zZ{Bn0<;8$WCOAL$^NqTjwM?5d=RHgw3!72WRo0c;+houoUA@HWLZM;^U$&sycWrFd zE7ekt9;kb0`lps{>R(}YnXlyGY}5pPd9zBpgXeJTY_jwaJGSJQC#-KJqmh-;ad&F- z-Y)E>!&`Rz!HtCz>%yOJ|v(u7P*I$jqEY3}(Z-orn4 zlI?CYKNl`6I){#2P1h)y(6?i;^z`N3bxTV%wNvQW+eu|x=kbj~s8rhCR*0H=iGkSj zk23lr9kr|p7#qKL=UjgO`@UnvzU)`&fI>1Qs7ubq{@+lK{hH* zvl6eSb9%yngRn^T<;jG1SVa)eA>T^XX=yUS@NCKpk?ovCW1D@!=@kn;l_BrG;hOTC z6K&H{<8K#dI(A+zw-MWxS+~{g$tI7|SfP$EYKxA}LlVO^sT#Oby^grkdZ^^lA}uEF zBSj$weBJG{+Bh@Yffzsw=HyChS(dtLE3i*}Zj@~!_T-Ay7z=B)+*~3|?w`Zd)Co2t zC&4DyB!o&YgSw+fJn6`sn$e)29`kUwAc+1MND7YjV%lO;H2}fNy>hD#=gT ze+-aFNpyKIoXY~Vq-}OWPBe?Rfu^{ps8>Xy%42r@RV#*QV~P83jdlFNgkPN=T|Kt7 zV*M`Rh*30&AWlb$;ae130e@}Tqi3zx2^JQHpM>j$6x`#{mu%tZlwx9Gj@Hc92IuY* zarmT|*d0E~vt6<+r?W^UW0&#U&)8B6+1+;k^2|FWBRP9?C4Rk)HAh&=AS8FS|NQaZ z2j!iZ)nbEyg4ZTp-zHwVlfLC~tXIrv(xrP8PAtR{*c;T24ycA-;auWsya-!kF~CWZ zw_uZ|%urXgUbc@x=L=_g@QJ@m#5beS@6W195Hn7>_}z@Xt{DIEA`A&V82bc^#!q8$ zFh?z_Vn|ozJ;NPd^5uu(9tspo8t%&-U9Ckay-s@DnM*R5rtu|4)~e)`z0P-sy?)kc zs_k&J@0&0!q4~%cKL)2l;N*T&0;mqX5T{Qy60%JtKTQZ-xb%KOcgqwJmb%MOOKk7N zgq})R_6**{8A|6H?fO+2`#QU)p$Ei2&nbj6TpLSIT^D$|`TcSeh+)}VMb}LmvZ{O| ze*1IdCt3+yhdYVxcM)Q_V0bIXLgr6~%JS<<&dxIgfL=Vnx4YHuU@I34JXA|+$_S3~ zy~X#gO_X!cSs^XM{yzDGNM>?v(+sF#<0;AH^YrE8smx<36bUsHbN#y57K8WEu(`qHvQ6cAZPo=J5C(lSmUCZ57Rj6cx!e^rfaI5%w}unz}4 zoX=nt)FVNV%QDJH`o!u9olLD4O5fl)xp+#RloZlaA92o3x4->?rB4`gS$;WO{R;Z3>cG3IgFX2EA?PK^M}@%1%A;?f6}s&CV$cIyEr#q5;yHdNZ9h{| z-=dX+a5elJoDo?Eq&Og!nN6A)5yYpnGEp}?=!C-V)(*~z-+?kY1Q7qs#Rsy%hu_60rdbB+QQNr?S1 z?;xtjUv|*E3}HmuNyB9aFL5H~3Ho0UsmuMZELp1a#CA1g`P{-mT?BchuLEtK}!QZ=3AWakRu~?f9V~3F;TV`5%9Pcs_$gq&CcU}r8gOO zC2&SWPsSG{&o-LIGTBqp6SLQZPvYKp$$7L4WRRZ0BR$Kf0I0SCFkqveCp@f)o8W)! z$%7D1R`&j7W9Q9CGus_)b%+B#J2G;l*FLz#s$hw{BHS~WNLODV#(!u_2Pe&tMsq={ zdm7>_WecWF#D=?eMjLj=-_z`aHMZ=3_-&E8;ibPmM}61i6J3is*=dKf%HC>=xbj4$ zS|Q-hWQ8T5mWde6h@;mS+?k=89?1FU<%qH9B(l&O>k|u_aD|DY*@~(`_pb|B#rJ&g zR0(~(68fpUPz6TdS@4JT5MOPrqDh5_H(eX1$P2SQrkvN8sTxwV>l0)Qq z0pzTuvtEAKRDkKGhhv^jk%|HQ1DdF%5oKq5BS>szk-CIke{%js?~%@$uaN3^Uz6Wf z_iyx{bZ(;9y4X&>LPV=L=d+A}7I4GkK0c1Xts{rrW1Q7apHf-))`BgC^0^F(>At1* za@e7{lq%yAkn*NH8Q1{@{lKhRg*^TfGvv!Sn*ed*x@6>M%aaqySxR|oNadYt1mpUZ z6H(rupHYf&Z z29$5g#|0MX#aR6TZ$@eGxxABRKakDYtD%5BmKp;HbG_ZbT+=81E&=XRk6m_3t9PvD zr5Cqy(v?gHcYvYvXkNH@S#Po~q(_7MOuCAB8G$a9BC##gw^5mW16cML=T=ERL7wsk zzNEayTG?mtB=x*wc@ifBCJ|irFVMOvH)AFRW8WE~U()QT=HBCe@s$dA9O!@`zAAT) zaOZ7l6vyR+Nk_OOF!ZlZmjoImKh)dxFbbR~z(cMhfeX1l7S_`;h|v3gI}n9$sSQ>+3@AFAy9=B_y$)q;Wdl|C-X|VV3w8 z2S#>|5dGA8^9%Bu&fhmVRrTX>Z7{~3V&0UpJNEl0=N32euvDGCJ>#6dUSi&PxFW*s zS`}TB>?}H(T2lxBJ!V#2taV;q%zd6fOr=SGHpoSG*4PDaiG0pdb5`jelVipkEk%FV zThLc@Hc_AL1#D&T4D=w@UezYNJ%0=f3iVRuVL5H?eeZM}4W*bomebEU@e2d`M<~uW zf#Bugwf`VezG|^Qbt6R_=U0}|=k;mIIakz99*>FrsQR{0aQRP6ko?5<7bkDN8evZ& zB@_KqQG?ErKL=1*ZM9_5?Pq%lcS4uLSzN(Mr5=t6xHLS~Ym`UgM@D&VNu8e?_=nSFtF$u@hpPSmI4Vo_t&v?>$~K4y(O~Rb*(MFy_igM7 z*~yYUyR6yQgzWnWMUgDov!!g=lInM+=lOmOk4L`O?{i&qxy&D*_qorRbDwj6?)!ef z#JLd7F6Z2I$S0iYI={rZNk*<{HtIl^mx=h>Cim*04K4+Z4IJtd*-)%6XV2(MCscPiw_a+y*?BKbTS@BZ3AUao^%Zi#PhoY9Vib4N>SE%4>=Jco0v zH_Miey{E;FkdlZSq)e<{`+S3W=*ttvD#hB8w=|2aV*D=yOV}(&p%0LbEWH$&@$X3x~CiF-?ejQ*N+-M zc8zT@3iwkdRT2t(XS`d7`tJQAjRmKAhiw{WOqpuvFp`i@Q@!KMhwKgsA}%@sw8Xo5Y=F zhRJZg)O4uqNWj?V&&vth*H#je6T}}p_<>!Dr#89q@uSjWv~JuW(>FqoJ5^ho0%K?E z9?x_Q;kmcsQ@5=}z@tdljMSt9-Z3xn$k)kEjK|qXS>EfuDmu(Z8|(W?gY6-l z@R_#M8=vxKMAoi&PwnaIYw2COJM@atcgfr=zK1bvjW?9B`-+Voe$Q+H$j!1$Tjn+* z&LY<%)L@;zhnJlB^Og6I&BOR-m?{IW;tyYC%FZ!&Z>kGjHJ6cqM-F z&19n+e1=9AH1VrVeHrIzqlC`w9=*zfmrerF?JMzO&|Mmv;!4DKc(sp+jy^Dx?(8>1 zH&yS_4yL7m&GWX~mdfgH*AB4{CKo;+egw=PrvkTaoBU+P-4u?E|&!c z)DKc;>$$B6u*Zr1SjUh2)FeuWLWHl5TH(UHWkf zLs>7px!c5n;rbe^lO@qlYLzlDVp(z?6rPZel=YB)Uv&n!2{+Mb$-vQl=xKw( zve&>xYx+jW_NJh!FV||r?;hdP*jOXYcLCp>DOtJ?2S^)DkM{{Eb zS$!L$e_o0(^}n3tA1R3-$SNvgBq;DOEo}fNc|tB%%#g4RA3{|euq)p+xd3I8^4E&m zFrD%}nvG^HUAIKe9_{tXB;tl|G<%>yk6R;8L2)KUJw4yHJXUOPM>(-+jxq4R;z8H#>rnJy*)8N+$wA$^F zN+H*3t)eFEgxLw+Nw3};4WV$qj&_D`%ADV2%r zJCPCo%{=z7;`F98(us5JnT(G@sKTZ^;2FVitXyLe-S5(hV&Ium+1pIUB(CZ#h|g)u zSLJJ<@HgrDiA-}V_6B^x1>c9B6%~847JkQ!^KLZ2skm;q*edo;UA)~?SghG8;QbHh z_6M;ouo_1rq9=x$<`Y@EA{C%6-pEV}B(1#sDoe_e1s3^Y>n#1Sw;N|}8D|s|VPd+g z-_$QhCz`vLxxrVMx3ape1xu3*wjx=yKSlM~nFgkNWb4?DDr*!?U)L_VeffF<+!j|b zZ$Wn2$TDv3C3V@BHpSgv3JUif8%hk%OsGZ=OxH@8&4`bbf$`aAMchl^qN>Eyu3JH} z9-S!x8-s4fE=lad%Pkp8hAs~u?|uRnL48O|;*DEU! zuS0{cpk%1E0nc__2%;apFsTm0bKtd&A0~S3Cj^?72-*Owk3V!ZG*PswDfS~}2<8le z5+W^`Y(&R)yVF*tU_s!XMcJS`;(Tr`J0%>p=Z&InR%D3@KEzzI+-2)HK zuoNZ&o=wUC&+*?ofPb0a(E6(<2Amd6%uSu_^-<1?hsxs~0K5^f(LsGqgEF^+0_H=uNk9S0bb!|O8d?m5gQjUKevPaO+*VfSn^2892K~%crWM8+6 z25@V?Y@J<9w%@NXh-2!}SK_(X)O4AM1-WTg>sj1{lj5@=q&dxE^9xng1_z9w9DK>| z6Iybcd0e zyi;Ew!KBRIfGPGytQ6}z}MeXCfLY0?9%RiyagSp_D1?N&c{ zyo>VbJ4Gy`@Fv+5cKgUgs~na$>BV{*em7PU3%lloy_aEovR+J7TfQKh8BJXyL6|P8un-Jnq(ghd!_HEOh$zlv2$~y3krgeH;9zC}V3f`uDtW(%mT#944DQa~^8ZI+zAUu4U(j0YcDfKR$bK#gvn_{JZ>|gZ5+)u?T$w7Q%F^;!Wk?G z(le7r!ufT*cxS}PR6hIVtXa)i`d$-_1KkyBU>qmgz-=T};uxx&sKgv48akIWQ89F{ z0XiY?WM^~;|T8zBOr zs#zuOONzH?svv*jokd5SK8wG>+yMC)LYL|vLqm^PMHcT=`}V$=nIRHe2?h)8WQa6O zPAU}d`1y(>kZiP~Gr=mtJLMu`i<2CspL|q2DqAgAD^7*$xzM`PU4^ga`ilE134XBQ z99P(LhHU@7qvl9Yzg$M`+dlS=x^(m-_3t|h>S}E0bcFMn=C|KamQ)=w2^e)35p`zY zRV8X?d;s^>Cof2SPR&nP3E+-LCkS0J$H!eh8~k0qo$}00b=7!H_I2O+Ro@3O$nPdm ztmbOO^B+IHzQ5w>@@@J4cKw5&^_w6s!s=H%&byAbUtczPQ7}wfTqxxtQNfn*u73Qw zGuWsrky_ajPx-5`R<)6xHf>C(oqGf_Fw|-U*GfS?xLML$kv;h_pZ@Kk$y0X(S+K80 z6^|z)*`5VUkawg}=z`S;VhZhxyDfrE0$(PMurAxl~<>lfZa>JZ288ULK7D` zl9|#L^JL}Y$j*j`0-K6kH#?bRmg#5L3iB4Z)%iF@SqT+Lp|{i`m%R-|ZE94Np7Pa5 zCqC^V3}B(FR340pmF*qaa}M}+h6}mqE~7Sh!9bDv9YRT|>vBNAqv09zXHMlcuhKD| zcjjA(b*XCIwJ33?CB!+;{)vX@9xns_b-VO{i0y?}{!sdXj1GM8+$#v>W7nw;+O_9B z_{4L;C6ol?(?W0<6taGEn1^uG=?Q3i29sE`RfYCaV$3DKc_;?HsL?D_fSYg}SuO5U zOB_f4^vZ_x%o`5|C@9C5+o=mFy@au{s)sKw!UgC&L35aH(sgDxRE2De%(%OT=VUdN ziVLEmdOvJ&5*tCMKRyXctCwQu_RH%;m*$YK&m;jtbdH#Ak~13T1^f89tn`A%QEHWs~jnY~E}p_Z$XC z=?YXLCkzVSK+Id`xZYTegb@W8_baLt-Fq`Tv|=)JPbFsKRm)4UW;yT+J`<)%#ue9DPOkje)YF2fsCilK9MIIK>p*`fkoD5nGfmLwt)!KOT+> zOFq*VZktDDyM3P5UOg`~XL#cbzC}eL%qMB=Q5$d89MKuN#$6|4gx_Jt0Gfn8w&q}%lq4QU%6#jT*MRT% zrLz~C8FYKHawn-EQWN1B75O&quS+Z81(zN)G>~vN8VwC+e+y(`>HcxC{MrJ;H1Z4k zZWuv$w_F0-Ub%MVcpIc){4PGL^I7M{>;hS?;eH!;gmcOE66z3;Z1Phqo(t zVP(Hg6q#0gIKgsg7L7WE!{Y#1nI(45tx2{$34dDd#!Z0NIyrm)HOn5W#7;f4pQci# zDW!FI(g4e668kI9{2+mLwB+=#9bfqgX%!B34V-$wwSN(_cm*^{y0jQtv*4}eO^sOV z*9xoNvX)c9isB}Tgx&ZRjp3kwhTVK?r9;n!x>^XYT z@Q^7zp{rkIs{2mUSE^2!Gf6$6;j~&4=-0cSJJDizZp6LTe8b45;{AKM%v99}{{FfC zz709%u0mC=1KXTo(=TqmZQ;c?$M3z(!xah>aywrj40sc2y3rKFw4jCq+Y+u=CH@_V zxz|qeTwa>+<|H%8Dz5u>ZI5MmjTFwXS-Fv!TDd*`>3{krWoNVx$<133`(ftS?ZPyY z&4@ah^3^i`vL$BZa>O|Nt?ucewzsF)0zX3qmM^|waXr=T0pfIb0*$AwU=?Ipl|1Y; z*Pk6{C-p4MY;j@IJ|DW>QHZQJcp;Z~?8(Q+Kk3^0qJ}SCk^*n4W zu9ZFwLHUx-$6xvaQ)SUQcYd6fF8&x)V`1bIuX@>{mE$b|Yd(qomn3;bPwnDUc0F=; zh*6_((%bqAYQWQ~odER?h>1mkL4kpb3s7`0m@rDKGU*oyF)$j~Ffd4fXV$?`f~rHf zB%Y)@5SXZvfwm10RY5X?TEo)PK_`L6qgBp=#>fO49$D zDq8Ozj0q6213tV5Qq=;fZ0$|KroY{Dz=l@lU^J)?Ko@ti20TRplXzphBi>XGx4bou zEWrkNjz0t5j!_ke{g5I#PUlEU$Km8g8TE|XK=MkU@PT4T><2OVamoK;wJ}3X0L$vX zgd7gNa359*nc)R-0!`2X@FOTB`+oETOPc=ubp5R)VQgY+5BTZZJ2?9QwnO=dnulIUF3gFn;BODC2)65)HeVd%t86sL7Rv^Y+nbn+&l z6BAJY(ETvwI)Ts$aiE8rht4KD*qNyE{8{x6R|%akbTBzw;2+6Echkt+W+`u^XX z_z&x%n '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..94a30ff --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "tg-ws-proxy-android" +include(":app") From ec6de3afb32b4f1fbd7ca84df41569b4dc926443 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:13:35 +0300 Subject: [PATCH 07/37] feat(android): embed python runtime and boot proxy service inside foreground service --- .gitignore | 1 + android/app/build.gradle.kts | 27 ++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 47 +++++++++- .../tgwsproxy/ProxyForegroundService.kt | 65 +++++++++++-- .../flowseal/tgwsproxy/ProxyServiceState.kt | 27 ++++++ .../flowseal/tgwsproxy/PythonProxyBridge.kt | 39 ++++++++ .../src/main/python/android_proxy_bridge.py | 92 +++++++++++++++++++ android/app/src/main/res/values/strings.xml | 12 ++- android/build-local-debug.sh | 45 +++++++++ android/build.gradle.kts | 1 + android/settings.gradle.kts | 10 ++ proxy/__init__.py | 1 + proxy/app_runtime.py | 13 +++ 13 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt create mode 100644 android/app/src/main/python/android_proxy_bridge.py create mode 100644 proxy/__init__.py 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()) From fe55624e2465f61207b3302656fd92c1a574a046 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:19:50 +0300 Subject: [PATCH 08/37] feat(ci): build and publish Android debug APK in GitHub releases --- .github/workflows/build.yml | 56 ++++++++++++++++++++++++++++++++++-- android/build-local-debug.sh | 2 ++ proxy/app_runtime.py | 3 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9887746..7c7f113 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,51 @@ jobs: path: | dist/TgWsProxy.exe + build-android: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses > /dev/null + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-34" "build-tools;34.0.0" + + - name: Build Android debug APK + run: | + chmod +x gradlew build-local-debug.sh + LOCAL_CHAQUOPY_REPO="$GITHUB_WORKSPACE/android/.m2-chaquopy-ci" ./build-local-debug.sh + + - name: Rename APK + run: cp app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy-android-debug + path: android/app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + build-win7: runs-on: windows-latest steps: @@ -71,7 +116,7 @@ jobs: path: dist/TgWsProxy-win7.exe release: - needs: [build, build-win7] + needs: [build, build-win7, build-android] runs-on: ubuntu-latest steps: - name: Download main build @@ -86,6 +131,12 @@ jobs: name: TgWsProxy-win7 path: dist + - name: Download Android build + uses: actions/download-artifact@v4 + with: + name: TgWsProxy-android-debug + path: dist + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -96,7 +147,8 @@ jobs: files: | dist/TgWsProxy.exe dist/TgWsProxy-win7.exe + dist/tg-ws-proxy-android-debug.apk draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 46312b0..402fb06 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -80,6 +80,8 @@ prefetch_chaquopy_runtime() { "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-stdlib-pyc.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip" "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip" ) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index bdeaa34..89e3450 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -36,7 +36,8 @@ class ProxyAppRuntime: self.default_config = dict(default_config or DEFAULT_CONFIG) self.log = logging.getLogger(logger_name) self.on_error = on_error - self.parse_dc_ip_list = parse_dc_ip_list or tg_ws_proxy.parse_dc_ip_list + self.parse_dc_ip_list = parse_dc_ip_list or \ + tg_ws_proxy.parse_dc_ip_list self.run_proxy = run_proxy or tg_ws_proxy._run self.thread_factory = thread_factory or threading.Thread self.config: dict = {} From c5f8b40570e2285f512c1fd304ca8d158684560f Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:39:04 +0300 Subject: [PATCH 09/37] fix(android): normalize Chaquopy Java list inputs for proxy config --- .../src/main/python/android_proxy_bridge.py | 19 ++++++- tests/test_android_proxy_bridge.py | 50 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/test_android_proxy_bridge.py diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index 1ade094..144854e 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -18,7 +18,24 @@ def _remember_error(message: str) -> None: 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()] + if dc_ip_list is None: + return [] + + values: list[object] + try: + values = list(dc_ip_list) + except TypeError: + # Chaquopy may expose Kotlin's List as java.util.ArrayList, + # which isn't always directly iterable from Python. + if hasattr(dc_ip_list, "toArray"): + values = list(dc_ip_list.toArray()) + elif hasattr(dc_ip_list, "size") and hasattr(dc_ip_list, "get"): + size = int(dc_ip_list.size()) + values = [dc_ip_list.get(i) for i in range(size)] + else: + values = [dc_ip_list] + + return [str(item).strip() for item in values if str(item).strip()] def start_proxy(app_dir: str, host: str, port: int, diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py new file mode 100644 index 0000000..590ea29 --- /dev/null +++ b/tests/test_android_proxy_bridge.py @@ -0,0 +1,50 @@ +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str( + Path(__file__).resolve().parents[1] / "android" / "app" / "src" / "main" / "python" +)) + +import android_proxy_bridge # noqa: E402 + + +class FakeJavaArrayList: + def __init__(self, items): + self._items = list(items) + + def size(self): + return len(self._items) + + def get(self, index): + return self._items[index] + + +class AndroidProxyBridgeTests(unittest.TestCase): + def test_normalize_dc_ip_list_with_python_iterable(self): + result = android_proxy_bridge._normalize_dc_ip_list([ + "2:149.154.167.220", + " ", + "4:149.154.167.220 ", + ]) + + self.assertEqual(result, [ + "2:149.154.167.220", + "4:149.154.167.220", + ]) + + def test_normalize_dc_ip_list_with_java_array_list_shape(self): + result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([ + "2:149.154.167.220", + "4:149.154.167.220", + ])) + + self.assertEqual(result, [ + "2:149.154.167.220", + "4:149.154.167.220", + ]) + + +if __name__ == "__main__": + unittest.main() From db5a6cc696219938fbacdf635377a867df75a94b Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 22:18:06 +0300 Subject: [PATCH 10/37] feat(android): add Telegram proxy intent and background-limit status checks --- android/app/src/main/AndroidManifest.xml | 1 + .../flowseal/tgwsproxy/AndroidSystemStatus.kt | 62 +++++++++++++++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 51 +++++++++++++++ .../flowseal/tgwsproxy/TelegramProxyIntent.kt | 23 +++++++ .../app/src/main/res/layout/activity_main.xml | 60 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 12 ++++ 6 files changed, 209 insertions(+) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/AndroidSystemStatus.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 132b7ef..989d4c9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + = Build.VERSION_CODES.M) { + powerManager.isIgnoringBatteryOptimizations(context.packageName) + } else { + true + } + + val backgroundRestricted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activityManager.isBackgroundRestricted + } else { + false + } + + return AndroidSystemStatus( + ignoringBatteryOptimizations = ignoringBatteryOptimizations, + backgroundRestricted = backgroundRestricted, + ) + } + + fun openBatteryOptimizationSettings(context: Context) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } +} 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 fe311c0..4768752 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -1,9 +1,11 @@ package org.flowseal.tgwsproxy import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.provider.Settings import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -42,10 +44,23 @@ class MainActivity : AppCompatActivity() { binding.startButton.setOnClickListener { onStartClicked() } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } + binding.disableBatteryOptimizationButton.setOnClickListener { + AndroidSystemStatus.openBatteryOptimizationSettings(this) + } + binding.openAppSettingsButton.setOnClickListener { + AndroidSystemStatus.openAppSettings(this) + } renderConfig(settingsStore.load()) requestNotificationPermissionIfNeeded() observeServiceState() + renderSystemStatus() + } + + override fun onResume() { + super.onResume() + renderSystemStatus() } private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? { @@ -71,6 +86,13 @@ class MainActivity : AppCompatActivity() { Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() } + private fun onOpenTelegramClicked() { + val config = onSaveClicked(showMessage = false) ?: return + if (!TelegramProxyIntent.open(this, config)) { + Snackbar.make(binding.root, R.string.telegram_not_found, Snackbar.LENGTH_LONG).show() + } + } + private fun renderConfig(config: ProxyConfig) { binding.hostInput.setText(config.host) binding.portInput.setText(config.portText) @@ -153,6 +175,35 @@ class MainActivity : AppCompatActivity() { } } + private fun renderSystemStatus() { + val status = AndroidSystemStatus.read(this) + + binding.systemStatusValue.text = getString( + if (status.canKeepRunningReliably) { + R.string.system_status_ready + } else { + R.string.system_status_attention + }, + ) + + val lines = mutableListOf() + lines += if (status.ignoringBatteryOptimizations) { + getString(R.string.system_check_battery_ignored) + } else { + getString(R.string.system_check_battery_active) + } + lines += if (status.backgroundRestricted) { + getString(R.string.system_check_background_restricted) + } else { + getString(R.string.system_check_background_ok) + } + lines += getString(R.string.system_check_oem_note) + binding.systemStatusHint.text = lines.joinToString("\n") + + binding.disableBatteryOptimizationButton.isVisible = !status.ignoringBatteryOptimizations + binding.openAppSettingsButton.isVisible = status.backgroundRestricted || !status.ignoringBatteryOptimizations + } + private fun requestNotificationPermissionIfNeeded() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { return diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt new file mode 100644 index 0000000..213126e --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt @@ -0,0 +1,23 @@ +package org.flowseal.tgwsproxy + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri + +object TelegramProxyIntent { + fun open(context: Context, config: NormalizedProxyConfig): Boolean { + val uri = Uri.parse( + "tg://socks?server=${Uri.encode(config.host)}&port=${config.port}" + ) + val intent = Intent(Intent.ACTION_VIEW, uri) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + } +} diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 04e35a2..e1dad84 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -64,6 +64,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 85b1c1c..b1f6041 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,6 +9,14 @@ 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. + Android background limits + Ready + Needs attention + Battery optimization: disabled for this app. + Battery optimization: still enabled, Android may stop the proxy in background. + 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 host Proxy port DC to IP mappings (one DC:IP per line) @@ -16,8 +24,12 @@ Save Settings Start Service Stop Service + Open in Telegram + Disable Battery Optimization + Open App Settings Settings saved Foreground service start requested + Telegram app was not found for tg://socks. TG WS Proxy Proxy service Keeps the Telegram proxy service alive in the foreground. From 8d43fa25fa300937ce0d5c46e8b0712bb05c681b Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 22:58:43 +0300 Subject: [PATCH 11/37] feat(android): add richer service notification --- .../tgwsproxy/ProxyForegroundService.kt | 112 +++++++++++++++++- android/app/src/main/res/values/strings.xml | 5 + 2 files changed, 111 insertions(+), 6 deletions(-) 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 8d060bc..cba1138 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -3,11 +3,13 @@ package org.flowseal.tgwsproxy import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder +import androidx.core.app.TaskStackBuilder import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,7 +49,14 @@ class ProxyForegroundService : Service() { startForeground( NOTIFICATION_ID, buildNotification( - getString(R.string.notification_starting, config.host, config.port), + buildNotificationPayload( + config = config, + statusText = getString( + R.string.notification_starting, + config.host, + config.port, + ), + ), ), ) serviceScope.launch { @@ -68,11 +77,21 @@ class ProxyForegroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null - private fun buildNotification(contentText: String): Notification { + private fun buildNotification(payload: NotificationPayload): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.notification_title)) - .setContentText(contentText) + .setContentText(payload.statusText) + .setSubText(payload.endpointText) + .setStyle( + NotificationCompat.BigTextStyle().bigText(payload.detailsText), + ) .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(createOpenAppPendingIntent()) + .addAction( + 0, + getString(R.string.notification_action_stop), + createStopPendingIntent(), + ) .setOngoing(true) .setOnlyAlertOnce(true) .build() @@ -85,7 +104,16 @@ class ProxyForegroundService : Service() { result.onSuccess { ProxyServiceState.markStarted(config) - updateNotification(getString(R.string.notification_running, config.host, config.port)) + updateNotification( + buildNotificationPayload( + config = config, + statusText = getString( + R.string.notification_running, + config.host, + config.port, + ), + ), + ) }.onFailure { error -> ProxyServiceState.markFailed( error.message ?: getString(R.string.proxy_start_failed_generic), @@ -107,9 +135,75 @@ class ProxyForegroundService : Service() { } } - private fun updateNotification(contentText: String) { + private fun updateNotification(payload: NotificationPayload) { val manager = getSystemService(NotificationManager::class.java) - manager.notify(NOTIFICATION_ID, buildNotification(contentText)) + manager.notify(NOTIFICATION_ID, buildNotification(payload)) + } + + private fun buildNotificationPayload( + config: NormalizedProxyConfig, + statusText: String, + ): NotificationPayload { + val endpointText = getString(R.string.notification_endpoint, config.host, config.port) + val detailsText = getString( + R.string.notification_details, + config.host, + config.port, + config.dcIpList.size, + if (config.verbose) { + getString(R.string.notification_verbose_on) + } else { + getString(R.string.notification_verbose_off) + }, + ) + return NotificationPayload( + statusText = statusText, + endpointText = endpointText, + detailsText = detailsText, + ) + } + + private fun createOpenAppPendingIntent(): PendingIntent { + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + ?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + ?: Intent(this, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + + return TaskStackBuilder.create(this) + .addNextIntentWithParentStack(launchIntent) + .getPendingIntent( + 1, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ?: PendingIntent.getActivity( + this, + 1, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createStopPendingIntent(): PendingIntent { + val intent = Intent(this, ProxyForegroundService::class.java).apply { + action = ACTION_STOP + } + return PendingIntent.getService( + this, + 2, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) } private fun createNotificationChannel() { @@ -149,3 +243,9 @@ class ProxyForegroundService : Service() { } } } + +private data class NotificationPayload( + val statusText: String, + val endpointText: String, + val detailsText: String, +) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b1f6041..a3d52c8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -35,6 +35,11 @@ Keeps the Telegram proxy service alive in the foreground. SOCKS5 %1$s:%2$d • starting embedded Python SOCKS5 %1$s:%2$d • proxy active + %1$s:%2$d + SOCKS5 endpoint: %1$s:%2$d\nDC mappings: %3$d\nVerbose logging: %4$s\nTap to open the app, or stop the service from this notification. + enabled + disabled + Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. From da15296f66a92d934449f99a8e570113dc50b957 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:15:12 +0300 Subject: [PATCH 12/37] feat(android): show proxy traffic stats in foreground notification --- .../tgwsproxy/ProxyForegroundService.kt | 110 ++++++++++++++++-- .../flowseal/tgwsproxy/PythonProxyBridge.kt | 21 ++++ .../src/main/python/android_proxy_bridge.py | 12 ++ android/app/src/main/res/values/strings.xml | 4 +- proxy/tg_ws_proxy.py | 15 +++ tests/test_android_proxy_bridge.py | 19 +++ 6 files changed, 171 insertions(+), 10 deletions(-) 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 cba1138..8e5bb25 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -13,13 +13,19 @@ import androidx.core.app.TaskStackBuilder import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.Locale class ProxyForegroundService : Service() { private lateinit var settingsStore: ProxySettingsStore private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var trafficJob: Job? = null + private var lastTrafficSample: TrafficSample? = null override fun onCreate() { super.onCreate() @@ -69,6 +75,7 @@ class ProxyForegroundService : Service() { } override fun onDestroy() { + stopTrafficUpdates() serviceScope.cancel() runCatching { PythonProxyBridge.stop(this) } ProxyServiceState.markStopped() @@ -104,6 +111,7 @@ class ProxyForegroundService : Service() { result.onSuccess { ProxyServiceState.markStarted(config) + lastTrafficSample = null updateNotification( buildNotificationPayload( config = config, @@ -114,16 +122,19 @@ class ProxyForegroundService : Service() { ), ), ) + startTrafficUpdates(config) }.onFailure { error -> ProxyServiceState.markFailed( error.message ?: getString(R.string.proxy_start_failed_generic), ) + stopTrafficUpdates() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } } private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { + stopTrafficUpdates() runCatching { PythonProxyBridge.stop(this) } ProxyServiceState.markStopped() @@ -144,17 +155,15 @@ class ProxyForegroundService : Service() { config: NormalizedProxyConfig, statusText: String, ): NotificationPayload { + val trafficState = readTrafficState() val endpointText = getString(R.string.notification_endpoint, config.host, config.port) val detailsText = getString( R.string.notification_details, - config.host, - config.port, config.dcIpList.size, - if (config.verbose) { - getString(R.string.notification_verbose_on) - } else { - getString(R.string.notification_verbose_off) - }, + formatRate(trafficState.upBytesPerSecond), + formatRate(trafficState.downBytesPerSecond), + formatBytes(trafficState.totalBytesUp), + formatBytes(trafficState.totalBytesDown), ) return NotificationPayload( statusText = statusText, @@ -163,6 +172,80 @@ class ProxyForegroundService : Service() { ) } + private fun startTrafficUpdates(config: NormalizedProxyConfig) { + stopTrafficUpdates() + trafficJob = serviceScope.launch { + while (isActive && ProxyServiceState.isRunning.value) { + updateNotification( + buildNotificationPayload( + config = config, + statusText = getString( + R.string.notification_running, + config.host, + config.port, + ), + ), + ) + delay(1000) + } + } + } + + private fun stopTrafficUpdates() { + trafficJob?.cancel() + trafficJob = null + lastTrafficSample = null + } + + private fun readTrafficState(): TrafficState { + val nowMillis = System.currentTimeMillis() + val current = PythonProxyBridge.getTrafficStats(this) + val previous = lastTrafficSample + lastTrafficSample = TrafficSample( + bytesUp = current.bytesUp, + bytesDown = current.bytesDown, + timestampMillis = nowMillis, + ) + + if (!current.running || previous == null) { + return TrafficState( + upBytesPerSecond = 0L, + downBytesPerSecond = 0L, + totalBytesUp = current.bytesUp, + totalBytesDown = current.bytesDown, + ) + } + + val elapsedMillis = (nowMillis - previous.timestampMillis).coerceAtLeast(1L) + val upDelta = (current.bytesUp - previous.bytesUp).coerceAtLeast(0L) + val downDelta = (current.bytesDown - previous.bytesDown).coerceAtLeast(0L) + return TrafficState( + upBytesPerSecond = (upDelta * 1000L) / elapsedMillis, + downBytesPerSecond = (downDelta * 1000L) / elapsedMillis, + totalBytesUp = current.bytesUp, + totalBytesDown = current.bytesDown, + ) + } + + private fun formatRate(bytesPerSecond: Long): String = formatBytes(bytesPerSecond) + + private fun formatBytes(bytes: Long): String { + val units = arrayOf("B", "KB", "MB", "GB") + var value = bytes.toDouble().coerceAtLeast(0.0) + var unitIndex = 0 + + while (value >= 1024.0 && unitIndex < units.lastIndex) { + value /= 1024.0 + unitIndex += 1 + } + + return if (unitIndex == 0) { + String.format(Locale.US, "%.0f %s", value, units[unitIndex]) + } else { + String.format(Locale.US, "%.1f %s", value, units[unitIndex]) + } + } + private fun createOpenAppPendingIntent(): PendingIntent { val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?.apply { @@ -249,3 +332,16 @@ private data class NotificationPayload( val endpointText: String, val detailsText: String, ) + +private data class TrafficSample( + val bytesUp: Long, + val bytesDown: Long, + val timestampMillis: Long, +) + +private data class TrafficState( + val upBytesPerSecond: Long, + val downBytesPerSecond: Long, + val totalBytesUp: Long, + val totalBytesDown: Long, +) 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 b5b9f52..95c95fd 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -4,6 +4,7 @@ import android.content.Context import com.chaquo.python.Python import com.chaquo.python.android.AndroidPlatform import java.io.File +import org.json.JSONObject object PythonProxyBridge { private const val MODULE_NAME = "android_proxy_bridge" @@ -27,6 +28,20 @@ object PythonProxyBridge { getModule(context).callAttr("stop_proxy") } + fun getTrafficStats(context: Context): ProxyTrafficStats { + if (!Python.isStarted()) { + return ProxyTrafficStats() + } + + val payload = getModule(context).callAttr("get_runtime_stats_json").toString() + val json = JSONObject(payload) + return ProxyTrafficStats( + bytesUp = json.optLong("bytes_up", 0L), + bytesDown = json.optLong("bytes_down", 0L), + running = json.optBoolean("running", false), + ) + } + private fun getModule(context: Context) = getPython(context.applicationContext).getModule(MODULE_NAME) @@ -37,3 +52,9 @@ object PythonProxyBridge { return Python.getInstance() } } + +data class ProxyTrafficStats( + val bytesUp: Long = 0L, + val bytesDown: Long = 0L, + val running: Boolean = false, +) diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index 144854e..c1dc246 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -1,10 +1,12 @@ import os import threading import time +import json from pathlib import Path from typing import Iterable, Optional from proxy.app_runtime import ProxyAppRuntime +import proxy.tg_ws_proxy as tg_ws_proxy _RUNTIME_LOCK = threading.RLock() @@ -49,6 +51,7 @@ def start_proxy(app_dir: str, host: str, port: int, _LAST_ERROR = None os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python" + tg_ws_proxy.reset_stats() runtime = ProxyAppRuntime( Path(app_dir), @@ -107,3 +110,12 @@ def is_running() -> bool: def get_last_error() -> Optional[str]: return _LAST_ERROR + + +def get_runtime_stats_json() -> str: + with _RUNTIME_LOCK: + running = bool(_RUNTIME and _RUNTIME.is_proxy_running()) + + payload = dict(tg_ws_proxy.get_stats_snapshot()) + payload["running"] = running + 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 a3d52c8..7795a5f 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -36,9 +36,7 @@ SOCKS5 %1$s:%2$d • starting embedded Python SOCKS5 %1$s:%2$d • proxy active %1$s:%2$d - SOCKS5 endpoint: %1$s:%2$d\nDC mappings: %3$d\nVerbose logging: %4$s\nTap to open the app, or stop the service from this notification. - enabled - disabled + DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 35cc3e7..b70b483 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -492,6 +492,21 @@ class Stats: _stats = Stats() +def reset_stats() -> None: + global _stats + _stats = Stats() + + +def get_stats_snapshot() -> Dict[str, int]: + return { + "bytes_up": _stats.bytes_up, + "bytes_down": _stats.bytes_down, + "connections_total": _stats.connections_total, + "connections_ws": _stats.connections_ws, + "connections_tcp_fallback": _stats.connections_tcp_fallback, + } + + class _WsPool: def __init__(self): self._idle: Dict[Tuple[int, bool], list] = {} diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 590ea29..2d314e7 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -1,5 +1,6 @@ import sys import unittest +import json from pathlib import Path @@ -8,6 +9,7 @@ sys.path.insert(0, str( )) import android_proxy_bridge # noqa: E402 +import proxy.tg_ws_proxy as tg_ws_proxy # noqa: E402 class FakeJavaArrayList: @@ -22,6 +24,9 @@ class FakeJavaArrayList: class AndroidProxyBridgeTests(unittest.TestCase): + def tearDown(self): + tg_ws_proxy.reset_stats() + def test_normalize_dc_ip_list_with_python_iterable(self): result = android_proxy_bridge._normalize_dc_ip_list([ "2:149.154.167.220", @@ -34,6 +39,20 @@ class AndroidProxyBridgeTests(unittest.TestCase): "4:149.154.167.220", ]) + def test_get_runtime_stats_json_reports_proxy_counters(self): + tg_ws_proxy.reset_stats() + snapshot = tg_ws_proxy.get_stats_snapshot() + snapshot["bytes_up"] = 1536 + snapshot["bytes_down"] = 4096 + tg_ws_proxy._stats.bytes_up = snapshot["bytes_up"] + tg_ws_proxy._stats.bytes_down = snapshot["bytes_down"] + + result = json.loads(android_proxy_bridge.get_runtime_stats_json()) + + self.assertEqual(result["bytes_up"], 1536) + self.assertEqual(result["bytes_down"], 4096) + self.assertFalse(result["running"]) + def test_normalize_dc_ip_list_with_java_array_list_shape(self): result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([ "2:149.154.167.220", From 61713703f83c9d222e1abe50b73fea220d37dfbd Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:30:08 +0300 Subject: [PATCH 13/37] build(android): add env-based release signing config --- .gitignore | 3 ++ android/app/build.gradle.kts | 58 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/.gitignore b/.gitignore index 28c6140..d523b6d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ local.properties android/.idea/ android/build/ android/app/build/ +android/*.jks +android/*.keystore +android/*.keystore.properties # OS Thumbs.db diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 18b3f4a..7fdacff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,4 +1,6 @@ import org.gradle.api.tasks.Sync +import org.gradle.api.GradleException +import java.io.File plugins { id("com.android.application") @@ -6,6 +8,44 @@ plugins { id("org.jetbrains.kotlin.android") } +data class ReleaseSigningEnv( + val keystoreFile: File, + val storePassword: String, + val keyAlias: String, + val keyPassword: String, +) + +fun requiredEnv(name: String): String { + return System.getenv(name)?.takeIf { it.isNotBlank() } + ?: throw GradleException("Missing required environment variable: $name") +} + +fun loadReleaseSigningEnv(releaseSigningRequested: Boolean): ReleaseSigningEnv? { + val keystorePath = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } + val anySigningEnvProvided = listOf( + keystorePath, + System.getenv("ANDROID_KEYSTORE_PASSWORD"), + System.getenv("ANDROID_KEY_ALIAS"), + System.getenv("ANDROID_KEY_PASSWORD"), + ).any { !it.isNullOrBlank() } + + if (!releaseSigningRequested && !anySigningEnvProvided) { + return null + } + + val keystoreFile = File(requiredEnv("ANDROID_KEYSTORE_FILE")) + if (!keystoreFile.isFile) { + throw GradleException("ANDROID_KEYSTORE_FILE does not exist: ${keystoreFile.absolutePath}") + } + + return ReleaseSigningEnv( + keystoreFile = keystoreFile, + storePassword = requiredEnv("ANDROID_KEYSTORE_PASSWORD"), + keyAlias = requiredEnv("ANDROID_KEY_ALIAS"), + keyPassword = requiredEnv("ANDROID_KEY_PASSWORD"), + ) +} + val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python") val stagePythonSources by tasks.registering(Sync::class) { from(rootProject.projectDir.resolve("../proxy")) { @@ -13,6 +53,10 @@ val stagePythonSources by tasks.registering(Sync::class) { } into(stagedPythonSourcesDir) } +val releaseSigningRequested = gradle.startParameter.taskNames.any { + it.contains("release", ignoreCase = true) +} +val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested) android { namespace = "org.flowseal.tgwsproxy" @@ -32,6 +76,17 @@ android { } } + signingConfigs { + if (releaseSigningEnv != null) { + create("release") { + storeFile = releaseSigningEnv.keystoreFile + storePassword = releaseSigningEnv.storePassword + keyAlias = releaseSigningEnv.keyAlias + keyPassword = releaseSigningEnv.keyPassword + } + } + } + buildTypes { release { isMinifyEnabled = false @@ -39,6 +94,9 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) + if (releaseSigningEnv != null) { + signingConfig = signingConfigs.getByName("release") + } } } From c61e2e84ed25944a2fb9b1fddf2e7d4901d804f6 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 23:48:29 +0300 Subject: [PATCH 14/37] feat(ci): build and publish signed Android release APK --- .github/workflows/build.yml | 41 ++++++++++++++++++++++++++++++------- .gitignore | 2 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c7f113..225018f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,8 @@ jobs: build-android: runs-on: ubuntu-latest timeout-minutes: 30 + env: + ANDROID_APK_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk defaults: run: working-directory: android @@ -50,6 +52,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Validate Android release signing secrets + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + test -n "$ANDROID_KEYSTORE_BASE64" || { echo "Missing secret: ANDROID_KEYSTORE_BASE64"; exit 1; } + test -n "$ANDROID_KEYSTORE_PASSWORD" || { echo "Missing secret: ANDROID_KEYSTORE_PASSWORD"; exit 1; } + test -n "$ANDROID_KEY_ALIAS" || { echo "Missing secret: ANDROID_KEY_ALIAS"; exit 1; } + test -n "$ANDROID_KEY_PASSWORD" || { echo "Missing secret: ANDROID_KEY_PASSWORD"; exit 1; } + - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -71,19 +85,32 @@ jobs: - name: Install Android SDK packages run: sdkmanager "platforms;android-34" "build-tools;34.0.0" - - name: Build Android debug APK + - name: Prepare Android release keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/android-release.keystore" + test -s "$RUNNER_TEMP/android-release.keystore" + + - name: Build Android release APK + env: + LOCAL_CHAQUOPY_REPO: ${{ github.workspace }}/android/.m2-chaquopy-ci + ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/android-release.keystore + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x gradlew build-local-debug.sh - LOCAL_CHAQUOPY_REPO="$GITHUB_WORKSPACE/android/.m2-chaquopy-ci" ./build-local-debug.sh + ./build-local-debug.sh assembleRelease - name: Rename APK - run: cp app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + run: cp app/build/outputs/apk/release/app-release.apk "app/build/outputs/apk/release/$ANDROID_APK_NAME" - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: TgWsProxy-android-debug - path: android/app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + name: TgWsProxy-android-release + path: android/app/build/outputs/apk/release/${{ env.ANDROID_APK_NAME }} build-win7: runs-on: windows-latest @@ -134,7 +161,7 @@ jobs: - name: Download Android build uses: actions/download-artifact@v4 with: - name: TgWsProxy-android-debug + name: TgWsProxy-android-release path: dist - name: Create GitHub Release @@ -147,7 +174,7 @@ jobs: files: | dist/TgWsProxy.exe dist/TgWsProxy-win7.exe - dist/tg-ws-proxy-android-debug.apk + dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk draft: false prerelease: false env: diff --git a/.gitignore b/.gitignore index d523b6d..f083934 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ android/.idea/ android/build/ android/app/build/ android/*.jks -android/*.keystore +*.keystore android/*.keystore.properties # OS From bf21bbfa95f4d28a5b995fc0911eeb062a4b6a7f Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 00:05:16 +0300 Subject: [PATCH 15/37] docs(android): update README for Android fork, signed APK releases, and setup flow --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1965130..9893e52 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ # TG WS Proxy -Локальный SOCKS5-прокси для Telegram Desktop, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. +Локальный SOCKS5-прокси для Telegram Desktop и Android, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. **Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа. +Этот репозиторий развивает Android-ветку проекта и является форком оригинального TG WS Proxy: [Flowseal/tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy). + image ## Как это работает @@ -31,7 +33,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t ## 🚀 Быстрый старт ### Windows -Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. +Перейдите на [страницу релизов](https://github.com/Dark-Avery/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [GitHub Actions](https://github.com/Dark-Avery/tg-ws-proxy/actions) из открытого исходного кода. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. @@ -42,6 +44,29 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t - **Открыть логи** — открыть файл логов - **Выход** — остановить прокси и закрыть приложение +### Android +Перейдите на [страницу релизов](https://github.com/Dark-Avery/tg-ws-proxy/releases) и скачайте подписанный APK вида **`tg-ws-proxy-android-v1.2.3.apk`**. + +После установки: +- откройте приложение +- проверьте `Android background limits` +- при необходимости отключите battery optimization и снимите background restrictions +- нажмите **Start Service** +- нажмите **Open in Telegram** + + +Что уже есть в Android-версии: +- foreground service +- встроенный Python runtime +- кнопка **Open in Telegram** через `tg://socks` +- статус ограничений Android +- уведомление с кнопкой остановки и статистикой трафика + +Что важно для стабильной работы на Android: +- разрешите уведомления +- отключите battery optimization для приложения + + ## Установка из исходников ```bash @@ -60,6 +85,44 @@ python windows.py python proxy/tg_ws_proxy.py [--port PORT] [--dc-ip DC:IP ...] [-v] ``` +### Android debug APK + +Требуются JDK 17, Android SDK и Gradle. Локальная debug-сборка: + +```bash +./android/build-local-debug.sh +``` + +Результат: + +```text +android/app/build/outputs/apk/debug/app-debug.apk +``` + +### Android signed release APK + +Для локальной release-сборки нужен keystore и переменные окружения: + +```bash +export ANDROID_KEYSTORE_FILE=/path/to/tg-ws-proxy-release.keystore +export ANDROID_KEYSTORE_PASSWORD=... +export ANDROID_KEY_ALIAS=tg-ws-proxy +export ANDROID_KEY_PASSWORD=... +``` + +Сборка: + +```bash +cd android +./gradlew assembleRelease +``` + +Результат: + +```text +android/app/build/outputs/apk/release/app-release.apk +``` + **Аргументы:** | Аргумент | По умолчанию | Описание | @@ -96,6 +159,25 @@ python proxy/tg_ws_proxy.py -v - **Порт:** `1080` - **Логин/Пароль:** оставить пустыми +## Настройка Telegram Android + +### Автоматически + +В приложении нажмите **Open in Telegram** после запуска foreground service. + +### Вручную + +1. Telegram → **Настройки** → **Данные и память** → **Настройки прокси** +2. Добавить прокси: + - **Тип:** SOCKS5 + - **Сервер:** `127.0.0.1` + - **Порт:** `1080` + - **Логин/Пароль:** оставить пустыми + +Важно: +- сначала должен быть запущен foreground service +- если Telegram был уже открыт, иногда проще закрыть и открыть его заново после запуска прокси + ## Конфигурация Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`: @@ -111,10 +193,25 @@ Tray-приложение хранит данные в `%APPDATA%/TgWsProxy`: } ``` +Android хранит рабочие файлы в приватной директории приложения. Основные параметры редактируются через UI приложения. + ## Автоматическая сборка Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. +Windows-артефакты: +- `TgWsProxy.exe` +- `TgWsProxy-win7.exe` + +Android-артефакт: +- `tg-ws-proxy-android-vX.Y.Z.apk` + +Для signed Android release в GitHub Actions нужны secrets: +- `ANDROID_KEYSTORE_BASE64` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + ```bash pip install pyinstaller pyinstaller packaging/windows.spec From 6cbec903608f2da272886e0cc2f4c1bad1e1b258 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 00:40:56 +0300 Subject: [PATCH 16/37] feat(android): add restart action, log viewer, and persistent service error state --- android/app/src/main/AndroidManifest.xml | 4 + .../flowseal/tgwsproxy/LogViewerActivity.kt | 53 ++++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 23 ++-- .../tgwsproxy/ProxyForegroundService.kt | 113 +++++++++++++----- .../flowseal/tgwsproxy/PythonProxyBridge.kt | 2 + .../src/main/python/android_proxy_bridge.py | 1 + .../main/res/layout/activity_log_viewer.xml | 94 +++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 47 ++++++++ android/app/src/main/res/values/strings.xml | 13 ++ tests/test_android_proxy_bridge.py | 9 ++ 10 files changed, 319 insertions(+), 40 deletions(-) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt create mode 100644 android/app/src/main/res/layout/activity_log_viewer.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 989d4c9..4f8e290 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ android:supportsRtl="true" android:theme="@style/Theme.TgWsProxy"> + + diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt new file mode 100644 index 0000000..63af385 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/LogViewerActivity.kt @@ -0,0 +1,53 @@ +package org.flowseal.tgwsproxy + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import org.flowseal.tgwsproxy.databinding.ActivityLogViewerBinding +import java.io.File + +class LogViewerActivity : AppCompatActivity() { + private lateinit var binding: ActivityLogViewerBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityLogViewerBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.refreshLogsButton.setOnClickListener { renderLog() } + binding.closeLogsButton.setOnClickListener { finish() } + + renderLog() + } + + override fun onResume() { + super.onResume() + renderLog() + } + + private fun renderLog() { + val logFile = File(filesDir, "tg-ws-proxy/proxy.log") + binding.logPathValue.text = logFile.absolutePath + binding.logContentValue.text = readLogTail(logFile) + } + + private fun readLogTail(logFile: File, maxChars: Int = 40000): String { + if (!logFile.isFile) { + return getString(R.string.logs_empty) + } + + val text = runCatching { + logFile.readText(Charsets.UTF_8) + }.getOrElse { error -> + return getString(R.string.logs_read_failed, error.message ?: error.javaClass.simpleName) + } + + if (text.isBlank()) { + return getString(R.string.logs_empty) + } + if (text.length <= maxChars) { + return text + } + + return getString(R.string.logs_truncated_prefix) + "\n\n" + text.takeLast(maxChars) + } +} 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 4768752..2a178b3 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.provider.Settings import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -43,7 +42,9 @@ class MainActivity : AppCompatActivity() { binding.startButton.setOnClickListener { onStartClicked() } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } + binding.restartButton.setOnClickListener { onRestartClicked() } binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + binding.openLogsButton.setOnClickListener { onOpenLogsClicked() } binding.openTelegramButton.setOnClickListener { onOpenTelegramClicked() } binding.disableBatteryOptimizationButton.setOnClickListener { AndroidSystemStatus.openBatteryOptimizationSettings(this) @@ -86,6 +87,16 @@ class MainActivity : AppCompatActivity() { Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() } + private fun onRestartClicked() { + onSaveClicked(showMessage = false) ?: return + ProxyForegroundService.restart(this) + Snackbar.make(binding.root, R.string.service_restart_requested, Snackbar.LENGTH_SHORT).show() + } + + private fun onOpenLogsClicked() { + startActivity(Intent(this, LogViewerActivity::class.java)) + } + private fun onOpenTelegramClicked() { val config = onSaveClicked(showMessage = false) ?: return if (!TelegramProxyIntent.open(this, config)) { @@ -127,6 +138,7 @@ class MainActivity : AppCompatActivity() { ) binding.startButton.isEnabled = !isStarting && !isRunning binding.stopButton.isEnabled = isStarting || isRunning + binding.restartButton.isEnabled = !isStarting } } } @@ -162,13 +174,10 @@ class MainActivity : AppCompatActivity() { repeatOnLifecycle(Lifecycle.State.STARTED) { ProxyServiceState.lastError.collect { error -> if (error.isNullOrBlank()) { - if (!binding.errorText.isVisible) { - return@collect - } - binding.errorText.isVisible = false + binding.lastErrorCard.isVisible = false } else { - binding.errorText.text = error - binding.errorText.isVisible = true + binding.lastErrorValue.text = error + binding.lastErrorCard.isVisible = true } } } 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 8e5bb25..1e888ba 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -43,33 +43,24 @@ class ProxyForegroundService : Service() { 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.markStarting(config) - startForeground( - NOTIFICATION_ID, - buildNotification( - buildNotificationPayload( - config = config, - statusText = getString( - R.string.notification_starting, - config.host, - config.port, - ), - ), - ), - ) - serviceScope.launch { - startProxyRuntime(config) - } - START_STICKY + ACTION_RESTART -> { + val config = loadValidatedConfig() ?: return START_NOT_STICKY + ProxyServiceState.clearError() + beginProxyStart(config) + serviceScope.launch { + stopRuntimeOnly() + startProxyRuntime(config) } + START_STICKY + } + + else -> { + val config = loadValidatedConfig() ?: return START_NOT_STICKY + beginProxyStart(config) + serviceScope.launch { + startProxyRuntime(config) + } + START_STICKY } } } @@ -133,9 +124,36 @@ class ProxyForegroundService : Service() { } } + private fun loadValidatedConfig(): NormalizedProxyConfig? { + val config = settingsStore.load().validate().normalized + if (config == null) { + ProxyServiceState.markFailed(getString(R.string.saved_config_invalid)) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + return config + } + + private fun beginProxyStart(config: NormalizedProxyConfig) { + ProxyServiceState.markStarting(config) + startForeground( + NOTIFICATION_ID, + buildNotification( + buildNotificationPayload( + config = config, + trafficState = TrafficState(), + statusText = getString( + R.string.notification_starting, + config.host, + config.port, + ), + ), + ), + ) + } + private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { - stopTrafficUpdates() - runCatching { PythonProxyBridge.stop(this) } + stopRuntimeOnly() ProxyServiceState.markStopped() if (removeNotification) { @@ -146,6 +164,11 @@ class ProxyForegroundService : Service() { } } + private fun stopRuntimeOnly() { + stopTrafficUpdates() + runCatching { PythonProxyBridge.stop(this) } + } + private fun updateNotification(payload: NotificationPayload) { val manager = getSystemService(NotificationManager::class.java) manager.notify(NOTIFICATION_ID, buildNotification(payload)) @@ -153,9 +176,9 @@ class ProxyForegroundService : Service() { private fun buildNotificationPayload( config: NormalizedProxyConfig, + trafficState: TrafficState, statusText: String, ): NotificationPayload { - val trafficState = readTrafficState() val endpointText = getString(R.string.notification_endpoint, config.host, config.port) val detailsText = getString( R.string.notification_details, @@ -176,9 +199,19 @@ class ProxyForegroundService : Service() { stopTrafficUpdates() trafficJob = serviceScope.launch { while (isActive && ProxyServiceState.isRunning.value) { + val trafficState = readTrafficState() + if (!trafficState.running) { + ProxyServiceState.markFailed( + trafficState.lastError ?: getString(R.string.proxy_runtime_stopped_unexpectedly), + ) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + break + } updateNotification( buildNotificationPayload( config = config, + trafficState = trafficState, statusText = getString( R.string.notification_running, config.host, @@ -213,6 +246,8 @@ class ProxyForegroundService : Service() { downBytesPerSecond = 0L, totalBytesUp = current.bytesUp, totalBytesDown = current.bytesDown, + running = current.running, + lastError = current.lastError, ) } @@ -224,6 +259,8 @@ class ProxyForegroundService : Service() { downBytesPerSecond = (downDelta * 1000L) / elapsedMillis, totalBytesUp = current.bytesUp, totalBytesDown = current.bytesDown, + running = current.running, + lastError = current.lastError, ) } @@ -310,6 +347,7 @@ class ProxyForegroundService : Service() { private const val NOTIFICATION_ID = 1001 private const val ACTION_START = "org.flowseal.tgwsproxy.action.START" private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP" + private const val ACTION_RESTART = "org.flowseal.tgwsproxy.action.RESTART" fun start(context: Context) { val intent = Intent(context, ProxyForegroundService::class.java).apply { @@ -324,6 +362,13 @@ class ProxyForegroundService : Service() { } context.startService(intent) } + + fun restart(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_RESTART + } + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } } } @@ -340,8 +385,10 @@ private data class TrafficSample( ) private data class TrafficState( - val upBytesPerSecond: Long, - val downBytesPerSecond: Long, - val totalBytesUp: Long, - val totalBytesDown: Long, + val upBytesPerSecond: Long = 0L, + val downBytesPerSecond: Long = 0L, + val totalBytesUp: Long = 0L, + val totalBytesDown: Long = 0L, + val running: Boolean = false, + val lastError: String? = 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 index 95c95fd..55ff549 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -39,6 +39,7 @@ object PythonProxyBridge { bytesUp = json.optLong("bytes_up", 0L), bytesDown = json.optLong("bytes_down", 0L), running = json.optBoolean("running", false), + lastError = json.optString("last_error").ifBlank { null }, ) } @@ -57,4 +58,5 @@ data class ProxyTrafficStats( val bytesUp: Long = 0L, val bytesDown: Long = 0L, val running: Boolean = false, + val lastError: 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 c1dc246..910d1fb 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -118,4 +118,5 @@ def get_runtime_stats_json() -> str: payload = dict(tg_ws_proxy.get_stats_snapshot()) payload["running"] = running + payload["last_error"] = _LAST_ERROR return json.dumps(payload) diff --git a/android/app/src/main/res/layout/activity_log_viewer.xml b/android/app/src/main/res/layout/activity_log_viewer.xml new file mode 100644 index 0000000..5880535 --- /dev/null +++ b/android/app/src/main/res/layout/activity_log_viewer.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index e1dad84..ead3c2a 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -64,6 +64,37 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7795a5f..5a3ef8c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -24,11 +24,15 @@ Save Settings Start Service Stop Service + Restart Proxy Open in Telegram + Open Logs Disable Battery Optimization Open App Settings + Last service error Settings saved Foreground service start requested + Foreground service restart requested Telegram app was not found for tg://socks. TG WS Proxy Proxy service @@ -40,4 +44,13 @@ Stop Saved proxy settings are invalid. Failed to start embedded Python proxy. + Proxy runtime stopped unexpectedly. + Proxy Logs + Shows the latest lines from the embedded Python proxy log. + Log file + Refresh Logs + Close + The log file is empty or has not been created yet. + Failed to read log file: %1$s + Showing the last part of the log file. diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 2d314e7..d7f159b 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -26,6 +26,7 @@ class FakeJavaArrayList: class AndroidProxyBridgeTests(unittest.TestCase): def tearDown(self): tg_ws_proxy.reset_stats() + android_proxy_bridge._LAST_ERROR = None def test_normalize_dc_ip_list_with_python_iterable(self): result = android_proxy_bridge._normalize_dc_ip_list([ @@ -52,6 +53,14 @@ class AndroidProxyBridgeTests(unittest.TestCase): self.assertEqual(result["bytes_up"], 1536) self.assertEqual(result["bytes_down"], 4096) self.assertFalse(result["running"]) + self.assertIsNone(result["last_error"]) + + def test_get_runtime_stats_json_includes_last_error(self): + android_proxy_bridge._LAST_ERROR = "boom" + + result = json.loads(android_proxy_bridge.get_runtime_stats_json()) + + self.assertEqual(result["last_error"], "boom") def test_normalize_dc_ip_list_with_java_array_list_shape(self): result = android_proxy_bridge._normalize_dc_ip_list(FakeJavaArrayList([ From cf758f6d68e1b49116dc2b2f4f280417a7b49380 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 00:58:21 +0300 Subject: [PATCH 17/37] fix(android): use dedicated notification icon instead of system download glyph --- .../flowseal/tgwsproxy/ProxyForegroundService.kt | 2 +- .../src/main/res/drawable/ic_proxy_notification.xml | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/drawable/ic_proxy_notification.xml 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 1e888ba..c737c2f 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -83,7 +83,7 @@ class ProxyForegroundService : Service() { .setStyle( NotificationCompat.BigTextStyle().bigText(payload.detailsText), ) - .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setSmallIcon(R.drawable.ic_proxy_notification) .setContentIntent(createOpenAppPendingIntent()) .addAction( 0, diff --git a/android/app/src/main/res/drawable/ic_proxy_notification.xml b/android/app/src/main/res/drawable/ic_proxy_notification.xml new file mode 100644 index 0000000..cd58fb9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_proxy_notification.xml @@ -0,0 +1,13 @@ + + + + + From 09fbc5d8767c885988977e0135b64356a9bced14 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 01:12:56 +0300 Subject: [PATCH 18/37] fix(android): restore notification payload compile after rollback --- .../main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt | 1 + 1 file changed, 1 insertion(+) 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 c737c2f..96d5f50 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -106,6 +106,7 @@ class ProxyForegroundService : Service() { updateNotification( buildNotificationPayload( config = config, + trafficState = TrafficState(running = true), statusText = getString( R.string.notification_running, config.host, From 30f902e0fb310d1f13d52dddc74d5eefb750aa26 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Tue, 17 Mar 2026 04:04:33 +0300 Subject: [PATCH 19/37] docs: align README with upstream and add minimal Android sections --- README.md | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9893e52..186d0c2 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,10 @@ # TG WS Proxy -Локальный SOCKS5-прокси для Telegram Desktop и Android, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. +Локальный SOCKS5-прокси для Telegram на Windows и Android, который перенаправляет трафик через WebSocket-соединения к указанным серверам, помогая частично ускорить работу Telegram. **Ожидаемый результат аналогичен прокидыванию hosts для Web Telegram**: ускорение загрузки и скачивания файлов, загрузки сообщений и части медиа. -Этот репозиторий развивает Android-ветку проекта и является форком оригинального TG WS Proxy: [Flowseal/tg-ws-proxy](https://github.com/Flowseal/tg-ws-proxy). - image ## Как это работает @@ -33,7 +31,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t ## 🚀 Быстрый старт ### Windows -Перейдите на [страницу релизов](https://github.com/Dark-Avery/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [GitHub Actions](https://github.com/Dark-Avery/tg-ws-proxy/actions) из открытого исходного кода. +Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. @@ -45,7 +43,7 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t - **Выход** — остановить прокси и закрыть приложение ### Android -Перейдите на [страницу релизов](https://github.com/Dark-Avery/tg-ws-proxy/releases) и скачайте подписанный APK вида **`tg-ws-proxy-android-v1.2.3.apk`**. +Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте подписанный APK вида **`tg-ws-proxy-android-v1.2.3.apk`**. После установки: - откройте приложение @@ -54,19 +52,10 @@ Telegram Desktop → SOCKS5 (127.0.0.1:1080) → TG WS Proxy → WSS (kws*.web.t - нажмите **Start Service** - нажмите **Open in Telegram** - -Что уже есть в Android-версии: -- foreground service -- встроенный Python runtime -- кнопка **Open in Telegram** через `tg://socks` -- статус ограничений Android -- уведомление с кнопкой остановки и статистикой трафика - Что важно для стабильной работы на Android: - разрешите уведомления - отключите battery optimization для приложения - ## Установка из исходников ```bash @@ -199,13 +188,6 @@ Android хранит рабочие файлы в приватной дирек Проект содержит спецификацию PyInstaller ([`windows.spec`](packaging/windows.spec)) и GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматической сборки. -Windows-артефакты: -- `TgWsProxy.exe` -- `TgWsProxy-win7.exe` - -Android-артефакт: -- `tg-ws-proxy-android-vX.Y.Z.apk` - Для signed Android release в GitHub Actions нужны secrets: - `ANDROID_KEYSTORE_BASE64` - `ANDROID_KEYSTORE_PASSWORD` From 85b111d0f3af70440ffbd06e904ab3d3b7ef8516 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Thu, 19 Mar 2026 20:34:52 +0300 Subject: [PATCH 20/37] refactor(desktop): move windows linux and macos launchers to shared runtime --- linux.py | 131 ++++++++--------------------------------------------- macos.py | 124 ++++++++------------------------------------------ windows.py | 120 ++++++++---------------------------------------- 3 files changed, 57 insertions(+), 318 deletions(-) diff --git a/linux.py b/linux.py index 9b2ff9e..60816c6 100644 --- a/linux.py +++ b/linux.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio as _asyncio import json import logging import os @@ -9,7 +8,7 @@ import sys import threading import time from pathlib import Path -from typing import Dict, Optional +from typing import Optional import customtkinter as ctk import psutil @@ -18,6 +17,7 @@ import pystray from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.app_runtime import ProxyAppRuntime APP_NAME = "TgWsProxy" APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME @@ -35,14 +35,20 @@ DEFAULT_CONFIG = { } -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") +_runtime = ProxyAppRuntime( + APP_DIR, + default_config=DEFAULT_CONFIG, + logger_name="tg-ws-tray", + on_error=lambda text: _show_error(text), +) +CONFIG_FILE = _runtime.config_file +LOG_FILE = _runtime.log_file def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: @@ -126,53 +132,19 @@ def _acquire_lock() -> bool: def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) + _runtime.ensure_dirs() def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return _runtime.load_config() def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) + _runtime.save_config(cfg) def setup_logging(verbose: bool = False): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.FileHandler(str(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", - ) - ) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S" - ) - ) - root.addHandler(ch) + _runtime.setup_logging(verbose) def _make_icon_image(size: int = 64): @@ -217,75 +189,16 @@ def _load_icon(): return _make_icon_image() -def _run_proxy_thread( - port: int, dc_opt: Dict[int, str], verbose: bool, host: str = "127.0.0.1" -): - global _async_stop - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host) - ) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "Address already in use" in str(exc): - _show_error( - "Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите." - ) - finally: - loop.close() - _async_stop = None - - def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, - name="proxy", - ) - _proxy_thread.start() + _runtime.start_proxy(_config) def stop_proxy(): - global _proxy_thread, _async_stop - if _async_stop: - loop, stop_ev = _async_stop - loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=2) - _proxy_thread = None - log.info("Proxy stopped") + _runtime.stop_proxy() def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() + _runtime.restart_proxy() def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): @@ -789,14 +702,8 @@ def _build_menu(): def run_tray(): global _tray_icon, _config - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass + _config = _runtime.prepare() + _runtime.reset_log_file() setup_logging(_config.get("verbose", False)) log.info("TG WS Proxy tray app starting") diff --git a/macos.py b/macos.py index e1806cf..f502656 100644 --- a/macos.py +++ b/macos.py @@ -9,9 +9,8 @@ import sys import threading import time import webbrowser -import asyncio as _asyncio from pathlib import Path -from typing import Dict, Optional +from typing import Optional try: import rumps @@ -29,6 +28,7 @@ except ImportError: pyperclip = None import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.app_runtime import ProxyAppRuntime APP_NAME = "TgWsProxy" APP_DIR = Path.home() / "Library" / "Application Support" / APP_NAME @@ -45,14 +45,20 @@ DEFAULT_CONFIG = { "verbose": False, } -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _app: Optional[object] = None _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") +_runtime = ProxyAppRuntime( + APP_DIR, + default_config=DEFAULT_CONFIG, + logger_name="tg-ws-tray", + on_error=lambda text: _show_error(text), +) +CONFIG_FILE = _runtime.config_file +LOG_FILE = _runtime.log_file # Single-instance lock @@ -130,48 +136,19 @@ def _acquire_lock() -> bool: # Filesystem helpers def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) + _runtime.ensure_dirs() def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return _runtime.load_config() def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) + _runtime.save_config(cfg) def setup_logging(verbose: bool = False): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.FileHandler(str(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")) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) + _runtime.setup_logging(verbose) # Menubar icon @@ -246,73 +223,16 @@ def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: # Proxy lifecycle -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - global _async_stop - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "Address already in use" in str(exc): - _show_error( - "Не удалось запустить прокси:\n" - "Порт уже используется другим приложением.\n\n" - "Закройте приложение, использующее этот порт, " - "или измените порт в настройках прокси и перезапустите.") - finally: - loop.close() - _async_stop = None - - def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() + _runtime.start_proxy(_config) def stop_proxy(): - global _proxy_thread, _async_stop - if _async_stop: - loop, stop_ev = _async_stop - loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=2) - _proxy_thread = None - log.info("Proxy stopped") + _runtime.stop_proxy() def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() + _runtime.restart_proxy() # Menu callbacks @@ -572,14 +492,8 @@ class TgWsProxyApp(_TgWsProxyAppBase): def run_menubar(): global _app, _config - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass + _config = _runtime.prepare() + _runtime.reset_log_file() setup_logging(_config.get("verbose", False)) log.info("TG WS Proxy menubar app starting") diff --git a/windows.py b/windows.py index 568fce0..422c12e 100644 --- a/windows.py +++ b/windows.py @@ -11,15 +11,15 @@ import threading import time import webbrowser import pyperclip -import asyncio as _asyncio from pathlib import Path -from typing import Dict, Optional +from typing import Optional import pystray import customtkinter as ctk from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.app_runtime import ProxyAppRuntime IS_FROZEN = bool(getattr(sys, "frozen", False)) @@ -41,14 +41,20 @@ DEFAULT_CONFIG = { } -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") +_runtime = ProxyAppRuntime( + APP_DIR, + default_config=DEFAULT_CONFIG, + logger_name="tg-ws-tray", + on_error=lambda text: _show_error(text), +) +CONFIG_FILE = _runtime.config_file +LOG_FILE = _runtime.log_file def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: @@ -125,48 +131,19 @@ def _acquire_lock() -> bool: def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) + _runtime.ensure_dirs() def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return _runtime.load_config() def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) + _runtime.save_config(cfg) def setup_logging(verbose: bool = False): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.FileHandler(str(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")) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) + _runtime.setup_logging(verbose) def _autostart_reg_name() -> str: @@ -261,69 +238,16 @@ def _load_icon(): -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - global _async_stop - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "10048" in str(exc) or "Address already in use" in str(exc): - _show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.") - finally: - loop.close() - _async_stop = None - - def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() + _runtime.start_proxy(_config) def stop_proxy(): - global _proxy_thread, _async_stop - if _async_stop: - loop, stop_ev = _async_stop - loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=2) - _proxy_thread = None - log.info("Proxy stopped") + _runtime.stop_proxy() def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() + _runtime.restart_proxy() def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): @@ -731,14 +655,8 @@ def _build_menu(): def run_tray(): global _tray_icon, _config - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass + _config = _runtime.prepare() + _runtime.reset_log_file() setup_logging(_config.get("verbose", False)) log.info("TG WS Proxy tray app starting") From faea4375569b04b74da6e9313e8891553ef51c1f Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 23 Mar 2026 02:42:08 +0300 Subject: [PATCH 21/37] feat(android): add advanced upstream tuning settings --- .../org/flowseal/tgwsproxy/MainActivity.kt | 6 +++ .../org/flowseal/tgwsproxy/ProxyConfig.kt | 50 ++++++++++++++++++ .../flowseal/tgwsproxy/ProxySettingsStore.kt | 20 ++++++++ .../flowseal/tgwsproxy/PythonProxyBridge.kt | 3 ++ .../src/main/python/android_proxy_bridge.py | 9 +++- .../app/src/main/res/layout/activity_main.xml | 42 +++++++++++++++ android/app/src/main/res/values/strings.xml | 3 ++ tests/test_android_proxy_bridge.py | 51 +++++++++++++++++++ 8 files changed, 182 insertions(+), 2 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 2a178b3..727be4f 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -108,6 +108,9 @@ class MainActivity : AppCompatActivity() { binding.hostInput.setText(config.host) binding.portInput.setText(config.portText) binding.dcIpInput.setText(config.dcIpText) + binding.logMaxMbInput.setText(config.logMaxMbText) + binding.bufferKbInput.setText(config.bufferKbText) + binding.poolSizeInput.setText(config.poolSizeText) binding.verboseSwitch.isChecked = config.verbose } @@ -116,6 +119,9 @@ class MainActivity : AppCompatActivity() { host = binding.hostInput.text?.toString().orEmpty(), portText = binding.portInput.text?.toString().orEmpty(), dcIpText = binding.dcIpInput.text?.toString().orEmpty(), + logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(), + bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(), + poolSizeText = binding.poolSizeInput.text?.toString().orEmpty(), verbose = binding.verboseSwitch.isChecked, ) } 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 a8ffcde..39cf065 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -4,6 +4,9 @@ data class ProxyConfig( val host: String = DEFAULT_HOST, val portText: String = DEFAULT_PORT.toString(), val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"), + val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB), + val bufferKbText: String = DEFAULT_BUFFER_KB.toString(), + val poolSizeText: String = DEFAULT_POOL_SIZE.toString(), val verbose: Boolean = false, ) { fun validate(): ValidationResult { @@ -37,11 +40,44 @@ data class ProxyConfig( } } + val logMaxMbValue = logMaxMbText.trim().toDoubleOrNull() + ?: return ValidationResult( + errorMessage = "Размер лог-файла должен быть числом." + ) + if (logMaxMbValue <= 0.0) { + return ValidationResult( + errorMessage = "Размер лог-файла должен быть больше нуля." + ) + } + + val bufferKbValue = bufferKbText.trim().toIntOrNull() + ?: return ValidationResult( + errorMessage = "Буфер сокета должен быть целым числом." + ) + if (bufferKbValue < 4) { + return ValidationResult( + errorMessage = "Буфер сокета должен быть не меньше 4 KB." + ) + } + + val poolSizeValue = poolSizeText.trim().toIntOrNull() + ?: return ValidationResult( + errorMessage = "Размер WS pool должен быть целым числом." + ) + if (poolSizeValue < 0) { + return ValidationResult( + errorMessage = "Размер WS pool не может быть отрицательным." + ) + } + return ValidationResult( normalized = NormalizedProxyConfig( host = hostValue, port = portValue, dcIpList = lines, + logMaxMb = logMaxMbValue, + bufferKb = bufferKbValue, + poolSize = poolSizeValue, verbose = verbose, ) ) @@ -50,11 +86,22 @@ data class ProxyConfig( companion object { const val DEFAULT_HOST = "127.0.0.1" const val DEFAULT_PORT = 1080 + const val DEFAULT_LOG_MAX_MB = 5.0 + const val DEFAULT_BUFFER_KB = 256 + const val DEFAULT_POOL_SIZE = 4 val DEFAULT_DC_IP_LINES = listOf( "2:149.154.167.220", "4:149.154.167.220", ) + fun formatDecimal(value: Double): String { + return if (value % 1.0 == 0.0) { + value.toInt().toString() + } else { + value.toString() + } + } + private fun isIpv4Address(value: String): Boolean { val octets = value.split(".") if (octets.size != 4) { @@ -80,5 +127,8 @@ data class NormalizedProxyConfig( val host: String, val port: Int, val dcIpList: List, + val logMaxMb: Double, + val bufferKb: Int, + val poolSize: Int, 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 e93249d..9a08cc9 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -13,6 +13,20 @@ class ProxySettingsStore(context: Context) { KEY_DC_IP_TEXT, ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"), ).orEmpty(), + logMaxMbText = ProxyConfig.formatDecimal( + preferences.getFloat( + KEY_LOG_MAX_MB, + ProxyConfig.DEFAULT_LOG_MAX_MB.toFloat(), + ).toDouble() + ), + bufferKbText = preferences.getInt( + KEY_BUFFER_KB, + ProxyConfig.DEFAULT_BUFFER_KB, + ).toString(), + poolSizeText = preferences.getInt( + KEY_POOL_SIZE, + ProxyConfig.DEFAULT_POOL_SIZE, + ).toString(), verbose = preferences.getBoolean(KEY_VERBOSE, false), ) } @@ -22,6 +36,9 @@ class ProxySettingsStore(context: Context) { .putString(KEY_HOST, config.host) .putInt(KEY_PORT, config.port) .putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n")) + .putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat()) + .putInt(KEY_BUFFER_KB, config.bufferKb) + .putInt(KEY_POOL_SIZE, config.poolSize) .putBoolean(KEY_VERBOSE, config.verbose) .apply() } @@ -31,6 +48,9 @@ class ProxySettingsStore(context: Context) { private const val KEY_HOST = "host" private const val KEY_PORT = "port" private const val KEY_DC_IP_TEXT = "dc_ip_text" + 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_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 55ff549..e6e4ce9 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -17,6 +17,9 @@ object PythonProxyBridge { config.host, config.port, config.dcIpList, + config.logMaxMb, + config.bufferKb, + config.poolSize, config.verbose, ).toString() } diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index 910d1fb..b83201e 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -41,7 +41,9 @@ def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]: def start_proxy(app_dir: str, host: str, port: int, - dc_ip_list: Iterable[object], verbose: bool = False) -> str: + dc_ip_list: Iterable[object], log_max_mb: float = 5.0, + buf_kb: int = 256, pool_size: int = 4, + verbose: bool = False) -> str: global _RUNTIME, _LAST_ERROR with _RUNTIME_LOCK: @@ -59,12 +61,15 @@ def start_proxy(app_dir: str, host: str, port: int, on_error=_remember_error, ) runtime.reset_log_file() - runtime.setup_logging(verbose=verbose) + runtime.setup_logging(verbose=verbose, log_max_mb=float(log_max_mb)) config = { "host": host, "port": int(port), "dc_ip": _normalize_dc_ip_list(dc_ip_list), + "log_max_mb": float(log_max_mb), + "buf_kb": int(buf_kb), + "pool_size": int(pool_size), "verbose": bool(verbose), } runtime.save_config(config) diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index ead3c2a..db5aed8 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -197,6 +197,48 @@ android:layout_marginTop="16dp" android:text="@string/verbose_label" /> + + + + + + + + + + + + + + + Proxy host Proxy port DC to IP mappings (one DC:IP per line) + Max log size before rotation (MB) + Socket buffer size (KB) + WS pool size per DC Verbose logging Save Settings Start Service diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index d7f159b..75cdb2b 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -73,6 +73,57 @@ class AndroidProxyBridgeTests(unittest.TestCase): "4:149.154.167.220", ]) + def test_start_proxy_saves_advanced_runtime_config(self): + captured = {} + + class FakeRuntime: + def __init__(self, *args, **kwargs): + captured["runtime_init"] = kwargs + self.log_file = Path("/tmp/proxy.log") + + def reset_log_file(self): + captured["reset_log_file"] = True + + def setup_logging(self, verbose=False, log_max_mb=5): + captured["verbose"] = verbose + captured["log_max_mb"] = log_max_mb + + def save_config(self, config): + captured["config"] = dict(config) + + def start_proxy(self, config): + captured["start_proxy"] = dict(config) + return True + + def is_proxy_running(self): + return True + + def stop_proxy(self): + captured["stop_proxy"] = True + + original_runtime = android_proxy_bridge.ProxyAppRuntime + try: + android_proxy_bridge.ProxyAppRuntime = FakeRuntime + log_path = android_proxy_bridge.start_proxy( + "/tmp/app", + "127.0.0.1", + 1080, + ["2:149.154.167.220"], + 7.0, + 512, + 6, + True, + ) + finally: + android_proxy_bridge.ProxyAppRuntime = original_runtime + + self.assertEqual(log_path, "/tmp/proxy.log") + self.assertEqual(captured["config"]["log_max_mb"], 7.0) + self.assertEqual(captured["config"]["buf_kb"], 512) + self.assertEqual(captured["config"]["pool_size"], 6) + self.assertEqual(captured["log_max_mb"], 7.0) + self.assertTrue(captured["verbose"]) + if __name__ == "__main__": unittest.main() From 4f658137852b5a74aa1317c82efce46b02eb3f77 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 23 Mar 2026 16:41:32 +0300 Subject: [PATCH 22/37] fix(runtime): accept advanced logging and socket tuning settings --- proxy/app_runtime.py | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 357ac3d..0433da8 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -17,6 +17,13 @@ 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, @@ -125,7 +132,11 @@ class ProxyAppRuntime: self.on_error(text) def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str], - host: str = "127.0.0.1"): + 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): loop = _asyncio.new_event_loop() _asyncio.set_event_loop(loop) stop_ev = _asyncio.Event() @@ -133,7 +144,12 @@ class ProxyAppRuntime: try: loop.run_until_complete( - self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host)) + 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)) except Exception as exc: self.log.error("Proxy thread crashed: %s", exc) if ("10048" in str(exc) or @@ -157,6 +173,15 @@ 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"]) @@ -174,7 +199,15 @@ class ProxyAppRuntime: tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) self._proxy_thread = self.thread_factory( target=self._run_proxy_thread, - args=(port, dc_opt, host), + args=( + port, + dc_opt, + host, + upstream_mode, + relay_url, + relay_token, + direct_ws_timeout_seconds, + ), daemon=True, name="proxy") self._proxy_thread.start() From 54b86cd9e2da34eb8670727f29aaedf49680fed3 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 23 Mar 2026 20:48:45 +0300 Subject: [PATCH 23/37] feat(android): add separate legacy32 APK build for armeabi-v7a --- .github/workflows/build.yml | 21 +++++++++++----- README.md | 33 ++++++++++++++++++++++--- android/app/build.gradle.kts | 26 +++++++++++++++++--- android/build-local-debug.sh | 47 ++++++++++++++++++++++++++++++------ 4 files changed, 105 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 57bdb33..52f753f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -322,7 +322,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 env: - ANDROID_APK_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk + ANDROID_APK_STANDARD_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk + ANDROID_APK_LEGACY32_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}-legacy32.apk defaults: run: working-directory: android @@ -370,7 +371,7 @@ jobs: printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/android-release.keystore" test -s "$RUNNER_TEMP/android-release.keystore" - - name: Build Android release APK + - name: Build Android release APKs env: LOCAL_CHAQUOPY_REPO: ${{ github.workspace }}/android/.m2-chaquopy-ci ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/android-release.keystore @@ -379,16 +380,23 @@ jobs: ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x gradlew build-local-debug.sh - ./build-local-debug.sh assembleRelease + ./build-local-debug.sh assembleStandardRelease + ./build-local-debug.sh assembleLegacy32Release - - name: Rename APK - run: cp app/build/outputs/apk/release/app-release.apk "app/build/outputs/apk/release/$ANDROID_APK_NAME" + - name: Rename APKs + run: | + cp app/build/outputs/apk/standard/release/app-standard-release.apk \ + "app/build/outputs/apk/standard/release/$ANDROID_APK_STANDARD_NAME" + cp app/build/outputs/apk/legacy32/release/app-legacy32-release.apk \ + "app/build/outputs/apk/legacy32/release/$ANDROID_APK_LEGACY32_NAME" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: TgWsProxy-android-release - path: android/app/build/outputs/apk/release/${{ env.ANDROID_APK_NAME }} + 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 }} release: needs: [build-windows, build-win7, build-macos, build-linux, build-android] @@ -422,6 +430,7 @@ jobs: dist/TgWsProxy_linux_amd64 dist/TgWsProxy_linux_amd64.deb dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk + dist/tg-ws-proxy-android-${{ github.event.inputs.version }}-legacy32.apk draft: false prerelease: false env: diff --git a/README.md b/README.md index fa687a1..121d240 100644 --- a/README.md +++ b/README.md @@ -142,13 +142,25 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v] Требуются JDK 17, Android SDK и Gradle. Локальная debug-сборка: ```bash -./android/build-local-debug.sh +./android/build-local-debug.sh assembleStandardDebug ``` Результат: ```text -android/app/build/outputs/apk/debug/app-debug.apk +android/app/build/outputs/apk/standard/debug/app-standard-debug.apk +``` + +Legacy32 debug-сборка: + +```bash +./android/build-local-debug.sh assembleLegacy32Debug +``` + +Результат: + +```text +android/app/build/outputs/apk/legacy32/debug/app-legacy32-debug.apk ``` ### Android signed release APK @@ -166,13 +178,15 @@ export ANDROID_KEY_PASSWORD=... ```bash cd android -./gradlew assembleRelease +./build-local-debug.sh assembleStandardRelease +./build-local-debug.sh assembleLegacy32Release ``` Результат: ```text -android/app/build/outputs/apk/release/app-release.apk +android/app/build/outputs/apk/standard/release/app-standard-release.apk +android/app/build/outputs/apk/legacy32/release/app-legacy32-release.apk ``` **Аргументы:** @@ -285,6 +299,17 @@ Tray-приложение хранит данные в: - Apple Silicon macOS 11.0+ - Linux x86_64 (требуется AppIndicator для системного трея) +Android-артефакты: + +- `tg-ws-proxy-android-vX.Y.Z.apk` +- `tg-ws-proxy-android-vX.Y.Z-legacy32.apk` + +Для signed Android release в GitHub Actions нужны secrets: + +- `ANDROID_KEYSTORE_BASE64` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` ## Лицензия [MIT License](LICENSE) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7fdacff..216b0e5 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,9 +70,22 @@ android { versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - ndk { - abiFilters += listOf("arm64-v8a", "x86_64") + flavorDimensions += "runtime" + productFlavors { + create("standard") { + dimension = "runtime" + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + create("legacy32") { + dimension = "runtime" + versionNameSuffix = "-legacy32" + ndk { + abiFilters += listOf("armeabi-v7a") + } } } @@ -115,8 +128,13 @@ android { } chaquopy { - defaultConfig { - version = "3.12" + productFlavors { + getByName("standard") { + version = "3.12" + } + getByName("legacy32") { + version = "3.11" + } } sourceSets { getByName("main") { diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 402fb06..5f26367 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -43,10 +43,26 @@ fi ATTEMPTS="${ATTEMPTS:-5}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}" -TASK="${1:-assembleDebug}" +TASK="${1:-assembleStandardDebug}" LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}" CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}" +task_uses_legacy32() { + [[ "$TASK" =~ [Ll]egacy32 ]] +} + +task_uses_standard() { + if [[ "$TASK" =~ [Ss]tandard ]]; then + return 0 + fi + + if task_uses_legacy32; then + return 1 + fi + + return 0 +} + prefetch_artifact() { local relative_path="$1" local destination="$LOCAL_CHAQUOPY_REPO/$relative_path" @@ -76,15 +92,30 @@ prefetch_chaquopy_runtime() { "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-stdlib-pyc.zip" - "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip" - "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip" ) + if task_uses_standard; then + artifacts+=( + "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-stdlib-pyc.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip" + ) + fi + + if task_uses_legacy32; then + artifacts+=( + "com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.11-armeabi-v7a.so" + "com/chaquo/python/target/3.11.10-0/target-3.11.10-0.pom" + "com/chaquo/python/target/3.11.10-0/target-3.11.10-0-armeabi-v7a.zip" + "com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib-pyc.zip" + "com/chaquo/python/target/3.11.10-0/target-3.11.10-0-stdlib.zip" + ) + fi + for artifact in "${artifacts[@]}"; do prefetch_artifact "$artifact" done From 934eb345a2a23133a96e07f80a930c2ee3518937 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Sat, 28 Mar 2026 20:53:15 +0300 Subject: [PATCH 24/37] 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" From 336602e93aac64a0e5a225678e7391502e75aa97 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Sat, 28 Mar 2026 21:13:41 +0300 Subject: [PATCH 25/37] 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() From 68a378bad91d4b45bf96fe402b0660eb1f1b1e1e Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Sat, 28 Mar 2026 21:13:41 +0300 Subject: [PATCH 26/37] 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 | 39 +++++-- .../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 | 54 ++++++++++ utils/update_check.py | 2 +- 13 files changed, 259 insertions(+), 71 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..01243c9 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 } @@ -141,7 +151,7 @@ class MainActivity : AppCompatActivity() { } private fun onOpenReleasePageClicked() { - val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Dark-Avery/tg-ws-proxy/releases/latest" + val url = currentUpdateStatus?.htmlUrl ?: "https://github.com/Flowseal/tg-ws-proxy/releases/latest" val opened = runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }.isSuccess @@ -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..c911572 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/Flowseal/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..bee30b9 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,54 @@ +import unittest + +from proxy import __version__ +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=__version__, + ) + + 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.2.1", + html_url="https://example.com/release", + current_version=__version__, + ) + + status = update_check.get_status() + self.assertFalse(status["has_update"]) + self.assertTrue(status["ahead_of_release"]) + self.assertEqual(status["latest"], "1.2.1") + + def test_apply_release_tag_marks_latest_when_versions_match(self): + update_check._apply_release_tag( + tag=f"v{__version__}", + html_url="https://example.com/release", + current_version=__version__, + ) + + status = update_check.get_status() + self.assertFalse(status["has_update"]) + self.assertFalse(status["ahead_of_release"]) + self.assertEqual(status["latest"], __version__) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/update_check.py b/utils/update_check.py index 86653fe..026dd41 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 = "Dark-Avery/tg-ws-proxy" +REPO = "Flowseal/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" From 1599b1126c34b2617f5c3faeb953d0cda548a113 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:14:42 +0300 Subject: [PATCH 27/37] feat(runtime): adapt android_migration shell to upstream mtproto core --- proxy/app_runtime.py | 33 +++++++- proxy/tg_ws_proxy.py | 24 ++++++ tests/test_app_runtime.py | 17 ++++ tests/test_crypto_mtproto.py | 93 +++++++++++++++------- tests/test_socks5_protocol.py | 145 +++++++++------------------------- tests/test_update_check.py | 7 +- 6 files changed, 179 insertions(+), 140 deletions(-) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 9cac781..5fb7ba8 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -4,6 +4,7 @@ import asyncio as _asyncio import json import logging import logging.handlers +import os import sys import threading import time @@ -14,8 +15,9 @@ import proxy.tg_ws_proxy as tg_ws_proxy DEFAULT_CONFIG = { - "port": 1080, + "port": 1443, "host": "127.0.0.1", + "secret": os.urandom(16).hex(), "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], "log_max_mb": 5, "buf_kb": 256, @@ -48,6 +50,27 @@ class ProxyAppRuntime: self._proxy_thread = None self._async_stop = None + def _build_core_config(self, active_cfg: dict, dc_opt: Dict[int, str]): + port = int(active_cfg.get("port", self.default_config["port"])) + host = str(active_cfg.get("host", self.default_config["host"])) + secret = str(active_cfg.get("secret") or "").strip() + if not secret: + secret = os.urandom(16).hex() + active_cfg["secret"] = secret + + buf_kb = int(active_cfg.get("buf_kb", self.default_config["buf_kb"])) + pool_size = int(active_cfg.get( + "pool_size", self.default_config["pool_size"])) + + return tg_ws_proxy.ProxyConfig( + port=port, + host=host, + secret=secret, + dc_redirects=dc_opt, + buffer_size=max(4, buf_kb) * 1024, + pool_size=max(0, pool_size), + ) + def ensure_dirs(self): self.app_dir.mkdir(parents=True, exist_ok=True) @@ -132,8 +155,7 @@ class ProxyAppRuntime: self._async_stop = (loop, stop_ev) try: - loop.run_until_complete( - self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host)) + loop.run_until_complete(self.run_proxy(stop_event=stop_ev)) except Exception as exc: self.log.error("Proxy thread crashed: %s", exc) if ("10048" in str(exc) or @@ -143,6 +165,8 @@ class ProxyAppRuntime: "Порт уже используется другим приложением.\n\n" "Закройте приложение, использующее этот порт, " "или измените порт в настройках прокси и перезапустите.") + else: + self._emit_error(str(exc) or exc.__class__.__name__) finally: loop.close() self._async_stop = None @@ -168,6 +192,9 @@ class ProxyAppRuntime: self._emit_error("Ошибка конфигурации:\n%s" % exc) return False + tg_ws_proxy.proxy_config = self._build_core_config(active_cfg, dc_opt) + self.save_config(active_cfg) + self.log.info("Starting proxy on %s:%d ...", host, port) tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 21ba025..f293f94 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -493,6 +493,7 @@ class Stats: self.bytes_down = 0 self.pool_hits = 0 self.pool_misses = 0 + self.last_transport_route: Optional[str] = None def summary(self) -> str: pool_total = self.pool_hits + self.pool_misses @@ -511,6 +512,27 @@ class Stats: _stats = Stats() +def reset_stats() -> None: + global _stats + _stats = Stats() + + +def get_stats_snapshot() -> Dict[str, object]: + return { + "connections_total": _stats.connections_total, + "connections_active": _stats.connections_active, + "connections_ws": _stats.connections_ws, + "connections_tcp_fallback": _stats.connections_tcp_fallback, + "connections_bad": _stats.connections_bad, + "ws_errors": _stats.ws_errors, + "bytes_up": _stats.bytes_up, + "bytes_down": _stats.bytes_down, + "pool_hits": _stats.pool_hits, + "pool_misses": _stats.pool_misses, + "last_transport_route": _stats.last_transport_route, + } + + class _WsPool: WS_POOL_MAX_AGE = 120.0 @@ -769,6 +791,7 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, return False _stats.connections_tcp_fallback += 1 + _stats.last_transport_route = "tcp_fallback" rw.write(relay_init) await rw.drain() await _bridge_tcp_reencrypt(reader, writer, rr, rw, label, @@ -965,6 +988,7 @@ async def _handle_client(reader, writer, secret: bytes): dc_fail_until.pop(dc_key, None) _stats.connections_ws += 1 + _stats.last_transport_route = "telegram_ws_direct" splitter = None try: diff --git a/tests/test_app_runtime.py b/tests/test_app_runtime.py index b6026f9..bf3dfc7 100644 --- a/tests/test_app_runtime.py +++ b/tests/test_app_runtime.py @@ -116,6 +116,23 @@ class ProxyAppRuntimeTests(unittest.TestCase): self.assertFalse(started) self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"]) + def test_run_proxy_thread_reports_generic_runtime_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + errors = [] + + async def fake_run_proxy(stop_event=None): + raise RuntimeError("proxy boom") + + runtime = ProxyAppRuntime( + Path(tmpdir), + on_error=errors.append, + run_proxy=fake_run_proxy, + ) + + runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1") + + self.assertEqual(errors, ["proxy boom"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_crypto_mtproto.py b/tests/test_crypto_mtproto.py index 4c5a18a..2bd0864 100644 --- a/tests/test_crypto_mtproto.py +++ b/tests/test_crypto_mtproto.py @@ -1,38 +1,55 @@ +import hashlib import struct import unittest +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from proxy.crypto_backend import create_aes_ctr_transform -from proxy.tg_ws_proxy import _MsgSplitter, _dc_from_init, _patch_init_dc +from proxy.tg_ws_proxy import ( + PROTO_ABRIDGED_INT, + PROTO_TAG_ABRIDGED, + _MsgSplitter, + _generate_relay_init, + _try_handshake, +) KEY = bytes(range(32)) IV = bytes(range(16)) -PROTO_TAG = 0xEFEFEFEF +SECRET = bytes.fromhex("0123456789abcdef0123456789abcdef") def _xor(left: bytes, right: bytes) -> bytes: return bytes(a ^ b for a, b in zip(left, right)) -def _keystream(size: int) -> bytes: - transform = create_aes_ctr_transform(KEY, IV) - return transform.update(b"\x00" * size) + transform.finalize() +def _keystream(size: int, key: bytes, iv: bytes) -> bytes: + transform = Cipher(algorithms.AES(key), modes.CTR(iv)).encryptor() + return transform.update(b"\x00" * size) -def _build_init_packet(dc_raw: int, proto: int = PROTO_TAG) -> bytes: +def _build_client_handshake( + dc_raw: int, + proto_tag: bytes = PROTO_TAG_ABRIDGED, + secret: bytes = SECRET, +) -> bytes: packet = bytearray(64) packet[8:40] = KEY packet[40:56] = IV - plain_tail = struct.pack(" bytes: - transform = create_aes_ctr_transform(init_packet[8:40], init_packet[40:56]) +def _encrypt_after_init(relay_init: bytes, plaintext: bytes) -> bytes: + transform = Cipher( + algorithms.AES(relay_init[8:40]), + modes.CTR(relay_init[40:56]), + ).encryptor() transform.update(b"\x00" * 64) - return transform.update(plaintext) + transform.finalize() + return transform.update(plaintext) class CryptoBackendTests(unittest.TestCase): @@ -63,42 +80,60 @@ class CryptoBackendTests(unittest.TestCase): create_aes_ctr_transform(KEY, IV, backend="missing") -class MtProtoInitTests(unittest.TestCase): - def test_dc_from_init_reads_non_media_dc(self): - init_packet = _build_init_packet(dc_raw=2) +class MtProtoHandshakeTests(unittest.TestCase): + def test_try_handshake_reads_non_media_dc(self): + handshake = _build_client_handshake(dc_raw=2) - self.assertEqual(_dc_from_init(init_packet), (2, False)) + result = _try_handshake(handshake, SECRET) - def test_dc_from_init_reads_media_dc(self): - init_packet = _build_init_packet(dc_raw=-4) + self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED)) - self.assertEqual(_dc_from_init(init_packet), (4, True)) + def test_try_handshake_reads_media_dc(self): + handshake = _build_client_handshake(dc_raw=-4) - def test_patch_init_dc_updates_signed_dc_and_preserves_tail(self): - original = _build_init_packet(dc_raw=99) + b"tail" + result = _try_handshake(handshake, SECRET) - patched = _patch_init_dc(original, -3) + self.assertEqual(result[:3], (4, True, PROTO_TAG_ABRIDGED)) - self.assertEqual(_dc_from_init(patched[:64]), (3, True)) - self.assertEqual(patched[64:], b"tail") + def test_try_handshake_rejects_wrong_secret(self): + handshake = _build_client_handshake(dc_raw=2) + + result = _try_handshake( + handshake, + bytes.fromhex("fedcba9876543210fedcba9876543210"), + ) + + self.assertIsNone(result) + + def test_generate_relay_init_encodes_proto_and_signed_dc(self): + relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -3) + decryptor = Cipher( + algorithms.AES(relay_init[8:40]), + modes.CTR(relay_init[40:56]), + ).encryptor() + + decrypted = decryptor.update(relay_init) + + self.assertEqual(decrypted[56:60], PROTO_TAG_ABRIDGED) + self.assertEqual(struct.unpack(" bytes: - end = self._offset + n - if end > len(self._payload): - partial = self._payload[self._offset:] - self._offset = len(self._payload) - raise asyncio.IncompleteReadError(partial, n) - chunk = self._payload[self._offset:end] - self._offset = end - return chunk +def _xor(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right)) -class _FakeWriter: - def __init__(self): - self.transport = _FakeTransport() - self.writes = [] - self.closed = False - self.close_calls = 0 +def _build_client_handshake(dc_raw: int, proto_tag: bytes) -> bytes: + packet = bytearray(64) + packet[8:40] = KEY + packet[40:56] = IV - def get_extra_info(self, name): - if name == "peername": - return ("127.0.0.1", 50000) - return None + dec_key = hashlib.sha256(KEY + SECRET).digest() + decryptor = Cipher(algorithms.AES(dec_key), modes.CTR(IV)).encryptor() + keystream = decryptor.update(b"\x00" * 64) - def write(self, data: bytes): - self.writes.append(data) - - async def drain(self): - return None - - def close(self): - self.closed = True - self.close_calls += 1 - - async def wait_closed(self): - return None + plain_tail = proto_tag + struct.pack(" bytes: - return bytes([0x05, cmd, 0x00, 0x01]) + socket.inet_aton(ip) + port.to_bytes(2, "big") +class MtProtoProtocolTests(unittest.TestCase): + def test_try_handshake_accepts_abridged_proto(self): + handshake = _build_client_handshake(2, PROTO_TAG_ABRIDGED) + result = _try_handshake(handshake, SECRET) -def _domain_connect_request(domain: str, port: int, cmd: int = 1) -> bytes: - encoded = domain.encode("utf-8") - return ( - bytes([0x05, cmd, 0x00, 0x03, len(encoded)]) - + encoded - + port.to_bytes(2, "big") - ) + self.assertIsNotNone(result) + self.assertEqual(result[:3], (2, False, PROTO_TAG_ABRIDGED)) + def test_try_handshake_accepts_intermediate_proto(self): + handshake = _build_client_handshake(-4, PROTO_TAG_INTERMEDIATE) -def _ipv6_connect_request(ip: str, port: int) -> bytes: - return ( - bytes([0x05, 0x01, 0x00, 0x04]) - + socket.inet_pton(socket.AF_INET6, ip) - + port.to_bytes(2, "big") - ) + result = _try_handshake(handshake, SECRET) + self.assertIsNotNone(result) + self.assertEqual(result[:3], (4, True, PROTO_TAG_INTERMEDIATE)) -class Socks5ProtocolTests(unittest.IsolatedAsyncioTestCase): - async def test_rejects_non_socks5_greeting(self): - reader = _FakeReader(b"\x04\x01") - writer = _FakeWriter() + def test_generate_relay_init_produces_handshake_sized_packet(self): + relay_init = _generate_relay_init(PROTO_TAG_ABRIDGED, -2) - await _handle_client(reader, writer) - - self.assertEqual(writer.writes, []) - self.assertTrue(writer.closed) - - async def test_rejects_unsupported_command(self): - reader = _FakeReader(b"\x05\x01\x00" + _ipv4_connect_request("1.1.1.1", 443, cmd=2)) - writer = _FakeWriter() - - await _handle_client(reader, writer) - - self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x07)]) - self.assertTrue(writer.closed) - - async def test_rejects_unsupported_address_type(self): - reader = _FakeReader(b"\x05\x01\x00" + b"\x05\x01\x00\x02") - writer = _FakeWriter() - - await _handle_client(reader, writer) - - self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x08)]) - self.assertTrue(writer.closed) - - async def test_rejects_ipv6_destinations(self): - reader = _FakeReader(b"\x05\x01\x00" + _ipv6_connect_request("2001:db8::1", 443)) - writer = _FakeWriter() - - await _handle_client(reader, writer) - - self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) - self.assertTrue(writer.closed) - - async def test_passthrough_connect_failure_returns_error(self): - reader = _FakeReader(b"\x05\x01\x00" + _domain_connect_request("example.com", 443)) - writer = _FakeWriter() - - with patch("proxy.tg_ws_proxy.asyncio.open_connection", side_effect=OSError("boom")): - await _handle_client(reader, writer) - - self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) - self.assertTrue(writer.closed) + self.assertEqual(len(relay_init), 64) + self.assertEqual(relay_init[0], relay_init[0] & 0xFF) if __name__ == "__main__": diff --git a/tests/test_update_check.py b/tests/test_update_check.py index bee30b9..fe3f23c 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -13,8 +13,11 @@ class UpdateCheckTests(unittest.TestCase): update_check._state.update(self._orig_state) def test_apply_release_tag_marks_update_available(self): + version_parts = [int(part) for part in __version__.split(".")] + version_parts[-1] += 1 + next_version = ".".join(str(part) for part in version_parts) update_check._apply_release_tag( - tag="v1.3.1", + tag=f"v{next_version}", html_url="https://example.com/release", current_version=__version__, ) @@ -22,7 +25,7 @@ class UpdateCheckTests(unittest.TestCase): 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["latest"], next_version) self.assertEqual(status["html_url"], "https://example.com/release") def test_apply_release_tag_marks_ahead_of_release(self): From 810991ea18d4b3901611a78550e4cfe761e99d88 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:14:54 +0300 Subject: [PATCH 28/37] feat(android): switch config and tg intent to mtproto model --- .../org/flowseal/tgwsproxy/MainActivity.kt | 2 ++ .../org/flowseal/tgwsproxy/ProxyConfig.kt | 21 ++++++++++++++++++- .../flowseal/tgwsproxy/ProxySettingsStore.kt | 3 +++ .../flowseal/tgwsproxy/PythonProxyBridge.kt | 1 + .../flowseal/tgwsproxy/TelegramProxyIntent.kt | 2 +- .../src/main/python/android_proxy_bridge.py | 3 ++- .../app/src/main/res/layout/activity_main.xml | 14 +++++++++++++ android/app/src/main/res/values/strings.xml | 9 ++++---- tests/test_android_proxy_bridge.py | 4 +++- 9 files changed, 51 insertions(+), 8 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 01243c9..15004ce 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -128,6 +128,7 @@ class MainActivity : AppCompatActivity() { private fun renderConfig(config: ProxyConfig) { binding.hostInput.setText(config.host) binding.portInput.setText(config.portText) + binding.secretInput.setText(config.secretText) binding.dcIpInput.setText(config.dcIpText) binding.logMaxMbInput.setText(config.logMaxMbText) binding.bufferKbInput.setText(config.bufferKbText) @@ -141,6 +142,7 @@ class MainActivity : AppCompatActivity() { return ProxyConfig( host = binding.hostInput.text?.toString().orEmpty(), portText = binding.portInput.text?.toString().orEmpty(), + secretText = binding.secretInput.text?.toString().orEmpty(), dcIpText = binding.dcIpInput.text?.toString().orEmpty(), logMaxMbText = binding.logMaxMbInput.text?.toString().orEmpty(), bufferKbText = binding.bufferKbInput.text?.toString().orEmpty(), 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 bc8075a..89628ea 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -1,8 +1,11 @@ package org.flowseal.tgwsproxy +import java.security.SecureRandom + data class ProxyConfig( val host: String = DEFAULT_HOST, val portText: String = DEFAULT_PORT.toString(), + val secretText: String = DEFAULT_SECRET, val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"), val logMaxMbText: String = formatDecimal(DEFAULT_LOG_MAX_MB), val bufferKbText: String = DEFAULT_BUFFER_KB.toString(), @@ -22,6 +25,13 @@ data class ProxyConfig( return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.") } + val secretValue = secretText.trim().lowercase() + if (secretValue.length != 32 || !secretValue.all { it in "0123456789abcdef" }) { + return ValidationResult( + errorMessage = "MTProto secret должен содержать ровно 32 hex-символа." + ) + } + val lines = dcIpText .lineSequence() .map { it.trim() } @@ -75,6 +85,7 @@ data class ProxyConfig( normalized = NormalizedProxyConfig( host = hostValue, port = portValue, + secret = secretValue, dcIpList = lines, logMaxMb = logMaxMbValue, bufferKb = bufferKbValue, @@ -87,10 +98,11 @@ data class ProxyConfig( companion object { const val DEFAULT_HOST = "127.0.0.1" - const val DEFAULT_PORT = 1080 + const val DEFAULT_PORT = 1443 const val DEFAULT_LOG_MAX_MB = 5.0 const val DEFAULT_BUFFER_KB = 256 const val DEFAULT_POOL_SIZE = 4 + val DEFAULT_SECRET = generateSecret() val DEFAULT_DC_IP_LINES = listOf( "2:149.154.167.220", "4:149.154.167.220", @@ -104,6 +116,12 @@ data class ProxyConfig( } } + private fun generateSecret(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + return bytes.joinToString(separator = "") { "%02x".format(it) } + } + private fun isIpv4Address(value: String): Boolean { val octets = value.split(".") if (octets.size != 4) { @@ -128,6 +146,7 @@ data class ValidationResult( data class NormalizedProxyConfig( val host: String, val port: Int, + val secret: String, val dcIpList: List, val logMaxMb: Double, val bufferKb: Int, 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 6d726e9..70e51ac 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -9,6 +9,7 @@ class ProxySettingsStore(context: Context) { return ProxyConfig( host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(), portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(), + secretText = preferences.getString(KEY_SECRET, ProxyConfig.DEFAULT_SECRET).orEmpty(), dcIpText = preferences.getString( KEY_DC_IP_TEXT, ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"), @@ -36,6 +37,7 @@ class ProxySettingsStore(context: Context) { preferences.edit() .putString(KEY_HOST, config.host) .putInt(KEY_PORT, config.port) + .putString(KEY_SECRET, config.secret) .putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n")) .putFloat(KEY_LOG_MAX_MB, config.logMaxMb.toFloat()) .putInt(KEY_BUFFER_KB, config.bufferKb) @@ -49,6 +51,7 @@ class ProxySettingsStore(context: Context) { private const val PREFS_NAME = "proxy_settings" private const val KEY_HOST = "host" private const val KEY_PORT = "port" + private const val KEY_SECRET = "secret" private const val KEY_DC_IP_TEXT = "dc_ip_text" private const val KEY_LOG_MAX_MB = "log_max_mb" private const val KEY_BUFFER_KB = "buf_kb" 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 1923300..1042aa5 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -17,6 +17,7 @@ object PythonProxyBridge { File(context.filesDir, "tg-ws-proxy").absolutePath, config.host, config.port, + config.secret, config.dcIpList, config.logMaxMb, config.bufferKb, diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt index 213126e..468663e 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/TelegramProxyIntent.kt @@ -8,7 +8,7 @@ import android.net.Uri object TelegramProxyIntent { fun open(context: Context, config: NormalizedProxyConfig): Boolean { val uri = Uri.parse( - "tg://socks?server=${Uri.encode(config.host)}&port=${config.port}" + "tg://proxy?server=${Uri.encode(config.host)}&port=${config.port}&secret=dd${Uri.encode(config.secret)}" ) val intent = Intent(Intent.ACTION_VIEW, uri) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py index c911572..5a5aa85 100644 --- a/android/app/src/main/python/android_proxy_bridge.py +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -44,7 +44,7 @@ def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]: return [str(item).strip() for item in values if str(item).strip()] -def start_proxy(app_dir: str, host: str, port: int, +def start_proxy(app_dir: str, host: str, port: int, secret: str, dc_ip_list: Iterable[object], log_max_mb: float = 5.0, buf_kb: int = 256, pool_size: int = 4, verbose: bool = False) -> str: @@ -70,6 +70,7 @@ def start_proxy(app_dir: str, host: str, port: int, config = { "host": host, "port": int(port), + "secret": str(secret).strip(), "dc_ip": _normalize_dc_ip_list(dc_ip_list), "log_max_mb": float(log_max_mb), "buf_kb": int(buf_kb), diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index ec2c29f..aac1a85 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -243,6 +243,20 @@ android:maxLines="1" /> + + + + + TG WS Proxy - Android app for the local Telegram SOCKS5 proxy. + Android app for the local Telegram MTProto proxy. Foreground service Starting Running @@ -33,6 +33,7 @@ Open Release Page Proxy host Proxy port + MTProto secret (32 hex characters) DC to IP mappings (one DC:IP per line) Max log size before rotation (MB) Socket buffer size (KB) @@ -51,12 +52,12 @@ Settings saved Foreground service start requested Foreground service restart requested - Telegram app was not found for tg://socks. + Telegram app was not found for tg://proxy. 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 + MTProto %1$s:%2$d • starting embedded Python + MTProto %1$s:%2$d • proxy active %1$s:%2$d DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s Stop diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index 3ddadce..ab10293 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -107,7 +107,8 @@ class AndroidProxyBridgeTests(unittest.TestCase): log_path = android_proxy_bridge.start_proxy( "/tmp/app", "127.0.0.1", - 1080, + 1443, + "0123456789abcdef0123456789abcdef", ["2:149.154.167.220"], 7.0, 512, @@ -118,6 +119,7 @@ class AndroidProxyBridgeTests(unittest.TestCase): android_proxy_bridge.ProxyAppRuntime = original_runtime self.assertEqual(log_path, "/tmp/proxy.log") + self.assertEqual(captured["config"]["secret"], "0123456789abcdef0123456789abcdef") self.assertEqual(captured["config"]["log_max_mb"], 7.0) self.assertEqual(captured["config"]["buf_kb"], 512) self.assertEqual(captured["config"]["pool_size"], 6) From 0302a3b817a8dbd5ddd4d893301d1d4cf5b9b3b2 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:15:41 +0300 Subject: [PATCH 29/37] docs(readme): align android instructions with mtproto config --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 207ba38..04028f2 100644 --- a/README.md +++ b/README.md @@ -257,10 +257,10 @@ tg-ws-proxy-tray-linux = "linux:main" 1. Telegram → **Настройки** → **Данные и память** → **Настройки прокси** 2. Добавить прокси: - - **Тип:** SOCKS5 + - **Тип:** MTProto - **Сервер:** `127.0.0.1` - - **Порт:** `1080` - - **Логин/Пароль:** оставить пустыми + - **Порт:** `1443` + - **Secret:** из настроек приложения Важно: From 7e9acc47fc00bde2bbbb69330b75ef2675ed15cd Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:17:44 +0300 Subject: [PATCH 30/37] docs(readme): keep android-only additions on android_migration --- README.md | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) diff --git a/README.md b/README.md index 04028f2..b6f33ff 100644 --- a/README.md +++ b/README.md @@ -152,44 +152,6 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v] android/app/build/outputs/apk/standard/debug/app-standard-debug.apk ``` -Legacy32 debug-сборка: - -```bash -./android/build-local-debug.sh assembleLegacy32Debug -``` - -Результат: - -```text -android/app/build/outputs/apk/legacy32/debug/app-legacy32-debug.apk -``` - -### Android signed release APK - -Для локальной release-сборки нужен keystore и переменные окружения: - -```bash -export ANDROID_KEYSTORE_FILE=/path/to/tg-ws-proxy-release.keystore -export ANDROID_KEYSTORE_PASSWORD=... -export ANDROID_KEY_ALIAS=tg-ws-proxy -export ANDROID_KEY_PASSWORD=... -``` - -Сборка: - -```bash -cd android -./build-local-debug.sh assembleStandardRelease -./build-local-debug.sh assembleLegacy32Release -``` - -Результат: - -```text -android/app/build/outputs/apk/standard/release/app-standard-release.apk -android/app/build/outputs/apk/legacy32/release/app-legacy32-release.apk -``` - **Аргументы:** | Аргумент | По умолчанию | Описание | @@ -306,18 +268,6 @@ Tray-приложение хранит данные в: - Intel macOS 10.15+ - Apple Silicon macOS 11.0+ - Linux x86_64 (требуется AppIndicator для системного трея) - -Android-артефакты: - -- `tg-ws-proxy-android-vX.Y.Z.apk` -- `tg-ws-proxy-android-vX.Y.Z-legacy32.apk` - -Для signed Android release в GitHub Actions нужны secrets: - -- `ANDROID_KEYSTORE_BASE64` -- `ANDROID_KEYSTORE_PASSWORD` -- `ANDROID_KEY_ALIAS` -- `ANDROID_KEY_PASSWORD` ## Лицензия [MIT License](LICENSE) From 7ad377c12c7b46921fcb9551ef879df8dd300bc5 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:22:38 +0300 Subject: [PATCH 31/37] fix(android-build): move WSL build outputs off Windows mounts --- android/build-local-debug.sh | 40 +++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 51e2881..d6b07b6 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_BUILD_DIR="$ROOT_DIR/app/build" if [[ -z "${GRADLE_USER_HOME:-}" ]]; then if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then @@ -47,6 +48,34 @@ TASK="${1:-assembleStandardDebug}" LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}" CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}" +running_on_wsl_windows_mount() { + [[ -n "${WSL_DISTRO_NAME:-}" && "$ROOT_DIR" == /mnt/* ]] +} + +prepare_wsl_build_dir() { + if ! running_on_wsl_windows_mount; then + return 0 + fi + + local cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/tg-ws-proxy-android-build" + local project_key + project_key="$(printf '%s' "$ROOT_DIR" | sha256sum | cut -d' ' -f1)" + local linux_build_dir="$cache_root/$project_key/app-build" + + mkdir -p "$cache_root" + mkdir -p "$linux_build_dir" + + if [[ -L "$APP_BUILD_DIR" ]]; then + return 0 + fi + + if [[ -e "$APP_BUILD_DIR" ]]; then + rm -rf "$APP_BUILD_DIR" + fi + + ln -s "$linux_build_dir" "$APP_BUILD_DIR" +} + task_uses_legacy32() { [[ "$TASK" =~ [Ll]egacy32 ]] } @@ -123,11 +152,11 @@ prefetch_chaquopy_runtime() { 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" + "$APP_BUILD_DIR/python/env" + "$APP_BUILD_DIR/intermediates/project_dex_archive" + "$APP_BUILD_DIR/intermediates/desugar_graph" + "$APP_BUILD_DIR/tmp/kotlin-classes" + "$APP_BUILD_DIR/snapshot/kotlin" ) for stale_dir in "${stale_dirs[@]}"; do @@ -138,6 +167,7 @@ cleanup_stale_build_state() { } prefetch_chaquopy_runtime +prepare_wsl_build_dir for attempt in $(seq 1 "$ATTEMPTS"); do echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" From 76b375bd03d577bab17e859a608b2d112f1a61cf Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:32:34 +0300 Subject: [PATCH 32/37] fix(runtime): close ws pool tasks before loop shutdown --- proxy/tg_ws_proxy.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index f293f94..2a30cc6 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -539,6 +539,7 @@ class _WsPool: def __init__(self): self._idle: Dict[Tuple[int, bool], deque] = {} self._refilling: Set[Tuple[int, bool]] = set() + self._refill_tasks: Dict[Tuple[int, bool], asyncio.Task] = {} async def get(self, dc: int, is_media: bool, target_ip: str, domains: List[str] @@ -571,10 +572,13 @@ class _WsPool: if key in self._refilling: return self._refilling.add(key) - asyncio.create_task(self._refill(key, target_ip, domains)) + task = asyncio.create_task(self._refill(key, target_ip, domains)) + self._refill_tasks[key] = task + task.add_done_callback(lambda _t, refill_key=key: self._refill_tasks.pop(refill_key, None)) async def _refill(self, key, target_ip, domains): dc, is_media = key + tasks: List[asyncio.Task] = [] try: bucket = self._idle.setdefault(key, deque()) needed = proxy_config.pool_size - len(bucket) @@ -588,10 +592,19 @@ class _WsPool: ws = await t if ws: bucket.append((ws, time.monotonic())) + except asyncio.CancelledError: + raise except Exception: pass log.debug("WS pool refilled DC%d%s: %d ready", dc, 'm' if is_media else '', len(bucket)) + except asyncio.CancelledError: + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + raise finally: self._refilling.discard(key) @@ -625,6 +638,29 @@ class _WsPool: self._schedule_refill((dc, is_media), target_ip, domains) log.info("WS pool warmup started for %d DC(s)", len(dc_redirects)) + async def close(self): + refill_tasks = list(self._refill_tasks.values()) + self._refill_tasks.clear() + for task in refill_tasks: + if not task.done(): + task.cancel() + if refill_tasks: + await asyncio.gather(*refill_tasks, return_exceptions=True) + + idle_sockets = [] + for bucket in self._idle.values(): + while bucket: + ws, _created = bucket.popleft() + idle_sockets.append(ws) + self._idle.clear() + self._refilling.clear() + + if idle_sockets: + await asyncio.gather( + *(self._quiet_close(ws) for ws in idle_sockets), + return_exceptions=True, + ) + _ws_pool = _WsPool() @@ -1111,6 +1147,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None): else: await server.serve_forever() finally: + await _ws_pool.close() log_stats_task.cancel() try: await log_stats_task From 7c8bc17db6c079dc10bb89065156346a725477f1 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:34:18 +0300 Subject: [PATCH 33/37] fix(android-build): run gradle from android project root --- android/build-local-debug.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index d6b07b6..47fe6c1 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -41,6 +41,7 @@ GRADLE_BIN="gradle" if [[ -x "$ROOT_DIR/gradlew" ]]; then GRADLE_BIN="$ROOT_DIR/gradlew" fi +GRADLE_RUN_DIR="$ROOT_DIR" ATTEMPTS="${ATTEMPTS:-5}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}" @@ -171,7 +172,10 @@ prepare_wsl_build_dir for attempt in $(seq 1 "$ATTEMPTS"); do echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" - if "$GRADLE_BIN" --no-daemon --console=plain "$TASK"; then + if ( + cd "$GRADLE_RUN_DIR" + "$GRADLE_BIN" --no-daemon --console=plain "$TASK" + ); then exit 0 fi From 3552de7dbf61161c756e70ea46706be627f9e248 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:34:57 +0300 Subject: [PATCH 34/37] fix(gitignore): ignore WSL android build symlink --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 07431c4..8c51ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ local.properties android/.idea/ android/build/ android/app/build/ +android/app/build android/*.jks *.keystore android/*.keystore.properties From e511ff597bb740748c4ec4864a84c240b8567dff Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 16:38:09 +0300 Subject: [PATCH 35/37] fix(runtime): detect android bind errors as port conflicts --- proxy/app_runtime.py | 5 +++-- tests/test_app_runtime.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 5fb7ba8..5465b17 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -158,8 +158,9 @@ class ProxyAppRuntime: loop.run_until_complete(self.run_proxy(stop_event=stop_ev)) except Exception as exc: self.log.error("Proxy thread crashed: %s", exc) - if ("10048" in str(exc) or - "Address already in use" in str(exc)): + exc_text = str(exc) + if ("10048" in exc_text or + "address already in use" in exc_text.lower()): self._emit_error( "Не удалось запустить прокси:\n" "Порт уже используется другим приложением.\n\n" diff --git a/tests/test_app_runtime.py b/tests/test_app_runtime.py index bf3dfc7..36a4305 100644 --- a/tests/test_app_runtime.py +++ b/tests/test_app_runtime.py @@ -133,6 +133,34 @@ class ProxyAppRuntimeTests(unittest.TestCase): self.assertEqual(errors, ["proxy boom"]) + def test_run_proxy_thread_reports_port_in_use_case_insensitively(self): + with tempfile.TemporaryDirectory() as tmpdir: + errors = [] + + async def fake_run_proxy(stop_event=None): + raise RuntimeError( + "[Errno 98] error while attempting to bind on address " + "('127.0.0.1', 1443): address already in use" + ) + + runtime = ProxyAppRuntime( + Path(tmpdir), + on_error=errors.append, + run_proxy=fake_run_proxy, + ) + + runtime._run_proxy_thread(1443, {2: "149.154.167.220"}, "127.0.0.1") + + self.assertEqual( + errors, + [ + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите." + ], + ) + if __name__ == "__main__": unittest.main() From 509f50fcaef25fd9e7f1f546ef1c83d7c4fdaea3 Mon Sep 17 00:00:00 2001 From: Dark_Avery Date: Mon, 30 Mar 2026 17:03:26 +0300 Subject: [PATCH 36/37] fix(android): avoid cryptography dependency and preserve version on update errors --- .../org/flowseal/tgwsproxy/MainActivity.kt | 2 +- proxy/tg_ws_proxy.py | 36 ++++++++----------- 2 files changed, 15 insertions(+), 23 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 15004ce..28a50f1 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -170,7 +170,7 @@ class MainActivity : AppCompatActivity() { } }.getOrElse { exc -> ProxyUpdateStatus( - currentVersion = "unknown", + currentVersion = currentAppVersionName(), error = exc.message ?: exc.javaClass.simpleName, ) } diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 2a30cc6..e3af6e1 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -17,7 +17,7 @@ from collections import deque from dataclasses import dataclass, field from typing import Dict, List, Optional, Set, Tuple -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from proxy.crypto_backend import create_aes_ctr_transform @dataclass @@ -332,9 +332,10 @@ def _try_handshake(handshake: bytes, secret: bytes) -> Optional[Tuple[int, bool, dec_key = hashlib.sha256(dec_prekey + secret).digest() dec_iv_int = int.from_bytes(dec_iv, 'big') - decryptor = Cipher( - algorithms.AES(dec_key), modes.CTR(dec_iv_int.to_bytes(16, 'big')) - ).encryptor() + decryptor = create_aes_ctr_transform( + dec_key, + dec_iv_int.to_bytes(16, 'big'), + ) decrypted = decryptor.update(handshake) proto_tag = decrypted[PROTO_TAG_POS:PROTO_TAG_POS + 4] @@ -367,9 +368,7 @@ def _generate_relay_init(proto_tag: bytes, dc_idx: int) -> bytes: enc_key = rnd_bytes[SKIP_LEN:SKIP_LEN + PREKEY_LEN] enc_iv = rnd_bytes[SKIP_LEN + PREKEY_LEN:SKIP_LEN + PREKEY_LEN + IV_LEN] - encryptor = Cipher( - algorithms.AES(enc_key), modes.CTR(enc_iv) - ).encryptor() + encryptor = create_aes_ctr_transform(enc_key, enc_iv) dc_bytes = struct.pack(' Date: Mon, 30 Mar 2026 17:18:44 +0300 Subject: [PATCH 37/37] test(android): cover bridge startup without cryptography --- tests/test_android_proxy_bridge.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_android_proxy_bridge.py b/tests/test_android_proxy_bridge.py index ab10293..09a7b50 100644 --- a/tests/test_android_proxy_bridge.py +++ b/tests/test_android_proxy_bridge.py @@ -1,6 +1,7 @@ import sys import unittest import json +import subprocess from pathlib import Path @@ -226,6 +227,38 @@ class AndroidProxyBridgeTests(unittest.TestCase): self.assertEqual(result["error"], "") self.assertEqual(result["html_url"], "https://example.com/releases/latest") + def test_android_bridge_import_and_update_status_work_without_cryptography(self): + root = Path(__file__).resolve().parents[1] + script = f""" +import importlib.abc +import sys +from pathlib import Path + +root = Path({str(root)!r}) +sys.path.insert(0, str(root / "android" / "app" / "src" / "main" / "python")) +sys.path.insert(0, str(root)) + +class BlockCryptography(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname == "cryptography" or fullname.startswith("cryptography."): + raise ModuleNotFoundError("No module named 'cryptography'") + return None + +sys.meta_path.insert(0, BlockCryptography()) + +import android_proxy_bridge +print(android_proxy_bridge.get_update_status_json(False)) +""" + result = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(result.stdout.strip()) + self.assertEqual(payload["current_version"], android_proxy_bridge.__version__) + self.assertEqual(payload["error"], "") + if __name__ == "__main__": unittest.main()