Merge pull request #2 from Dark-Avery/android_migration
Android migration
This commit is contained in:
commit
a57f238d3c
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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] = {}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue