Merge pull request #1 from Dark-Avery/android_migration

Android migration
This commit is contained in:
Dark-Avery 2026-03-16 21:21:43 +03:00 committed by GitHub
commit 360ea20902
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2278 additions and 124 deletions

View File

@ -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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@ -16,6 +16,14 @@ build/
.idea/
*.swp
*.swo
.gradle/
.gradle-local/
android/.gradle-local/
android/.m2-chaquopy*/
local.properties
android/.idea/
android/build/
android/app/build/
# OS
Thumbs.db

View File

@ -0,0 +1,82 @@
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
defaultConfig {
applicationId = "org.flowseal.tgwsproxy"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
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
}
}
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")
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")
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# Intentionally empty for the initial Android shell.

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_proxy_app"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_proxy_app"
android:supportsRtl="true"
android:theme="@style/Theme.TgWsProxy">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".ProxyForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View File

@ -0,0 +1,169 @@
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.flow.combine
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) {
combine(
ProxyServiceState.isStarting,
ProxyServiceState.isRunning,
) { isStarting, isRunning ->
isStarting to isRunning
}.collect { (isStarting, isRunning) ->
binding.statusValue.text = getString(
when {
isStarting -> R.string.status_starting
isRunning -> R.string.status_running
else -> R.string.status_stopped
},
)
binding.startButton.isEnabled = !isStarting && !isRunning
binding.stopButton.isEnabled = isStarting || isRunning
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
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,
config.host,
config.port,
)
}
}
}
}
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() {
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)
}
}

View File

@ -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<String>,
val verbose: Boolean,
)

View File

@ -0,0 +1,151 @@
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
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()
settingsStore = ProxySettingsStore(this)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return when (intent?.action) {
ACTION_STOP -> {
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.markStarting(config)
startForeground(
NOTIFICATION_ID,
buildNotification(
getString(R.string.notification_starting, config.host, config.port),
),
)
serviceScope.launch {
startProxyRuntime(config)
}
START_STICKY
}
}
}
}
override fun onDestroy() {
serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(contentText: String): Notification {
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 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
}
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)
}
}
}

View File

@ -0,0 +1,49 @@
package org.flowseal.tgwsproxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
object ProxyServiceState {
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning
private val _isStarting = MutableStateFlow(false)
val isStarting: StateFlow<Boolean> = _isStarting
private val _activeConfig = MutableStateFlow<NormalizedProxyConfig?>(null)
val activeConfig: StateFlow<NormalizedProxyConfig?> = _activeConfig
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _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
}
}

View File

@ -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"
}
}

View File

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

View File

@ -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

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1E88E5"
android:pathData="M54,10A44,44 0 1,1 10,54A44,44 0 0,1 54,10Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M33,34h42v10H59v30H49V44H33z" />
</vector>

View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textStyle="bold" />
<TextView
android:id="@+id/subtitleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/statusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/status_stopped"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/serviceHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="@string/service_hint_idle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/host_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/hostInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/port_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/portInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/dc_ip_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/dcIpInput"
android:layout_width="match_parent"
android:layout_height="140dp"
android:gravity="top|start"
android:inputType="textMultiLine"
android:minLines="5" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/verboseSwitch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/verbose_label" />
<TextView
android:id="@+id/errorText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/save_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/start_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:enabled="false"
android:text="@string/stop_button" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="proxy_blue">#1E88E5</color>
<color name="proxy_navy">#0B1F33</color>
<color name="proxy_surface">#F4F8FC</color>
<color name="proxy_white">#FFFFFF</color>
</resources>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TG WS Proxy</string>
<string name="subtitle">Android app for the local Telegram SOCKS5 proxy.</string>
<string name="status_label">Foreground service</string>
<string name="status_starting">Starting</string>
<string name="status_running">Running</string>
<string name="status_stopped">Stopped</string>
<string name="service_hint_idle">Configure the proxy settings, then start the foreground service.</string>
<string name="service_hint_starting">Starting embedded Python proxy for %1$s:%2$d.</string>
<string name="service_hint_running">Foreground service active for %1$s:%2$d.</string>
<string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
<string name="verbose_label">Verbose logging</string>
<string name="save_button">Save Settings</string>
<string name="start_button">Start Service</string>
<string name="stop_button">Stop Service</string>
<string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string>
<string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</string>
<string name="notification_channel_description">Keeps the Telegram proxy service alive in the foreground.</string>
<string name="notification_starting">SOCKS5 %1$s:%2$d • starting embedded Python</string>
<string name="notification_running">SOCKS5 %1$s:%2$d • proxy active</string>
<string name="saved_config_invalid">Saved proxy settings are invalid.</string>
<string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>
</resources>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.TgWsProxy" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/proxy_blue</item>
<item name="colorSecondary">@color/proxy_navy</item>
<item name="android:statusBarColor">@color/proxy_navy</item>
<item name="android:navigationBarColor">@color/proxy_white</item>
<item name="colorSurface">@color/proxy_surface</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>

View File

@ -0,0 +1,108 @@
#!/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}"
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-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"
)
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)"
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

5
android/build.gradle.kts Normal file
View File

@ -0,0 +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
}

View File

@ -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

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
android/gradlew vendored Normal file
View File

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
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" "$@"

92
android/gradlew.bat vendored Normal file
View File

@ -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

View File

@ -0,0 +1,28 @@
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()
}
}
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()
}
}
rootProject.name = "tg-ws-proxy-android"
include(":app")

1
proxy/__init__.py Normal file
View File

@ -0,0 +1 @@
"""TG WS Proxy core package."""

184
proxy/app_runtime.py Normal file
View File

@ -0,0 +1,184 @@
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)
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):
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"))
ch._tg_ws_proxy_runtime_handler = True
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()
def is_proxy_running(self) -> bool:
return bool(self._proxy_thread and self._proxy_thread.is_alive())

208
proxy/crypto_backend.py Normal file
View File

@ -0,0 +1,208 @@
from __future__ import annotations
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:
...
def finalize(self) -> bytes:
...
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
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.
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')
if selected == 'cryptography':
return _create_cryptography_transform(key, iv)
if selected == 'python':
return _PurePythonAesCtrTransform(key, iv)
raise ValueError(f"Unsupported AES-CTR backend: {selected}")

View File

@ -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('<I', plain[0:4])[0]
@ -396,8 +396,7 @@ def _patch_init_dc(data: bytes, dc: int) -> 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]:

121
tests/test_app_runtime.py Normal file
View File

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

View File

@ -0,0 +1,107 @@
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("<Ih", proto, dc_raw) + b"\x00\x00"
packet[56:64] = _xor(plain_tail, _keystream(64)[56:64])
return bytes(packet)
def _encrypt_after_init(init_packet: bytes, plaintext: bytes) -> 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_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")
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()

View File

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

View File

@ -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")