Merge pull request #2 from Dark-Avery/android_migration

Android migration
This commit is contained in:
Dark-Avery 2026-03-16 23:49:42 +03:00 committed by GitHub
commit a57f238d3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 644 additions and 14 deletions

View File

@ -43,6 +43,8 @@ jobs:
build-android: build-android:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
ANDROID_APK_NAME: tg-ws-proxy-android-${{ github.event.inputs.version }}.apk
defaults: defaults:
run: run:
working-directory: android working-directory: android
@ -50,6 +52,18 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
@ -71,19 +85,32 @@ jobs:
- name: Install Android SDK packages - name: Install Android SDK packages
run: sdkmanager "platforms;android-34" "build-tools;34.0.0" 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: | run: |
chmod +x gradlew build-local-debug.sh 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 - 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: TgWsProxy-android-debug name: TgWsProxy-android-release
path: android/app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk path: android/app/build/outputs/apk/release/${{ env.ANDROID_APK_NAME }}
build-win7: build-win7:
runs-on: windows-latest runs-on: windows-latest
@ -134,7 +161,7 @@ jobs:
- name: Download Android build - name: Download Android build
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: TgWsProxy-android-debug name: TgWsProxy-android-release
path: dist path: dist
- name: Create GitHub Release - name: Create GitHub Release
@ -147,7 +174,7 @@ jobs:
files: | files: |
dist/TgWsProxy.exe dist/TgWsProxy.exe
dist/TgWsProxy-win7.exe dist/TgWsProxy-win7.exe
dist/tg-ws-proxy-android-debug.apk dist/tg-ws-proxy-android-${{ github.event.inputs.version }}.apk
draft: false draft: false
prerelease: false prerelease: false
env: env:

3
.gitignore vendored
View File

