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 28c6140..f083934 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,9 @@ local.properties
android/.idea/
android/build/
android/app/build/
+android/*.jks
+*.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")
+ }
}
}
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/ProxyForegroundService.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt
index 8d060bc..8e5bb25 100644
--- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt
+++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt
@@ -3,21 +3,29 @@ 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
+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()
@@ -47,7 +55,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 {
@@ -60,6 +75,7 @@ class ProxyForegroundService : Service() {
}
override fun onDestroy() {
+ stopTrafficUpdates()
serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped()
@@ -68,11 +84,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,17 +111,30 @@ class ProxyForegroundService : Service() {
result.onSuccess {
ProxyServiceState.markStarted(config)
- updateNotification(getString(R.string.notification_running, config.host, config.port))
+ lastTrafficSample = null
+ updateNotification(
+ buildNotificationPayload(
+ config = config,
+ statusText = getString(
+ R.string.notification_running,
+ config.host,
+ config.port,
+ ),
+ ),
+ )
+ 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()
@@ -107,9 +146,147 @@ 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 trafficState = readTrafficState()
+ val endpointText = getString(R.string.notification_endpoint, config.host, config.port)
+ val detailsText = getString(
+ R.string.notification_details,
+ config.dcIpList.size,
+ formatRate(trafficState.upBytesPerSecond),
+ formatRate(trafficState.downBytesPerSecond),
+ formatBytes(trafficState.totalBytesUp),
+ formatBytes(trafficState.totalBytesDown),
+ )
+ return NotificationPayload(
+ statusText = statusText,
+ endpointText = endpointText,
+ detailsText = detailsText,
+ )
+ }
+
+ 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 {
+ 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 +326,22 @@ class ProxyForegroundService : Service() {
}
}
}
+
+private data class NotificationPayload(
+ val statusText: String,
+ 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/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/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py
index 1ade094..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()
@@ -18,7 +20,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,
@@ -32,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),
@@ -90,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/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..7795a5f 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,13 +24,20 @@
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.
SOCKS5 %1$s:%2$d • starting embedded Python
SOCKS5 %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
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
new file mode 100644
index 0000000..2d314e7
--- /dev/null
+++ b/tests/test_android_proxy_bridge.py
@@ -0,0 +1,69 @@
+import sys
+import unittest
+import json
+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
+import proxy.tg_ws_proxy as tg_ws_proxy # 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 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",
+ " ",
+ "4:149.154.167.220 ",
+ ])
+
+ self.assertEqual(result, [
+ "2:149.154.167.220",
+ "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",
+ "4:149.154.167.220",
+ ]))
+
+ self.assertEqual(result, [
+ "2:149.154.167.220",
+ "4:149.154.167.220",
+ ])
+
+
+if __name__ == "__main__":
+ unittest.main()