diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ebf381..573f364 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -289,7 +289,7 @@ jobs:
run: |
npm install
cargo build -p ostp-tun-helper --release --target ${{ matrix.target }}
- npm run build -- --no-bundle --target ${{ matrix.target }}
+ npx tauri build --no-bundle --target ${{ matrix.target }}
- name: Package Portable ZIP
shell: pwsh
@@ -312,8 +312,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-android:
- name: Build Android Client (Flutter)
+ name: Build Android Client (Flutter) - ${{ matrix.arch }}
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - arch: arm64-v8a
+ flutter_target: android-arm64
+ tun2socks_arch: linux-arm64
+ - arch: armeabi-v7a
+ flutter_target: android-arm
+ tun2socks_arch: linux-armv7
steps:
- uses: actions/checkout@v4
@@ -347,42 +356,31 @@ jobs:
working-directory: ostp-flutter
run: |
# 1. Compile JNI
- mkdir -p android/app/src/main/jniLibs/arm64-v8a
- mkdir -p android/app/src/main/jniLibs/armeabi-v7a
+ mkdir -p android/app/src/main/jniLibs/${{ matrix.arch }}
cd ../ostp-jni
- cargo ndk -t arm64-v8a -t armeabi-v7a -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
+ cargo ndk -t ${{ matrix.arch }} -o "../ostp-flutter/android/app/src/main/jniLibs" build --release
cd ../ostp-flutter
# 2. Download tun2socks
- if [ ! -f "android/app/src/main/jniLibs/arm64-v8a/libtun2socks.so" ]; then
- curl -fsSL "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-arm64.zip" -o "t2s64.zip"
- unzip -o t2s64.zip -d t2s64_tmp
- cp t2s64_tmp/tun2socks-linux-arm64 android/app/src/main/jniLibs/arm64-v8a/libtun2socks.so
- rm -rf t2s64.zip t2s64_tmp
- fi
-
- if [ ! -f "android/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so" ]; then
- curl -fsSL "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-armv7.zip" -o "t2s32.zip"
- unzip -o t2s32.zip -d t2s32_tmp
- cp t2s32_tmp/tun2socks-linux-armv7 android/app/src/main/jniLibs/armeabi-v7a/libtun2socks.so
- rm -rf t2s32.zip t2s32_tmp
+ if [ ! -f "android/app/src/main/jniLibs/${{ matrix.arch }}/libtun2socks.so" ]; then
+ curl -fsSL "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip" -o "t2s.zip"
+ unzip -o t2s.zip -d t2s_tmp
+ cp t2s_tmp/tun2socks-${{ matrix.tun2socks_arch }} android/app/src/main/jniLibs/${{ matrix.arch }}/libtun2socks.so
+ rm -rf t2s.zip t2s_tmp
fi
# 3. Build Flutter APK
- flutter build apk --release --split-per-abi
+ flutter build apk --release --target-platform ${{ matrix.flutter_target }}
# 4. Copy to output
- cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk ostp-android-armv7.apk
- cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk ostp-android-arm64.apk
+ cp build/app/outputs/flutter-apk/app-release.apk ostp-android-${{ matrix.arch }}.apk
- name: Upload to GitHub Release
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
- files: |
- ostp-flutter/ostp-android-armv7.apk
- ostp-flutter/ostp-android-arm64.apk
+ files: ostp-flutter/ostp-android-${{ matrix.arch }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index b0d05d1..fbc9a7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
/target/
/target_build/
/target_linux/
-/ostp-gui/
/dist/
**/*.rs.bk
.idea/
@@ -32,4 +31,4 @@ wintun.dll
# Dev notes (not for repo)
.ai-rules.md
turn-harvesting-idea.md
-ostp-flutter/
+
diff --git a/README.md b/README.md
index 46d55ec..a3078ae 100644
--- a/README.md
+++ b/README.md
@@ -139,8 +139,9 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git
### 4. Connect via share link (one-liner)
```bash
-./ostp ostp://ACCESS_KEY@server.com:50000
+./ostp "ostp://ACCESS_KEY@server.com:50000?..."
```
+> **Note**: Always wrap the `ostp://...` link in quotes (`"`) so your terminal doesn't misinterpret special characters like `&` or `?`.
---
@@ -230,3 +231,10 @@ cargo test -p ostp-core -p ostp-server
Business Source License 1.1. Free for personal and non-commercial use.
Converts to MIT License on May 14, 2030.
+
+---
+
+## Contact
+
+- **Telegram**: [@ospab0](https://t.me/ospab0)
+- **Email**: gvoprgrg@gmail.com
diff --git a/ostp-flutter/.gitignore b/ostp-flutter/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/ostp-flutter/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/ostp-flutter/.metadata b/ostp-flutter/.metadata
new file mode 100644
index 0000000..51c0bb5
--- /dev/null
+++ b/ostp-flutter/.metadata
@@ -0,0 +1,45 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: android
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: ios
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: linux
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: macos
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: web
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ - platform: windows
+ create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/ostp-flutter/README.md b/ostp-flutter/README.md
new file mode 100644
index 0000000..f6ef46b
--- /dev/null
+++ b/ostp-flutter/README.md
@@ -0,0 +1,17 @@
+# ostp_client
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/ostp-flutter/analysis_options.yaml b/ostp-flutter/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/ostp-flutter/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/ostp-flutter/android/.gitignore b/ostp-flutter/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/ostp-flutter/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/ostp-flutter/android/app/build.gradle.kts b/ostp-flutter/android/app/build.gradle.kts
new file mode 100644
index 0000000..686b244
--- /dev/null
+++ b/ostp-flutter/android/app/build.gradle.kts
@@ -0,0 +1,48 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.ospab.ostp_client"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.ospab.ostp_client"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = maxOf(flutter.minSdkVersion, 24)
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+}
diff --git a/ostp-flutter/android/app/src/debug/AndroidManifest.xml b/ostp-flutter/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/ostp-flutter/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/ostp-flutter/android/app/src/main/AndroidManifest.xml b/ostp-flutter/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5f080f7
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ostp-flutter/android/app/src/main/assets/tun2socks-arm64 b/ostp-flutter/android/app/src/main/assets/tun2socks-arm64
new file mode 100644
index 0000000..14c8bbd
Binary files /dev/null and b/ostp-flutter/android/app/src/main/assets/tun2socks-arm64 differ
diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/MainActivity.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/MainActivity.kt
new file mode 100644
index 0000000..ab1ba66
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/MainActivity.kt
@@ -0,0 +1,138 @@
+package com.ospab.ostp_client
+
+import android.content.Intent
+import android.net.VpnService
+import androidx.annotation.NonNull
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.util.Base64
+import java.io.ByteArrayOutputStream
+
+class MainActivity : FlutterActivity() {
+ private val CHANNEL = "com.ospab.ostp/vpn"
+ private val VPN_REQUEST_CODE = 0x0F
+ private var pendingConfigJson: String? = null
+
+ private fun getAppIconBase64(pm: PackageManager, appInfo: ApplicationInfo): String? {
+ try {
+ val drawable = pm.getApplicationIcon(appInfo)
+ val width = 96
+ val height = 96
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ drawable.setBounds(0, 0, width, height)
+ drawable.draw(canvas)
+
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream)
+ val byteArray = outputStream.toByteArray()
+ return Base64.encodeToString(byteArray, Base64.NO_WRAP)
+ } catch (e: Throwable) {
+ return null
+ }
+ }
+
+ override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "saveConfig" -> {
+ val configJson = call.argument("configJson")
+ val prefs = getSharedPreferences("OstpPrefs", android.content.Context.MODE_PRIVATE)
+ prefs.edit().putString("latest_config_json", configJson).apply()
+ result.success(true)
+ }
+ "startTunnel" -> {
+ pendingConfigJson = call.argument("configJson")
+ val intent = VpnService.prepare(this)
+ if (intent != null) {
+ startActivityForResult(intent, VPN_REQUEST_CODE)
+ result.success(true)
+ } else {
+ startVpnService()
+ result.success(true)
+ }
+ }
+ "stopTunnel" -> {
+ try {
+ val intent = Intent(this, OstpVpnService::class.java)
+ intent.action = "STOP"
+ startService(intent)
+ result.success(true)
+ } catch (e: Throwable) {
+ result.error("ERROR", e.message, null)
+ }
+ }
+ "getLogs" -> {
+ try {
+ val logs = net.ostp.client.OstpClientSdk.getLogs()
+ result.success(logs ?: "[]")
+ } catch (e: Throwable) {
+ result.error("ERROR", e.message ?: "Unknown JNI Error", null)
+ }
+ }
+ "clearLogs" -> {
+ try {
+ net.ostp.client.OstpClientSdk.getLogs() // Drain
+ result.success(true)
+ } catch (e: Throwable) {
+ result.error("ERROR", e.message, null)
+ }
+ }
+ "isRunning" -> {
+ result.success(OstpVpnService.isRunning)
+ }
+ "getMetrics" -> {
+ try {
+ val metrics = net.ostp.client.OstpClientSdk.getMetrics()
+ result.success(metrics ?: "{}")
+ } catch (e: Throwable) {
+ result.error("ERROR", e.message, null)
+ }
+ }
+ "getInstalledApps" -> {
+ try {
+ val pm = packageManager
+ val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
+ val list = apps.map { app ->
+ val isSystem = ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) &&
+ (pm.getLaunchIntentForPackage(app.packageName) == null)
+ val iconBase64 = getAppIconBase64(pm, app)
+ mapOf(
+ "name" to pm.getApplicationLabel(app).toString(),
+ "package" to app.packageName,
+ "isSystem" to isSystem,
+ "icon" to (iconBase64 ?: "")
+ )
+ }
+ result.success(list)
+ } catch (e: Exception) {
+ result.error("ERROR", e.message, null)
+ }
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (requestCode == VPN_REQUEST_CODE && resultCode == RESULT_OK) {
+ startVpnService()
+ }
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ private fun startVpnService() {
+ val intent = Intent(this, OstpVpnService::class.java)
+ intent.action = "START"
+ if (pendingConfigJson != null) {
+ intent.putExtra("configJson", pendingConfigJson)
+ }
+ startService(intent)
+ }
+}
diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt
new file mode 100644
index 0000000..8650032
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpTileService.kt
@@ -0,0 +1,93 @@
+package com.ospab.ostp_client
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import androidx.annotation.Keep
+import androidx.annotation.RequiresApi
+
+@Keep
+@RequiresApi(Build.VERSION_CODES.N)
+class OstpTileService : TileService() {
+
+ override fun onStartListening() {
+ super.onStartListening()
+ updateTile()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ if (OstpVpnService.isRunning) {
+ // Отключить VPN
+ val stopIntent = Intent(this, OstpVpnService::class.java).apply { action = "STOP" }
+ startService(stopIntent)
+ // Обновим плитку сразу
+ qsTile?.state = Tile.STATE_INACTIVE
+ qsTile?.label = "OSTP VPN"
+ qsTile?.updateTile()
+ } else {
+ // Включить VPN напрямую
+ val prefs = getSharedPreferences("OstpPrefs", Context.MODE_PRIVATE)
+ val configJson = prefs.getString("latest_config_json", null)
+
+ if (configJson != null) {
+ val startIntent = Intent(this, OstpVpnService::class.java).apply {
+ action = "START"
+ putExtra("configJson", configJson)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(startIntent)
+ } else {
+ startService(startIntent)
+ }
+ qsTile?.state = Tile.STATE_ACTIVE
+ qsTile?.label = "OSTP VPN"
+ qsTile?.updateTile()
+ } else {
+ // Если конфигурация еще не сохранена, открыть приложение
+ val appIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ putExtra("tile_connect", true)
+ }
+ if (appIntent != null) {
+ startActivityAndCollapse(appIntent)
+ }
+ }
+ }
+ }
+
+ private fun updateTile() {
+ val tile = qsTile ?: return
+ if (OstpVpnService.isRunning) {
+ tile.label = "OSTP VPN"
+ tile.state = Tile.STATE_ACTIVE
+ } else {
+ tile.label = "OSTP VPN"
+ tile.state = Tile.STATE_INACTIVE
+ }
+ tile.updateTile()
+ }
+
+ companion object {
+ /**
+ * Запрашивает обновление плитки быстрых настроек.
+ * Вызывается из OstpVpnService при изменении состояния.
+ */
+ @Keep
+ fun requestListeningState(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ try {
+ requestListeningState(
+ context,
+ ComponentName(context, OstpTileService::class.java)
+ )
+ } catch (e: Exception) {
+ // Плитка может быть не добавлена в панель — это нормально
+ }
+ }
+ }
+ }
+}
diff --git a/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt
new file mode 100644
index 0000000..525f3a6
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/kotlin/com/ospab/ostp_client/OstpVpnService.kt
@@ -0,0 +1,261 @@
+package com.ospab.ostp_client
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.VpnService
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.os.PowerManager
+import android.util.Log
+import net.ostp.client.OstpClientSdk
+import java.io.IOException
+import androidx.annotation.Keep
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+
+@Keep
+class OstpVpnService : VpnService() {
+
+ @Keep
+ companion object {
+ @Keep
+ var isRunning = false
+ @Keep
+ var instance: OstpVpnService? = null
+
+ private const val NOTIF_ID = 1001
+ private const val CHANNEL_ID = "ostp_vpn_channel"
+ private const val WAKE_LOCK_TAG = "ostp:vpn_wakelock"
+
+ /** Called from Kotlin OstpClientSdk to protect VPN sockets from the VPN itself. */
+ @Keep
+ @JvmStatic
+ fun protectSocket(fd: Int): Boolean {
+ return instance?.protect(fd) ?: false
+ }
+
+ /**
+ * Called by OstpClientSdk.notifyNetworkChanged() JNI thunk.
+ */
+ @Keep
+ @JvmStatic
+ fun onNetworkChanged() {
+ android.util.Log.d("OstpVpnService", "onNetworkChanged() signaled to Rust bridge")
+ }
+ }
+
+ private var vpnInterface: ParcelFileDescriptor? = null
+ private var wakeLock: PowerManager.WakeLock? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val action = intent?.action
+ if (action == "START") {
+ val configJson = intent.getStringExtra("configJson") ?: return START_NOT_STICKY
+ // Launch foreground immediately so Android doesn't kill us
+ startForeground(NOTIF_ID, buildNotification(connecting = true))
+ startVpn(configJson)
+ } else if (action == "STOP") {
+ stopVpn()
+ }
+ return START_STICKY
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "OSTP VPN",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "OSTP VPN connection status"
+ setShowBadge(false)
+ }
+ val nm = getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(connecting: Boolean): Notification {
+ val stopIntent = PendingIntent.getService(
+ this,
+ 0,
+ Intent(this, OstpVpnService::class.java).apply { action = "STOP" },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val openIntent = PendingIntent.getActivity(
+ this,
+ 1,
+ packageManager.getLaunchIntentForPackage(packageName)
+ ?.apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val (statusText, actionLabel) = if (connecting) {
+ Pair("Подключение...", "Отмена")
+ } else {
+ Pair("Подключено", "Отключить")
+ }
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("OSTP VPN")
+ .setContentText(statusText)
+ .setSmallIcon(android.R.drawable.ic_lock_lock)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setContentIntent(openIntent)
+ .addAction(android.R.drawable.ic_delete, actionLabel, stopIntent)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build()
+ }
+
+ fun updateNotification(connected: Boolean) {
+ try {
+ val nm = NotificationManagerCompat.from(this)
+ nm.notify(NOTIF_ID, buildNotification(connecting = !connected))
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Failed to update notification", e)
+ }
+ // Refresh Quick Settings tile state
+ OstpTileService.requestListeningState(applicationContext)
+ }
+
+ private fun acquireWakeLock() {
+ if (wakeLock == null) {
+ val pm = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG)
+ wakeLock?.acquire(24 * 60 * 60 * 1000L) // Max 24h
+ Log.d("OstpVpnService", "WakeLock acquired")
+ }
+ }
+
+ private fun releaseWakeLock() {
+ try {
+ wakeLock?.let {
+ if (it.isHeld) it.release()
+ }
+ wakeLock = null
+ Log.d("OstpVpnService", "WakeLock released")
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Error releasing WakeLock", e)
+ }
+ }
+
+ private fun startVpn(configJson: String) {
+ if (vpnInterface != null) return
+
+ acquireWakeLock()
+
+ try {
+ val json = org.json.JSONObject(configJson)
+ val dnsServer = json.optString("dns_server", "1.1.1.1")
+ val localProxy = json.optJSONObject("local_proxy")?.optString("bind_addr", "127.0.0.1:1088") ?: "127.0.0.1:1088"
+
+ val builder = Builder()
+ .setSession("OSTP Tunnel")
+ .addAddress("10.1.0.2", 24)
+ .addAddress("fd00:1:fd00:1:fd00:1:fd00:1", 128)
+ .addRoute("0.0.0.0", 0)
+ .addRoute("::", 0)
+ .addDnsServer(dnsServer)
+ .setMtu(1300)
+
+ try {
+ builder.allowFamily(android.system.OsConstants.AF_INET)
+ builder.allowFamily(android.system.OsConstants.AF_INET6)
+ } catch (e: Throwable) { }
+
+ val appRules = json.optJSONObject("app_rules")
+ val mode = appRules?.optString("mode", "bypass") ?: "bypass"
+ val packages = appRules?.optJSONArray("packages")
+
+ if (mode == "proxy") {
+ if (packages != null) {
+ for (i in 0 until packages.length()) {
+ val pkg = packages.getString(i)
+ try {
+ builder.addAllowedApplication(pkg)
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Failed to add allowed application $pkg: $e")
+ }
+ }
+ }
+ } else {
+ try {
+ builder.addDisallowedApplication(applicationContext.packageName)
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Failed to disallow our own package: $e")
+ }
+
+ if (packages != null) {
+ for (i in 0 until packages.length()) {
+ val pkg = packages.getString(i)
+ try {
+ builder.addDisallowedApplication(pkg)
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Failed to add disallowed application $pkg: $e")
+ }
+ }
+ }
+ }
+
+ vpnInterface = builder.establish()
+ val fd = vpnInterface?.fd ?: throw Exception("Failed to get VPN FD")
+
+ // CRITICAL: Clear O_CLOEXEC so the child process inherits the TUN file descriptor
+ try {
+ android.system.Os.fcntlInt(vpnInterface!!.fileDescriptor, android.system.OsConstants.F_SETFD, 0)
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Failed to clear O_CLOEXEC", e)
+ }
+
+ val t2sBin = applicationInfo.nativeLibraryDir + "/libtun2socks.so"
+ val success = OstpClientSdk.startClient(configJson, fd, t2sBin, localProxy)
+ if (success) {
+ Log.i("OstpVpnService", "OSTP Rust Core & tun2socks started successfully")
+ isRunning = true
+ updateNotification(connected = true)
+ } else {
+ Log.e("OstpVpnService", "Failed to start OSTP Rust Core & tun2socks")
+ stopVpn()
+ }
+
+ } catch (e: Throwable) {
+ Log.e("OstpVpnService", "Error starting VPN", e)
+ stopVpn()
+ }
+ }
+
+ private fun stopVpn() {
+ isRunning = false
+ releaseWakeLock()
+
+ try {
+ OstpClientSdk.stopClient()
+ vpnInterface?.close()
+ vpnInterface = null
+ } catch (e: IOException) {
+ Log.e("OstpVpnService", "Error closing VPN interface", e)
+ }
+
+ stopForeground(true)
+ OstpTileService.requestListeningState(applicationContext)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ instance = null
+ stopVpn()
+ }
+}
diff --git a/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt b/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt
new file mode 100644
index 0000000..0495e35
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/kotlin/net/ostp/client/OstpClientSdk.kt
@@ -0,0 +1,43 @@
+package net.ostp.client
+
+import androidx.annotation.Keep
+
+@Keep
+object OstpClientSdk {
+ init {
+ System.loadLibrary("ostp_jni")
+ }
+
+ @Keep
+ @JvmStatic
+ fun protectSocket(fd: Int): Boolean {
+ val service = com.ospab.ostp_client.OstpVpnService.instance
+ if (service != null) {
+ val res = service.protect(fd)
+ android.util.Log.i("OstpClientSdk", "VpnService.protect(socketFd=$fd) -> success=$res")
+ return res
+ }
+ android.util.Log.e("OstpClientSdk", "VpnService instance is null! Cannot protect socketFd=$fd")
+ return false
+ }
+
+ @Keep
+ @JvmStatic
+ external fun startClient(configJson: String, fd: Int, t2sBinPath: String, localProxy: String): Boolean
+
+ @Keep
+ @JvmStatic
+ external fun stopClient(): Boolean
+
+ @Keep
+ @JvmStatic
+ external fun getMetrics(): String
+
+ @Keep
+ @JvmStatic
+ external fun getLogs(): String
+
+ @Keep
+ @JvmStatic
+ external fun addLog(logMsg: String)
+}
diff --git a/ostp-flutter/android/app/src/main/res/drawable-v21/launch_background.xml b/ostp-flutter/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/ostp-flutter/android/app/src/main/res/drawable/launch_background.xml b/ostp-flutter/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/ostp-flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/ostp-flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/ostp-flutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/ostp-flutter/android/app/src/main/res/values-night/styles.xml b/ostp-flutter/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/ostp-flutter/android/app/src/main/res/values/styles.xml b/ostp-flutter/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/ostp-flutter/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/ostp-flutter/android/app/src/profile/AndroidManifest.xml b/ostp-flutter/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/ostp-flutter/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/ostp-flutter/android/build.gradle.kts b/ostp-flutter/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/ostp-flutter/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/ostp-flutter/android/gradle.properties b/ostp-flutter/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/ostp-flutter/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/ostp-flutter/android/gradle/wrapper/gradle-wrapper.properties b/ostp-flutter/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/ostp-flutter/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/ostp-flutter/android/settings.gradle.kts b/ostp-flutter/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /dev/null
+++ b/ostp-flutter/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/ostp-flutter/build.ps1 b/ostp-flutter/build.ps1
new file mode 100644
index 0000000..e8ab8fa
--- /dev/null
+++ b/ostp-flutter/build.ps1
@@ -0,0 +1,48 @@
+$ErrorActionPreference = "Stop"
+
+Write-Host "==============================================" -ForegroundColor Cyan
+Write-Host " OSTP Android App Release Build Pipeline " -ForegroundColor Cyan
+Write-Host "==============================================" -ForegroundColor Cyan
+
+# Step 1: Run JNI build script to compile Rust core and download tun2socks
+Write-Host ""
+Write-Host "[1/3] Compiling Rust JNI Core & Downloading tun2socks..." -ForegroundColor Yellow
+$jniScript = Join-Path $PSScriptRoot "build_android_jni.ps1"
+if (Test-Path $jniScript) {
+ & $jniScript
+} else {
+ Write-Error "Could not find build_android_jni.ps1 at $jniScript"
+ exit 1
+}
+
+# Step 2: Build Flutter APK in release mode
+Write-Host ""
+Write-Host "[2/3] Compiling Flutter Application in Release Mode..." -ForegroundColor Yellow
+Push-Location $PSScriptRoot
+try {
+ & flutter build apk --release --target-platform android-arm,android-arm64
+} catch {
+ Write-Host "[ERROR] Flutter build failed! Make sure Flutter SDK is installed and configured in your PATH." -ForegroundColor Red
+ Pop-Location
+ exit 1
+}
+Pop-Location
+
+# Step 3: Copy and rename the final release APK next to this script
+Write-Host ""
+Write-Host "[3/3] Copying and packaging release APK..." -ForegroundColor Yellow
+$apkPath = Join-Path $PSScriptRoot "build\app\outputs\flutter-apk\app-release.apk"
+$destPath = Join-Path $PSScriptRoot "ostp-client-release.apk"
+
+if (Test-Path $apkPath) {
+ Copy-Item -Path $apkPath -Destination $destPath -Force
+ Write-Host ""
+ Write-Host "==============================================" -ForegroundColor Green
+ Write-Host " SUCCESS! Build completed successfully! " -ForegroundColor Green
+ Write-Host " Release APK copied to: " -ForegroundColor Green
+ Write-Host " $destPath" -ForegroundColor White
+ Write-Host "==============================================" -ForegroundColor Green
+} else {
+ Write-Host "[ERROR] Release APK was not found at expected path: $apkPath" -ForegroundColor Red
+ exit 1
+}
diff --git a/ostp-flutter/build_android_jni.ps1 b/ostp-flutter/build_android_jni.ps1
new file mode 100644
index 0000000..dd5407a
--- /dev/null
+++ b/ostp-flutter/build_android_jni.ps1
@@ -0,0 +1,35 @@
+$ErrorActionPreference = "Stop"
+
+Write-Host "Building OSTP JNI for Android (arm64-v8a and armeabi-v7a)..."
+
+$jniLibs = "$PSScriptRoot\android\app\src\main\jniLibs"
+New-Item -ItemType Directory -Force -Path "$jniLibs\arm64-v8a" | Out-Null
+New-Item -ItemType Directory -Force -Path "$jniLibs\armeabi-v7a" | Out-Null
+
+Push-Location "$PSScriptRoot\..\ostp-jni"
+
+Write-Host "Compiling for aarch64-linux-android and armv7-linux-androideabi..."
+cargo ndk -t arm64-v8a -t armeabi-v7a -o "$jniLibs" build --release
+
+$tun2socksArm64 = "$jniLibs\arm64-v8a\libtun2socks.so"
+$tun2socksArmv7 = "$jniLibs\armeabi-v7a\libtun2socks.so"
+
+if (-not (Test-Path $tun2socksArm64)) {
+ Write-Host "Downloading tun2socks for arm64-v8a..."
+ Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-arm64.zip" -OutFile "$jniLibs\t2s64.zip"
+ Expand-Archive "$jniLibs\t2s64.zip" "$jniLibs\t2s64_tmp" -Force
+ Copy-Item "$jniLibs\t2s64_tmp\tun2socks-linux-arm64" $tun2socksArm64 -Force
+ Remove-Item "$jniLibs\t2s64.zip", "$jniLibs\t2s64_tmp" -Recurse -Force
+}
+
+if (-not (Test-Path $tun2socksArmv7)) {
+ Write-Host "Downloading tun2socks for armeabi-v7a..."
+ Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-linux-armv7.zip" -OutFile "$jniLibs\t2s32.zip"
+ Expand-Archive "$jniLibs\t2s32.zip" "$jniLibs\t2s32_tmp" -Force
+ Copy-Item "$jniLibs\t2s32_tmp\tun2socks-linux-armv7" $tun2socksArmv7 -Force
+ Remove-Item "$jniLibs\t2s32.zip", "$jniLibs\t2s32_tmp" -Recurse -Force
+}
+
+Pop-Location
+
+Write-Host "Done! The .so files have been copied to $jniLibs"
diff --git a/ostp-flutter/lib/main.dart b/ostp-flutter/lib/main.dart
new file mode 100644
index 0000000..59b6a4d
--- /dev/null
+++ b/ostp-flutter/lib/main.dart
@@ -0,0 +1,2077 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:ui';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:mobile_scanner/mobile_scanner.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ final prefs = await SharedPreferences.getInstance();
+ runApp(OstpApp(prefs: prefs));
+}
+
+class OstpApp extends StatelessWidget {
+ final SharedPreferences prefs;
+ const OstpApp({super.key, required this.prefs});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'OSTP Client',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ brightness: Brightness.dark,
+ scaffoldBackgroundColor: const Color(0xFF08080F),
+ colorScheme: const ColorScheme.dark(
+ primary: Color(0xFF6C72FF),
+ secondary: Color(0xFF22D3A5),
+ surface: Color(0xFF151522),
+ ),
+ fontFamily: 'Inter',
+ useMaterial3: true,
+ ),
+ home: HomeScreen(prefs: prefs),
+ );
+ }
+}
+
+class HomeScreen extends StatefulWidget {
+ final SharedPreferences prefs;
+ const HomeScreen({super.key, required this.prefs});
+
+ @override
+ State createState() => _HomeScreenState();
+}
+
+enum ConnectionStateEnum { disconnected, connecting, connected }
+
+class _HomeScreenState extends State with TickerProviderStateMixin {
+ static const platform = MethodChannel('com.ospab.ostp/vpn');
+
+ ConnectionStateEnum _state = ConnectionStateEnum.disconnected;
+ Timer? _pollTimer;
+ Timer? _uptimeTimer;
+ int _uptimeSecs = 0;
+
+ String _serverAddr = '127.0.0.1:443';
+ String _accessKey = 'default_key';
+
+ String _download = '0 B';
+ String _upload = '0 B';
+
+ late AnimationController _pulseController;
+ late AnimationController _spinController;
+
+ bool _isCheckingPing = false;
+ String _pingText = 'Target Ping: -- ms';
+ Color _pingColor = Colors.white54;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadSettings();
+ _pulseController = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 2),
+ );
+ _spinController = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 4),
+ );
+ _checkInitialState();
+ }
+
+ Future _checkInitialState() async {
+ try {
+ final isRunning = await platform.invokeMethod('isRunning');
+ if (isRunning == true && mounted) {
+ _setConnected();
+ }
+ } catch (e) {
+ debugPrint("Failed to check initial state: $e");
+ }
+ }
+
+ void _loadSettings() {
+ setState(() {
+ _serverAddr = widget.prefs.getString('server_addr') ?? '127.0.0.1:443';
+ _accessKey = widget.prefs.getString('access_key') ?? '';
+ });
+ _updateLatestConfigJson();
+ }
+
+ void _updateLatestConfigJson() {
+ final bool owndns = widget.prefs.getBool('owndns') ?? false;
+ final dnsServer = owndns ? '10.1.0.1' : (widget.prefs.getString('dns_server') ?? '1.1.1.1');
+ final exDomains = widget.prefs.getString('ex_domains') ?? '';
+ final exIps = widget.prefs.getString('ex_ips') ?? '';
+ final exProcesses = widget.prefs.getString('ex_processes') ?? '';
+ final debugMode = widget.prefs.getBool('debug_mode') ?? false;
+ final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
+ final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
+ final stealthPort = widget.prefs.getString('stealth_port') ?? '443';
+ final mtu = widget.prefs.getString('mtu') ?? '1350';
+ final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
+ final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
+ final tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
+
+ final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
+ final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
+
+ final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
+ final configMap = {
+ "mode": "client",
+ "debug": debugMode,
+ "ostp": {
+ "server_addr": _serverAddr,
+ "local_bind_addr": "0.0.0.0:0",
+ "access_key": _accessKey,
+ "handshake_timeout_ms": 10000,
+ "io_timeout_ms": 5000,
+ "mtu": int.tryParse(mtu) ?? 1350,
+ },
+ "local_proxy": {
+ "bind_addr": localBind,
+ "connect_timeout_ms": 15000,
+ },
+ "transport": {
+ "mode": transportMode,
+ "stealth_sni": stealthSni,
+ "stealth_port": int.tryParse(stealthPort) ?? 443,
+ },
+ "multiplex": {
+ "enabled": muxEnabled,
+ "sessions": int.tryParse(muxSessions) ?? 2,
+ },
+ "reality": {
+ "enabled": widget.prefs.getString('pbk')?.isNotEmpty ?? false,
+ "dest": "",
+ "private_key": "",
+ "pbk": widget.prefs.getString('pbk') ?? "",
+ "sid": widget.prefs.getString('sid') ?? "",
+ "sni_list": []
+ },
+ "tun": {
+ "enable": true,
+ "stack": tunStack
+ },
+ "exclusions": {
+ "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ },
+ "app_rules": {
+ "mode": appRoutingMode,
+ "packages": appRoutingPackages,
+ },
+ "dns_server": dnsServer,
+ "tun_stack": tunStack
+ };
+ widget.prefs.setString('latest_config_json', jsonEncode(configMap));
+ platform.invokeMethod('saveConfig', {
+ "configJson": jsonEncode(configMap)
+ });
+ }
+
+ @override
+ void dispose() {
+ _pollTimer?.cancel();
+ _uptimeTimer?.cancel();
+ _pulseController.dispose();
+ _spinController.dispose();
+ super.dispose();
+ }
+
+ Future _toggleConnection() async {
+ if (_state == ConnectionStateEnum.disconnected) {
+ if (_serverAddr.isEmpty || _accessKey.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Please configure Server and Key in Settings')),
+ );
+ return;
+ }
+
+ setState(() {
+ _state = ConnectionStateEnum.connecting;
+ });
+ _pulseController.repeat(reverse: true);
+ _spinController.repeat();
+
+ final bool owndns = widget.prefs.getBool('owndns') ?? false;
+ final dnsServer = owndns ? '10.1.0.1' : (widget.prefs.getString('dns_server') ?? '1.1.1.1');
+ final exDomains = widget.prefs.getString('ex_domains') ?? '';
+ final exIps = widget.prefs.getString('ex_ips') ?? '';
+ final exProcesses = widget.prefs.getString('ex_processes') ?? '';
+ final debugMode = widget.prefs.getBool('debug_mode') ?? false;
+ final transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
+ final stealthSni = widget.prefs.getString('stealth_sni') ?? 'vk.com';
+ final stealthPort = widget.prefs.getString('stealth_port') ?? '443';
+ final mtu = widget.prefs.getString('mtu') ?? '1350';
+ final muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
+ final muxSessions = widget.prefs.getString('mux_sessions') ?? '2';
+ final tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
+
+ final appRoutingMode = widget.prefs.getString('app_routing_mode') ?? 'bypass';
+ final appRoutingPackages = widget.prefs.getStringList('app_routing_packages') ?? [];
+
+ final localBind = widget.prefs.getString('local_bind') ?? '127.0.0.1:1088';
+ final configMap = {
+ "mode": "client",
+ "debug": debugMode,
+ "ostp": {
+ "server_addr": _serverAddr,
+ "local_bind_addr": "0.0.0.0:0",
+ "access_key": _accessKey,
+ "handshake_timeout_ms": 10000,
+ "io_timeout_ms": 5000,
+ "mtu": int.tryParse(mtu) ?? 1350,
+ },
+ "local_proxy": {
+ "bind_addr": localBind,
+ "connect_timeout_ms": 15000,
+ },
+ "transport": {
+ "mode": transportMode,
+ "stealth_sni": stealthSni,
+ "stealth_port": int.tryParse(stealthPort) ?? 443,
+ },
+ "multiplex": {
+ "enabled": muxEnabled,
+ "sessions": int.tryParse(muxSessions) ?? 2,
+ },
+ "reality": {
+ "enabled": widget.prefs.getString('pbk')?.isNotEmpty ?? false,
+ "dest": "",
+ "private_key": "",
+ "pbk": widget.prefs.getString('pbk') ?? "",
+ "sid": widget.prefs.getString('sid') ?? "",
+ "sni_list": []
+ },
+ "tun": {
+ "enable": true,
+ "stack": tunStack
+ },
+ "exclusions": {
+ "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(),
+ },
+ "app_rules": {
+ "mode": appRoutingMode,
+ "packages": appRoutingPackages,
+ },
+ "dns_server": dnsServer,
+ "tun_stack": tunStack
+ };
+
+ widget.prefs.setString('latest_config_json', jsonEncode(configMap));
+
+
+ try {
+ await platform.invokeMethod('saveConfig', {
+ "configJson": jsonEncode(configMap)
+ });
+ await platform.invokeMethod('startTunnel', {
+ "configJson": jsonEncode(configMap)
+ });
+
+ bool started = false;
+ for (int i = 0; i < 10; i++) {
+ await Future.delayed(const Duration(milliseconds: 500));
+ final isRunning = await platform.invokeMethod('isRunning');
+ if (isRunning == true) {
+ started = true;
+ break;
+ }
+ }
+
+ if (started) {
+ _setConnected();
+ } else {
+ _setDisconnected();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Failed to connect. Check logs for details.')),
+ );
+ }
+ }
+ } catch (e, stackTrace) {
+ debugPrint("Failed to start tunnel: $e\n$stackTrace");
+ _setDisconnected();
+ if (mounted) {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Error', style: TextStyle(color: Colors.redAccent)),
+ content: SingleChildScrollView(
+ child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: e.toString()));
+ ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!')));
+ },
+ child: const Text('Copy'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ );
+ }
+ }
+ } else {
+ try {
+ await platform.invokeMethod('stopTunnel');
+ } catch (e) {
+ debugPrint("Stop error: $e");
+ }
+ _setDisconnected();
+ }
+ }
+
+ void _setConnected() {
+ if (!mounted) return;
+ setState(() {
+ _state = ConnectionStateEnum.connected;
+ });
+ _pulseController.stop();
+ _pulseController.value = 1.0;
+
+ _uptimeSecs = 0;
+ _uptimeTimer?.cancel();
+ _uptimeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
+ if (!mounted) return;
+ setState(() => _uptimeSecs++);
+ });
+
+ _startPollingMetrics();
+ }
+
+ void _startPollingMetrics() {
+ _pollTimer?.cancel();
+ _pollTimer = Timer.periodic(const Duration(seconds: 1), (timer) async {
+ if (!mounted) return;
+ try {
+ final metricsJson = await platform.invokeMethod('getMetrics');
+ if (metricsJson != null && metricsJson.isNotEmpty) {
+ final Map parsed = jsonDecode(metricsJson);
+ final bytesSent = parsed['bytes_sent'] as int? ?? 0;
+ final bytesRecv = parsed['bytes_recv'] as int? ?? 0;
+ final connState = parsed['connection_state'] as int? ?? 2;
+ final rttMs = parsed['rtt_ms'] as int? ?? 0;
+
+ if (connState == 0 && _state != ConnectionStateEnum.disconnected) {
+ try {
+ await platform.invokeMethod('stopTunnel');
+ } catch (e) {
+ debugPrint("Failed to stop background tunnel: $e");
+ }
+ _setDisconnected();
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Connection failed. Check logs for details.')),
+ );
+ }
+ return;
+ }
+
+ if (mounted) {
+ setState(() {
+ _download = _formatBytes(bytesRecv);
+ _upload = _formatBytes(bytesSent);
+ if (rttMs > 0 && !_isCheckingPing) {
+ _pingText = 'Server Ping: $rttMs ms';
+ if (rttMs < 100) {
+ _pingColor = const Color(0xFF22D3A5);
+ } else if (rttMs < 250) {
+ _pingColor = Colors.amberAccent;
+ } else {
+ _pingColor = Colors.redAccent;
+ }
+ }
+ });
+ }
+ }
+ } catch (e) {
+ debugPrint("Failed to get metrics: $e");
+ }
+ });
+ }
+
+ String _formatBytes(int bytes) {
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+ return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
+ }
+
+ Future _checkConnectionLatency() async {
+ if (_state != ConnectionStateEnum.connected) return;
+
+ setState(() {
+ _isCheckingPing = true;
+ _pingText = 'Updating...';
+ _pingColor = Colors.white70;
+ });
+
+ await Future.delayed(const Duration(milliseconds: 500));
+
+ if (mounted) {
+ setState(() {
+ _isCheckingPing = false;
+ });
+ }
+ }
+
+ void _setDisconnected() {
+ if (!mounted) return;
+ setState(() {
+ _state = ConnectionStateEnum.disconnected;
+ _download = '0 B';
+ _upload = '0 B';
+ _pingText = 'Target Ping: -- ms';
+ _pingColor = Colors.white54;
+ _isCheckingPing = false;
+ });
+ _pulseController.stop();
+ _pulseController.value = 0.0;
+ _spinController.stop();
+ _uptimeTimer?.cancel();
+ _pollTimer?.cancel();
+ }
+
+ String _formatTime(int s) {
+ final h = s ~/ 3600;
+ final m = (s % 3600) ~/ 60;
+ final sec = s % 60;
+ final pad = (int n) => n.toString().padLeft(2, '0');
+ return h > 0 ? '$h:${pad(m)}:${pad(sec)}' : '${pad(m)}:${pad(sec)}';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Scaffold(
+ body: Stack(
+ children: [
+ Positioned(
+ top: -150, right: -100,
+ child: Container(
+ width: 400, height: 400,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: theme.colorScheme.primary.withOpacity(0.15),
+ ),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
+ child: Container(),
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: -100, left: -100,
+ child: Container(
+ width: 350, height: 350,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: theme.colorScheme.secondary.withOpacity(0.1),
+ ),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
+ child: Container(),
+ ),
+ ),
+ ),
+
+ SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ return SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: constraints.maxHeight),
+ child: IntrinsicHeight(
+ child: Column(
+ children: [
+ _buildTopBar(theme),
+ Expanded(child: _buildStage(theme)),
+ _buildMetricsBar(theme),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildTopBar(ThemeData theme) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ AnimatedContainer(
+ duration: const Duration(milliseconds: 300),
+ width: 12, height: 12,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(4),
+ color: _state == ConnectionStateEnum.connected
+ ? theme.colorScheme.secondary
+ : theme.colorScheme.primary,
+ boxShadow: [
+ BoxShadow(
+ color: _state == ConnectionStateEnum.connected
+ ? theme.colorScheme.secondary.withOpacity(0.5)
+ : theme.colorScheme.primary.withOpacity(0.5),
+ blurRadius: 10,
+ )
+ ]
+ ),
+ ),
+ const SizedBox(width: 12),
+ const Text(
+ 'OSTP',
+ style: TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ letterSpacing: 2.5,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ IconButton(
+ iconSize: 30,
+ icon: const Icon(Icons.settings_outlined, color: Colors.white),
+ onPressed: () async {
+ await Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => SettingsScreen(prefs: widget.prefs)),
+ );
+ _loadSettings();
+ },
+ )
+ ],
+ ),
+ );
+ }
+
+ Widget _buildStage(ThemeData theme) {
+ Color getAccentColor() {
+ if (_state == ConnectionStateEnum.connected) return theme.colorScheme.secondary;
+ return theme.colorScheme.primary;
+ }
+
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SizedBox(
+ width: 260, height: 260,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ if (_state != ConnectionStateEnum.disconnected)
+ RotationTransition(
+ turns: _spinController,
+ child: Container(
+ width: 240, height: 240,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: getAccentColor().withOpacity(0.25),
+ width: 2.0,
+ ),
+ ),
+ ),
+ ),
+ if (_state != ConnectionStateEnum.disconnected)
+ RotationTransition(
+ turns: ReverseAnimation(_spinController),
+ child: Container(
+ width: 200, height: 200,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: getAccentColor().withOpacity(0.15),
+ width: 1.5,
+ ),
+ ),
+ ),
+ ),
+
+ AnimatedBuilder(
+ animation: _pulseController,
+ builder: (context, child) {
+ return Container(
+ width: 140, height: 140,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: theme.colorScheme.surface,
+ border: Border.all(
+ color: _state == ConnectionStateEnum.disconnected
+ ? Colors.white.withOpacity(0.15)
+ : getAccentColor(),
+ width: 3,
+ ),
+ boxShadow: [
+ if (_state != ConnectionStateEnum.disconnected)
+ BoxShadow(
+ color: getAccentColor().withOpacity(0.4 * (_state == ConnectionStateEnum.connected ? 1.0 : _pulseController.value)),
+ blurRadius: 40,
+ spreadRadius: 8,
+ )
+ ]
+ ),
+ child: child,
+ );
+ },
+ child: Material(
+ color: Colors.transparent,
+ child: InkWell(
+ customBorder: const CircleBorder(),
+ onTap: _toggleConnection,
+ child: Icon(
+ Icons.power_settings_new_rounded,
+ size: 60,
+ color: _state == ConnectionStateEnum.disconnected
+ ? Colors.white54
+ : getAccentColor(),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 40),
+
+ Text(
+ _state == ConnectionStateEnum.disconnected ? 'Disconnected' :
+ _state == ConnectionStateEnum.connecting ? 'Connecting...' : 'Connected',
+ style: TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.w700,
+ color: _state == ConnectionStateEnum.disconnected ? Colors.white70 : getAccentColor(),
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ _state == ConnectionStateEnum.connected ? _formatTime(_uptimeSecs) : 'Tap to protect your traffic',
+ style: const TextStyle(
+ fontSize: 16,
+ color: Colors.white54,
+ ),
+ ),
+
+ const SizedBox(height: 30),
+
+ AnimatedOpacity(
+ opacity: _state == ConnectionStateEnum.connected ? 1.0 : 0.0,
+ duration: const Duration(milliseconds: 300),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.08),
+ borderRadius: BorderRadius.circular(30),
+ border: Border.all(color: Colors.white.withOpacity(0.15)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.dns_rounded, size: 18, color: Colors.white70),
+ const SizedBox(width: 10),
+ Text(
+ _serverAddr,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: Colors.white70,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 16),
+ Container(
+ margin: const EdgeInsets.symmetric(horizontal: 32),
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.03),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: Colors.white.withOpacity(0.06)),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text(
+ 'CONNECTION TEST',
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.bold,
+ color: Colors.white38,
+ letterSpacing: 0.8,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ _pingText,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.bold,
+ color: _pingColor,
+ ),
+ ),
+ ],
+ ),
+ _isCheckingPing
+ ? const SizedBox(
+ width: 20, height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white70),
+ )
+ : TextButton.icon(
+ onPressed: _checkConnectionLatency,
+ icon: Icon(Icons.speed_rounded, size: 16, color: theme.colorScheme.primary),
+ label: Text(
+ 'Test Ping',
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 13,
+ color: theme.colorScheme.primary,
+ ),
+ ),
+ style: TextButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ backgroundColor: theme.colorScheme.primary.withOpacity(0.1),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ )
+ ],
+ );
+ }
+
+ Widget _buildMetricsBar(ThemeData theme) {
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.04),
+ border: Border(top: BorderSide(color: Colors.white.withOpacity(0.08))),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ _buildMetricItem(Icons.arrow_downward_rounded, 'Download', _download, theme.colorScheme.secondary),
+ Container(width: 1, height: 40, color: Colors.white.withOpacity(0.15)),
+ _buildMetricItem(Icons.arrow_upward_rounded, 'Upload', _upload, theme.colorScheme.primary),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildMetricItem(IconData icon, String label, String value, Color color) {
+ return Row(
+ children: [
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: color.withOpacity(0.15),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(icon, size: 20, color: color),
+ ),
+ const SizedBox(width: 12),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label.toUpperCase(),
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Colors.white54,
+ letterSpacing: 0.8,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ value,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ )
+ ],
+ );
+ }
+}
+
+class SettingsScreen extends StatefulWidget {
+ final SharedPreferences prefs;
+ const SettingsScreen({super.key, required this.prefs});
+
+ @override
+ State createState() => _SettingsScreenState();
+}
+
+class _SettingsScreenState extends State {
+ late TextEditingController _importCtrl;
+ late TextEditingController _serverCtrl;
+ late TextEditingController _localBindCtrl;
+ late TextEditingController _keyCtrl;
+ late TextEditingController _dnsCtrl;
+ late TextEditingController _mtuCtrl;
+ late TextEditingController _domainsCtrl;
+ late TextEditingController _ipsCtrl;
+ late TextEditingController _processesCtrl;
+ late TextEditingController _stealthSniCtrl;
+ late TextEditingController _stealthPortCtrl;
+ late TextEditingController _pbkCtrl;
+ late TextEditingController _sidCtrl;
+
+ bool _obscureKey = true;
+ bool _debugMode = false;
+ String _transportMode = 'udp'; // 'udp' | 'wss'
+ String _tunStack = 'ostp'; // 'system' | 'ostp'
+ bool _muxEnabled = false;
+ late TextEditingController _muxSessionsCtrl;
+ bool _owndns = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _importCtrl = TextEditingController();
+ _serverCtrl = TextEditingController(text: widget.prefs.getString('server_addr') ?? '127.0.0.1:443');
+ _localBindCtrl = TextEditingController(text: widget.prefs.getString('local_bind') ?? '127.0.0.1:1088');
+ _keyCtrl = TextEditingController(text: widget.prefs.getString('access_key') ?? '');
+ _dnsCtrl = TextEditingController(text: widget.prefs.getString('dns_server') ?? '1.1.1.1');
+ _mtuCtrl = TextEditingController(text: widget.prefs.getString('mtu') ?? '1350');
+ _domainsCtrl = TextEditingController(text: widget.prefs.getString('ex_domains') ?? '');
+ _ipsCtrl = TextEditingController(text: widget.prefs.getString('ex_ips') ?? '');
+ _processesCtrl = TextEditingController(text: widget.prefs.getString('ex_processes') ?? '');
+ _stealthSniCtrl = TextEditingController(text: widget.prefs.getString('stealth_sni') ?? '');
+ _stealthPortCtrl = TextEditingController(text: widget.prefs.getString('stealth_port') ?? '443');
+ _pbkCtrl = TextEditingController(text: widget.prefs.getString('pbk') ?? '');
+ _sidCtrl = TextEditingController(text: widget.prefs.getString('sid') ?? '');
+ _transportMode = widget.prefs.getString('transport_mode') ?? 'udp';
+ _tunStack = widget.prefs.getString('tun_stack') ?? 'ostp';
+ _debugMode = widget.prefs.getBool('debug_mode') ?? false;
+ _muxEnabled = widget.prefs.getBool('mux_enabled') ?? false;
+ _muxSessionsCtrl = TextEditingController(text: widget.prefs.getString('mux_sessions') ?? '2');
+ _owndns = widget.prefs.getBool('owndns') ?? false;
+ }
+
+ @override
+ void dispose() {
+ _saveSettings();
+ _importCtrl.dispose();
+ _serverCtrl.dispose();
+ _localBindCtrl.dispose();
+ _keyCtrl.dispose();
+ _dnsCtrl.dispose();
+ _mtuCtrl.dispose();
+ _domainsCtrl.dispose();
+ _ipsCtrl.dispose();
+ _processesCtrl.dispose();
+ _stealthSniCtrl.dispose();
+ _stealthPortCtrl.dispose();
+ _pbkCtrl.dispose();
+ _sidCtrl.dispose();
+ _muxSessionsCtrl.dispose();
+ super.dispose();
+ }
+
+ void _saveSettings() {
+ widget.prefs.setString('server_addr', _serverCtrl.text.trim());
+ widget.prefs.setString('local_bind', _localBindCtrl.text.trim());
+ widget.prefs.setString('access_key', _keyCtrl.text.trim());
+ widget.prefs.setString('dns_server', _dnsCtrl.text.trim());
+ widget.prefs.setString('mtu', _mtuCtrl.text.trim());
+ widget.prefs.setString('ex_domains', _domainsCtrl.text.trim());
+ widget.prefs.setString('ex_ips', _ipsCtrl.text.trim());
+ widget.prefs.setString('ex_processes', _processesCtrl.text.trim());
+ widget.prefs.setBool('debug_mode', _debugMode);
+ widget.prefs.setString('transport_mode', _transportMode);
+ widget.prefs.setString('tun_stack', _tunStack);
+ widget.prefs.setString('stealth_sni', _stealthSniCtrl.text.trim());
+ widget.prefs.setString('stealth_port', _stealthPortCtrl.text.trim());
+ widget.prefs.setString('pbk', _pbkCtrl.text.trim());
+ widget.prefs.setString('sid', _sidCtrl.text.trim());
+ widget.prefs.setBool('mux_enabled', _muxEnabled);
+ widget.prefs.setString('mux_sessions', _muxSessionsCtrl.text.trim());
+ widget.prefs.setBool('owndns', _owndns);
+ }
+
+ Widget _buildTextField(String label, TextEditingController controller, {String? hint, bool isPassword = false, int maxLines = 1, bool isMono = false}) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
+ const SizedBox(height: 10),
+ TextField(
+ controller: controller,
+ obscureText: isPassword && _obscureKey,
+ maxLines: maxLines,
+ style: TextStyle(fontSize: 16, fontFamily: isMono ? 'monospace' : 'Inter'),
+ decoration: InputDecoration(
+ hintText: hint,
+ hintStyle: const TextStyle(color: Colors.white30),
+ filled: true,
+ fillColor: Theme.of(context).colorScheme.surface,
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+ suffixIcon: isPassword ? IconButton(
+ icon: Icon(_obscureKey ? Icons.visibility : Icons.visibility_off, color: Colors.white54),
+ onPressed: () => setState(() => _obscureKey = !_obscureKey),
+ ) : null,
+ ),
+ ),
+ const SizedBox(height: 24),
+ ],
+ );
+ }
+
+ Widget _buildToggle(String title, String subtitle, bool value, ValueChanged onChanged) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 24),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 4),
+ Text(subtitle, style: const TextStyle(fontSize: 13, color: Colors.white54)),
+ ],
+ ),
+ ),
+ Switch(
+ value: value,
+ onChanged: (v) {
+ onChanged(v);
+ _saveSettings();
+ },
+ activeColor: Theme.of(context).colorScheme.secondary,
+ activeTrackColor: Theme.of(context).colorScheme.secondary.withOpacity(0.3),
+ inactiveTrackColor: Colors.white10,
+ )
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Configuration', style: TextStyle(fontWeight: FontWeight.bold)),
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back_rounded),
+ onPressed: () => Navigator.pop(context),
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.qr_code_scanner_rounded),
+ onPressed: () async {
+ final result = await Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => const QRScannerScreen()),
+ );
+ if (result != null && result is String && result.startsWith('ostp://')) {
+ setState(() {
+ _importCtrl.text = result;
+ });
+ }
+ },
+ )
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
+ children: [
+ // Quick Import Row
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _importCtrl,
+ decoration: InputDecoration(
+ hintText: 'Paste ostp:// share link...',
+ hintStyle: const TextStyle(color: Colors.white30, fontSize: 14),
+ filled: true,
+ fillColor: Colors.white.withOpacity(0.05),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ ElevatedButton(
+ onPressed: () {
+ final raw = _importCtrl.text.trim();
+ if (raw.isEmpty) return;
+ try {
+ if (!raw.startsWith('ostp://')) {
+ throw Exception('Link must start with ostp://');
+ }
+ final uri = Uri.parse(raw);
+ final key = Uri.decodeComponent(uri.userInfo);
+ final host = uri.authority.replaceFirst(uri.userInfo + '@', '');
+ if (key.isEmpty || host.isEmpty) {
+ throw Exception('Incomplete link parameters');
+ }
+ setState(() {
+ _serverCtrl.text = host;
+ _keyCtrl.text = key;
+ _stealthSniCtrl.text = uri.queryParameters['sni'] ?? '';
+ _pbkCtrl.text = uri.queryParameters['pbk'] ?? '';
+ _sidCtrl.text = uri.queryParameters['sid'] ?? '';
+ final type = uri.queryParameters['type'] ?? 'udp';
+ _transportMode = type == 'tcp' || type == 'http' ? 'uot' : 'udp';
+ _owndns = uri.queryParameters['owndns'] == 'true';
+ _importCtrl.clear();
+ _saveSettings();
+ });
+ ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Imported successfully')));
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
+ }
+ },
+ style: ElevatedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
+ backgroundColor: Theme.of(context).colorScheme.primary,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
+ ),
+ child: const Text('Import', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white)),
+ )
+ ],
+ ),
+
+ const SizedBox(height: 30),
+
+ Container(
+ padding: const EdgeInsets.all(24),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.02),
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(color: Colors.white.withOpacity(0.05)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildTextField('Server Address', _serverCtrl, hint: 'host:port'),
+ _buildTextField('Local Proxy Bind', _localBindCtrl, hint: '127.0.0.1:1088'),
+ _buildTextField('Access Key', _keyCtrl, hint: 'Secure access key', isPassword: true),
+ if (!_owndns) ...[
+ _buildTextField('DNS Server', _dnsCtrl, hint: '1.1.1.1'),
+ ],
+ _buildTextField('MTU (Packet Size)', _mtuCtrl, hint: '1350 (decrease if connection drops)'),
+
+ // ── Transport Mode ───────────────────────────────────────
+ const Text('Transport Mode', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
+ const SizedBox(height: 10),
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ children: [
+ RadioListTile(
+ value: 'udp',
+ groupValue: _transportMode,
+ title: const Text('UDP (по умолчанию)', style: TextStyle(fontWeight: FontWeight.w600)),
+ subtitle: const Text('Быстро, работает через Wi-Fi и большинство сетей', style: TextStyle(color: Colors.white54, fontSize: 12)),
+ activeColor: Theme.of(context).colorScheme.secondary,
+ onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
+ ),
+ Divider(color: Colors.white.withOpacity(0.05), height: 1),
+ RadioListTile(
+ value: 'uot',
+ groupValue: _transportMode,
+ title: Wrap(
+ crossAxisAlignment: WrapCrossAlignment.center,
+ spacing: 8,
+ children: [
+ const Text('UoT (UDP-over-TCP)', style: TextStyle(fontWeight: FontWeight.w600)),
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
+ decoration: BoxDecoration(
+ color: const Color(0xFF6C72FF).withOpacity(0.2),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ child: const Text('xHTTP Стелс', style: TextStyle(fontSize: 10, color: Color(0xFF6C72FF), fontWeight: FontWeight.bold)),
+ ),
+ ],
+ ),
+ subtitle: const Text('Маскировка под HTTP-поток, обходит белые списки (уровень 1)', style: TextStyle(color: Colors.white54, fontSize: 12)),
+ activeColor: Theme.of(context).colorScheme.primary,
+ onChanged: (v) => setState(() { _transportMode = v!; _saveSettings(); }),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+
+ // Stealth parameters
+ AnimatedCrossFade(
+ duration: const Duration(milliseconds: 250),
+ crossFadeState: _transportMode == 'uot' ? CrossFadeState.showFirst : CrossFadeState.showSecond,
+ firstChild: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: const Color(0xFF6C72FF).withOpacity(0.06),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFF6C72FF).withOpacity(0.2)),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.security, size: 16, color: Color(0xFF6C72FF)),
+ const SizedBox(width: 8),
+ const Text('Стелс параметры', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF6C72FF), fontSize: 14)),
+ ],
+ ),
+ const SizedBox(height: 4),
+ const Text(
+ 'Укажи домен из белого списка. OSTP подключится к серверу и подделает SNI / HTTP Host.',
+ style: TextStyle(fontSize: 12, color: Colors.white38),
+ ),
+ const SizedBox(height: 16),
+ Builder(builder: (context) {
+ final List domains = [
+ 'yastatic.net', 'mc.yandex.ru', 'st.mycdn.me',
+ 'top-fwz1.mail.ru', 'sso.passport.yandex.ru',
+ 'sberbank.ru', 'ad.mail.ru', 'ads.vk.com',
+ 'login.vk.com', 'api.sberbank.ru', 'ok.ru',
+ 'rostelecom.ru', 'rt.ru', 'tinkoff.ru',
+ 'x5.ru', 'ozon.ru', 'wildberries.ru', 'gosuslugi.ru', 'vk.com'
+ ];
+ String currentVal = _stealthSniCtrl.text.trim();
+ if (currentVal.isEmpty) currentVal = 'vk.com';
+ if (!domains.contains(currentVal)) {
+ domains.add(currentVal);
+ }
+ return DropdownButtonFormField(
+ value: currentVal,
+ dropdownColor: const Color(0xFF1E1E2C),
+ style: const TextStyle(color: Colors.white, fontSize: 14),
+ decoration: InputDecoration(
+ labelText: 'Стелс Домен (Автоподставление)',
+ labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ ),
+ items: domains.map((String domain) {
+ return DropdownMenuItem(
+ value: domain,
+ child: Text(domain),
+ );
+ }).toList(),
+ onChanged: (String? newValue) {
+ if (newValue != null) {
+ setState(() {
+ _stealthSniCtrl.text = newValue;
+ _stealthPortCtrl.text = '443';
+ _saveSettings();
+ });
+ }
+ },
+ );
+ }),
+ const SizedBox(height: 16),
+ _buildTextField('Reality PublicKey (pbk)', _pbkCtrl, hint: 'Оставьте пустым для отключения Reality'),
+ _buildTextField('Reality ShortId (sid)', _sidCtrl, hint: 'Опционально (необязательно)'),
+ ],
+ ),
+ ),
+ secondChild: const SizedBox.shrink(),
+ ),
+
+ const SizedBox(height: 16),
+ const Text('TUN Stack (Desktop only)', style: TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.bold, letterSpacing: 1.0)),
+ const SizedBox(height: 10),
+ Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Column(
+ children: [
+ RadioListTile(
+ value: 'system',
+ groupValue: _tunStack,
+ title: const Text('System (tun2socks)', style: TextStyle(fontWeight: FontWeight.w600)),
+ activeColor: Theme.of(context).colorScheme.secondary,
+ onChanged: (v) => setState(() { _tunStack = v!; _saveSettings(); }),
+ ),
+ Divider(color: Colors.white.withOpacity(0.05), height: 1),
+ RadioListTile(
+ value: 'ostp',
+ groupValue: _tunStack,
+ title: const Text('OSTP (Native)', style: TextStyle(fontWeight: FontWeight.w600)),
+ activeColor: Theme.of(context).colorScheme.primary,
+ onChanged: (v) => setState(() { _tunStack = v!; _saveSettings(); }),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 16),
+ _buildToggle('Multiplexing (Mux)', 'Combine multiple TCP streams to bypass throttling', _muxEnabled, (v) => setState(() => _muxEnabled = v)),
+ AnimatedCrossFade(
+ duration: const Duration(milliseconds: 200),
+ crossFadeState: _muxEnabled ? CrossFadeState.showFirst : CrossFadeState.showSecond,
+ firstChild: Padding(
+ padding: const EdgeInsets.only(top: 12.0),
+ child: _buildTextField('Mux Sessions', _muxSessionsCtrl, hint: '4'),
+ ),
+ secondChild: const SizedBox.shrink(),
+ ),
+
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(child: _buildToggle('Debug Logs', 'Verbose output', _debugMode, (v) => setState(() => _debugMode = v))),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 24.0, left: 10),
+ child: IconButton(
+ icon: const Icon(Icons.receipt_long_rounded),
+ color: Theme.of(context).colorScheme.primary,
+ tooltip: 'View Logs',
+ onPressed: () {
+ Navigator.push(context, MaterialPageRoute(builder: (context) => const LogsScreen()));
+ },
+ ),
+ ),
+ ],
+ ),
+
+
+ const Padding(
+ padding: EdgeInsets.symmetric(vertical: 16),
+ child: Row(
+ children: [
+ Text('Exclusions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+ SizedBox(width: 10),
+ Text('one per line', style: TextStyle(fontSize: 13, color: Colors.white30)),
+ ],
+ ),
+ ),
+
+ _buildTextField('Bypass Domains', _domainsCtrl, hint: 'example.com\n*.google.com', maxLines: 3, isMono: true),
+ _buildTextField('Bypass IPs / CIDR', _ipsCtrl, hint: '192.168.1.0/24\n10.0.0.1', maxLines: 3, isMono: true),
+
+ // Premium app routing trigger button
+ InkWell(
+ onTap: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (context) => AppRoutingScreen(prefs: widget.prefs)),
+ );
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primary.withOpacity(0.08),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)),
+ ),
+ child: Row(
+ children: [
+ Icon(Icons.apps_rounded, color: Theme.of(context).colorScheme.primary, size: 24),
+ const SizedBox(width: 16),
+ const Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Per-App Connection Rules',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
+ ),
+ SizedBox(height: 4),
+ Text(
+ 'Choose which apps bypass or use VPN',
+ style: TextStyle(fontSize: 13, color: Colors.white54),
+ ),
+ ],
+ ),
+ ),
+ const Icon(Icons.arrow_forward_ios_rounded, color: Colors.white54, size: 16),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 10),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 40),
+ ],
+ ),
+ );
+ }
+}
+
+class LogsScreen extends StatefulWidget {
+ const LogsScreen({super.key});
+
+ @override
+ State createState() => _LogsScreenState();
+}
+
+class _LogsScreenState extends State {
+ static const platform = MethodChannel('com.ospab.ostp/vpn');
+ Timer? _pollTimer;
+ final List _logs = [];
+ final ScrollController _scrollCtrl = ScrollController();
+
+ @override
+ void initState() {
+ super.initState();
+ _fetchLogs();
+ _pollTimer = Timer.periodic(const Duration(seconds: 1), (_) => _fetchLogs());
+ }
+
+ @override
+ void dispose() {
+ _pollTimer?.cancel();
+ _scrollCtrl.dispose();
+ super.dispose();
+ }
+
+ Future _fetchLogs() async {
+ try {
+ final String logsJson = await platform.invokeMethod('getLogs');
+ if (logsJson.isNotEmpty && logsJson != "[]") {
+ final List parsed = jsonDecode(logsJson);
+ if (parsed.isNotEmpty) {
+ setState(() {
+ _logs.addAll(parsed.map((e) => e.toString()));
+ });
+ Future.delayed(const Duration(milliseconds: 100), () {
+ if (_scrollCtrl.hasClients) {
+ _scrollCtrl.animateTo(_scrollCtrl.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
+ }
+ });
+ }
+ }
+ } catch (e, stackTrace) {
+ debugPrint("Failed to fetch logs: $e\n$stackTrace");
+ if (mounted) {
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Logs Error', style: TextStyle(color: Colors.redAccent)),
+ content: SingleChildScrollView(
+ child: SelectableText(e.toString(), style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Clipboard.setData(ClipboardData(text: e.toString()));
+ ScaffoldMessenger.of(ctx).showSnackBar(const SnackBar(content: Text('Copied!')));
+ },
+ child: const Text('Copy'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ );
+ }
+ }
+ }
+
+ Future _clearLogs() async {
+ await platform.invokeMethod('clearLogs');
+ setState(() {
+ _logs.clear();
+ });
+ }
+
+ Future _copyLogs() async {
+ final text = _logs.join('\n');
+ await Clipboard.setData(ClipboardData(text: text));
+ if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Logs copied to clipboard')));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('System Logs', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ elevation: 0,
+ actions: [
+ IconButton(icon: const Icon(Icons.delete_outline), onPressed: _clearLogs, tooltip: 'Clear'),
+ IconButton(icon: const Icon(Icons.copy_rounded), onPressed: _copyLogs, tooltip: 'Copy All'),
+ ],
+ ),
+ body: Container(
+ color: Colors.black,
+ padding: const EdgeInsets.all(12),
+ child: ListView.builder(
+ controller: _scrollCtrl,
+ itemCount: _logs.length,
+ itemBuilder: (context, index) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 2.0),
+ child: Text(
+ _logs[index],
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ color: Colors.greenAccent,
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+class AppRoutingScreen extends StatefulWidget {
+ final SharedPreferences prefs;
+ const AppRoutingScreen({super.key, required this.prefs});
+
+ @override
+ State createState() => _AppRoutingScreenState();
+}
+
+class _AppRoutingScreenState extends State {
+ static const platform = MethodChannel('com.ospab.ostp/vpn');
+
+ List