@ -24,6 +24,9 @@ local.properties
android/.idea/ android/.idea/
android/build/ android/build/
android/app/build/ android/app/build/
android/*.jks
*.keystore
android/*.keystore.properties
# OS # OS
Thumbs.db Thumbs.db

View File

@ -1,4 +1,6 @@
import org.gradle.api.tasks.Sync import org.gradle.api.tasks.Sync
import org.gradle.api.GradleException
import java.io.File
plugins { plugins {
id("com.android.application") id("com.android.application")
@ -6,6 +8,44 @@ plugins {
id("org.jetbrains.kotlin.android") 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 stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python")
val stagePythonSources by tasks.registering(Sync::class) { val stagePythonSources by tasks.registering(Sync::class) {
from(rootProject.projectDir.resolve("../proxy")) { from(rootProject.projectDir.resolve("../proxy")) {
@ -13,6 +53,10 @@ val stagePythonSources by tasks.registering(Sync::class) {
} }
into(stagedPythonSourcesDir) into(stagedPythonSourcesDir)
} }
val releaseSigningRequested = gradle.startParameter.taskNames.any {
it.contains("release", ignoreCase = true)
}
val releaseSigningEnv = loadReleaseSigningEnv(releaseSigningRequested)
android { android {
namespace = "org.flowseal.tgwsproxy" 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 { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
@ -39,6 +94,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
if (releaseSigningEnv != null) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }

View File

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@ -0,0 +1,62 @@
package org.flowseal.tgwsproxy
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
data class AndroidSystemStatus(
val ignoringBatteryOptimizations: Boolean,
val backgroundRestricted: Boolean,
) {
val canKeepRunningReliably: Boolean
get() = ignoringBatteryOptimizations && !backgroundRestricted
companion object {
fun read(context: Context): AndroidSystemStatus {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val ignoringBatteryOptimizations = if (Build.VERSION.SDK_INT >= 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))
}
}
}

View File

@ -1,9 +1,11 @@
package org.flowseal.tgwsproxy package org.flowseal.tgwsproxy
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -42,10 +44,23 @@ class MainActivity : AppCompatActivity() {
binding.startButton.setOnClickListener { onStartClicked() } binding.startButton.setOnClickListener { onStartClicked() }
binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) }
binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } 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()) renderConfig(settingsStore.load())
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
observeServiceState() observeServiceState()
renderSystemStatus()
}
override fun onResume() {
super.onResume()
renderSystemStatus()
} }
private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? { 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() 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) { private fun renderConfig(config: ProxyConfig) {
binding.hostInput.setText(config.host) binding.hostInput.setText(config.host)
binding.portInput.setText(config.portText) 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<String>()
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() { private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return return

View File

@ -3,21 +3,29 @@ package org.flowseal.tgwsproxy
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.TaskStackBuilder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
class ProxyForegroundService : Service() { class ProxyForegroundService : Service() {
private lateinit var settingsStore: ProxySettingsStore private lateinit var settingsStore: ProxySettingsStore
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var trafficJob: Job? = null
private var lastTrafficSample: TrafficSample? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -47,7 +55,14 @@ class ProxyForegroundService : Service() {
startForeground( startForeground(
NOTIFICATION_ID, NOTIFICATION_ID,
buildNotification( 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 { serviceScope.launch {
@ -60,6 +75,7 @@ class ProxyForegroundService : Service() {
} }
override fun onDestroy() { override fun onDestroy() {
stopTrafficUpdates()
serviceScope.cancel() serviceScope.cancel()
runCatching { PythonProxyBridge.stop(this) } runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped() ProxyServiceState.markStopped()
@ -68,11 +84,21 @@ class ProxyForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null 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) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title)) .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) .setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(createOpenAppPendingIntent())
.addAction(
0,
getString(R.string.notification_action_stop),
createStopPendingIntent(),
)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.build() .build()
@ -85,17 +111,30 @@ class ProxyForegroundService : Service() {
result.onSuccess { result.onSuccess {
ProxyServiceState.markStarted(config) 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 -> }.onFailure { error ->
ProxyServiceState.markFailed( ProxyServiceState.markFailed(
error.message ?: getString(R.string.proxy_start_failed_generic), error.message ?: getString(R.string.proxy_start_failed_generic),
) )
stopTrafficUpdates()
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
} }
} }
private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) {
stopTrafficUpdates()
runCatching { PythonProxyBridge.stop(this) } runCatching { PythonProxyBridge.stop(this) }
ProxyServiceState.markStopped() 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) 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() { 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,
)

View File

@ -4,6 +4,7 @@ import android.content.Context
import com.chaquo.python.Python import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform import com.chaquo.python.android.AndroidPlatform
import java.io.File import java.io.File
import org.json.JSONObject
object PythonProxyBridge { object PythonProxyBridge {
private const val MODULE_NAME = "android_proxy_bridge" private const val MODULE_NAME = "android_proxy_bridge"
@ -27,6 +28,20 @@ object PythonProxyBridge {
getModule(context).callAttr("stop_proxy") 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) = private fun getModule(context: Context) =
getPython(context.applicationContext).getModule(MODULE_NAME) getPython(context.applicationContext).getModule(MODULE_NAME)
@ -37,3 +52,9 @@ object PythonProxyBridge {
return Python.getInstance() return Python.getInstance()
} }
} }
data class ProxyTrafficStats(
val bytesUp: Long = 0L,
val bytesDown: Long = 0L,
val running: Boolean = false,
)

View File

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

View File

@ -1,10 +1,12 @@
import os import os
import threading import threading
import time import time
import json
from pathlib import Path from pathlib import Path
from typing import Iterable, Optional from typing import Iterable, Optional
from proxy.app_runtime import ProxyAppRuntime from proxy.app_runtime import ProxyAppRuntime
import proxy.tg_ws_proxy as tg_ws_proxy
_RUNTIME_LOCK = threading.RLock() _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]: 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<String> 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, 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 _LAST_ERROR = None
os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python" os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python"
tg_ws_proxy.reset_stats()
runtime = ProxyAppRuntime( runtime = ProxyAppRuntime(
Path(app_dir), Path(app_dir),
@ -90,3 +110,12 @@ def is_running() -> bool:
def get_last_error() -> Optional[str]: def get_last_error() -> Optional[str]:
return _LAST_ERROR 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)

View File

@ -64,6 +64,58 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardCornerRadius="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="18dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/system_status_label"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge" />
<TextView
android:id="@+id/systemStatusValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/system_status_attention"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:textStyle="bold" />
<TextView
android:id="@+id/systemStatusHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
<com.google.android.material.button.MaterialButton
android:id="@+id/disableBatteryOptimizationButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/disable_battery_optimization_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openAppSettingsButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_app_settings_button" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -147,5 +199,13 @@
android:enabled="false" android:enabled="false"
android:text="@string/stop_button" /> android:text="@string/stop_button" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openTelegramButton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/open_telegram_button" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@ -9,6 +9,14 @@
<string name="service_hint_idle">Configure the proxy settings, then start the foreground service.</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_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="service_hint_running">Foreground service active for %1$s:%2$d.</string>
<string name="system_status_label">Android background limits</string>
<string name="system_status_ready">Ready</string>
<string name="system_status_attention">Needs attention</string>
<string name="system_check_battery_ignored">Battery optimization: disabled for this app.</string>
<string name="system_check_battery_active">Battery optimization: still enabled, Android may stop the proxy in background.</string>
<string name="system_check_background_ok">Background restriction: not detected.</string>
<string name="system_check_background_restricted">Background restriction: enabled, Android may block long-running work.</string>
<string name="system_check_oem_note">Some phones also require manual vendor settings such as Autostart, Lock in recents, or Unrestricted battery mode.</string>
<string name="host_hint">Proxy host</string> <string name="host_hint">Proxy host</string>
<string name="port_hint">Proxy port</string> <string name="port_hint">Proxy port</string>
<string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string> <string name="dc_ip_hint">DC to IP mappings (one DC:IP per line)</string>
@ -16,13 +24,20 @@
<string name="save_button">Save Settings</string> <string name="save_button">Save Settings</string>
<string name="start_button">Start Service</string> <string name="start_button">Start Service</string>
<string name="stop_button">Stop Service</string> <string name="stop_button">Stop Service</string>
<string name="open_telegram_button">Open in Telegram</string>
<string name="disable_battery_optimization_button">Disable Battery Optimization</string>
<string name="open_app_settings_button">Open App Settings</string>
<string name="settings_saved">Settings saved</string> <string name="settings_saved">Settings saved</string>
<string name="service_start_requested">Foreground service start requested</string> <string name="service_start_requested">Foreground service start requested</string>
<string name="telegram_not_found">Telegram app was not found for tg://socks.</string>
<string name="notification_title">TG WS Proxy</string> <string name="notification_title">TG WS Proxy</string>
<string name="notification_channel_name">Proxy service</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_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_starting">SOCKS5 %1$s:%2$d • starting embedded Python</string>
<string name="notification_running">SOCKS5 %1$s:%2$d • proxy active</string> <string name="notification_running">SOCKS5 %1$s:%2$d • proxy active</string>
<string name="notification_endpoint">%1$s:%2$d</string>
<string name="notification_details">DC mappings: %1$d\nTraffic: ↑ %2$s/s ↓ %3$s/s\nTransferred: ↑ %4$s ↓ %5$s</string>
<string name="notification_action_stop">Stop</string>
<string name="saved_config_invalid">Saved proxy settings are invalid.</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> <string name="proxy_start_failed_generic">Failed to start embedded Python proxy.</string>
</resources> </resources>

View File

@ -492,6 +492,21 @@ class Stats:
_stats = 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: class _WsPool:
def __init__(self): def __init__(self):
self._idle: Dict[Tuple[int, bool], list] = {} self._idle: Dict[Tuple[int, bool], list] = {}

View File

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