Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ migrate_working_dir/
**/build/
**/bin/
**/obj/
/plugins/**/pubspec.lock

# Local release artifacts belong in GitHub Releases, not git history
/release/
Expand Down Expand Up @@ -85,6 +86,8 @@ config.json
profiles.json

# Local diagnostics and one-off inspection scripts
/.agents/
/.codex/
/.tmp_xray_jar_dump/
/temp_fetch.js
/temp_links_view.ps1
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
# Changelog

## 1.0.114 - 2026-07-15

- Expanded the Russian and English in-app FAQ with protocol guidance plus
practical Smart Route and Auto DNS setup, verification, and fallback steps.
- Added a rolling native watchdog detector that restarts the VPN runtime after
four degraded endpoint quorums in five minutes while ignoring isolated
timeouts and resetting its history after a real Android network change.
- Persisted the active profile label across native background recovery so the
foreground notification no longer falls back to an empty profile name.
- Completed a 12-hour single-profile LTE baseline with 720/720 healthy samples,
no process restarts, crashes, ANRs, duplicate TUN interfaces, or memory leak;
extended observation then reproduced the long-session TLS degradation that
this release recovers from earlier.

## 1.0.113 - 2026-07-14

- Added an Xray UID traffic sampler so VLESS XHTTP sessions report live upload,
download, and session totals without depending on the sing-box status channel;
stopping the tunnel now clears stale speeds while preserving the final total.

## 1.0.112 - 2026-07-14

- Restarted the isolated VPN process cleanly on every runtime-config switch,
preventing stale TUN descriptors and native crashes between sing-box and Xray.
- Fixed XHTTP startup with compact Xray configs and Android-compatible uTLS
fingerprint selection while preserving explicit non-Chrome fingerprints.
- Kept the foreground notification synchronized with the verified native tunnel
state after watchdog recovery and coalesced rapid Flutter status updates.
- Persisted watchdog restart cooldown across process recovery and added a short
startup grace so Hysteria2 can warm up without an unnecessary early restart.
- Completed a 12-hour LTE soak across VLESS Reality, NaiveProxy, Hysteria2, and
XHTTP with 720 samples, no app crashes or ANRs, and no duplicate TUN interfaces.

## 1.0.111 - 2026-07-12

- Delayed native `Started` status until DNS and HTTPS succeed through the
selected tunnel on at least two independent external endpoints.
- Marked an active session as reconnecting while Android changes the default
Wi-Fi or cellular network, even if the VPN NetworkAgent remains validated.
- Added bounded readiness retries, controlled runtime restart, and background
retry after restart cooldown instead of reporting a false connection.
- Made VPN session state the single owner of native status updates and added
TLS hostname verification to native health probes.

## 1.0.110 - 2026-07-12

- Isolated the encrypted VPN runtime configuration from cross-process service
flags so Android cannot restore an older profile after a process restart.
- Added one-time migration for existing encrypted runtime configurations.
- Reconciled a restored VLESS tunnel with the profile selected in Flutter and
restarted it once when the native runtime still contains another profile.
- Added regression coverage for semantic runtime-config comparison.

## 1.0.109 - 2026-07-11

- Synchronized explicit start, stop, and restart actions between the Android
Expand Down
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ android {
versionCode = flutter.versionCode + 10000
buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"play\"")
}
create("soak") {
dimension = "distribution"
buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"soak\"")
}
}

signingConfigs {
Expand Down
30 changes: 30 additions & 0 deletions android/app/src/soak/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".qa.SoakControlActivity"
android:enabled="true"
android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@android:style/Theme.NoDisplay" />

<service
android:name=".qa.SoakControlService"
android:enabled="true"
android:exported="true"
android:permission="android.permission.DUMP"
android:stopWithTask="false" />

<receiver
android:name=".qa.SoakControlReceiver"
android:enabled="true"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="online.dnsai.ivanvpn.action.SOAK_CONTROL" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package online.dnsai.ivanvpn.qa

import android.app.Activity
import android.os.Bundle
import android.util.Log

class SoakControlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
SoakControlCommandHandler.handle(applicationContext, intent)
} catch (error: Throwable) {
Log.e(TAG, "QA command failed: ${error.message}", error)
} finally {
finish()
}
}

companion object {
private const val TAG = "SoakControlActivity"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package online.dnsai.ivanvpn.qa

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import com.tecclub.flutter_singbox.Application
import com.tecclub.flutter_singbox.bg.BoxService
import com.tecclub.flutter_singbox.config.SimpleConfigManager
import com.tecclub.flutter_singbox.constant.Action
import com.tecclub.flutter_singbox.database.Settings
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPInputStream
import kotlin.concurrent.thread

class SoakControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pendingResult = goAsync()
val appContext = context.applicationContext
thread(name = "yurich-soak-control") {
try {
SoakControlCommandHandler.handle(appContext, intent)
} catch (error: Throwable) {
Log.e(TAG, "QA command failed: ${error.message}", error)
} finally {
pendingResult.finish()
}
}
}

companion object {
private const val TAG = "SoakControlReceiver"
}
}

internal object SoakControlCommandHandler {
fun handle(context: Context, intent: Intent) {
Application.initializeIfNeeded(context)
when (intent.getStringExtra(EXTRA_COMMAND)?.trim()?.lowercase()) {
COMMAND_ACTIVATE -> activate(context, intent)
COMMAND_STOP -> {
BoxService.stop()
Log.i(TAG, "QA stop requested")
}
COMMAND_RESTART -> {
context.sendBroadcast(
Intent(Action.SERVICE_RESTART).apply {
`package` = context.packageName
putExtra(Action.EXTRA_USER_INITIATED, true)
},
)
Log.i(TAG, "QA notification-equivalent restart requested")
}
COMMAND_STATUS -> {
Log.i(
TAG,
"QA status validConfig=${SimpleConfigManager.hasValidConfig()} " +
"startedByUser=${SimpleConfigManager.getStartedByUser()}",
)
}
else -> error("Unsupported or missing QA command")
}
}

private fun activate(context: Context, intent: Intent) {
val encoded = intent.getStringExtra(EXTRA_CONFIG_GZIP_BASE64)
?: error("Missing compressed runtime config")
val config = decodeConfig(encoded)
JSONObject(config)

check(SimpleConfigManager.saveConfig(config)) {
"Unable to persist QA runtime config"
}
SimpleConfigManager.setStartedByUser(true)
SimpleConfigManager.setManualDisconnectRequested(false)

val label = intent.getStringExtra(EXTRA_PROFILE_LABEL)
?.replace(Regex("[\\r\\n\\t]"), " ")
?.trim()
?.take(MAX_LABEL_LENGTH)
.orEmpty()
if (label.isNotEmpty()) {
SimpleConfigManager.setNotificationDescription(label)
}

val serviceIntent = Intent(context, Settings.serviceClass()).apply {
action = BoxService.ACTION_START
putExtra(BoxService.EXTRA_CONFIG_CONTENT, config)
}
ContextCompat.startForegroundService(context, serviceIntent)
Log.i(TAG, "QA activate requested label=$label configLength=${config.length}")
}

private fun decodeConfig(encoded: String): String {
val compressed = Base64.decode(encoded, Base64.NO_WRAP)
require(compressed.size <= MAX_COMPRESSED_BYTES) {
"Compressed runtime config is too large"
}

val output = ByteArrayOutputStream()
GZIPInputStream(ByteArrayInputStream(compressed)).use { input ->
val buffer = ByteArray(BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
output.write(buffer, 0, count)
require(output.size() <= MAX_CONFIG_BYTES) {
"Runtime config exceeds QA limit"
}
}
}
return output.toString(Charsets.UTF_8.name()).also {
require(it.isNotBlank()) { "Runtime config is empty" }
}
}

private const val TAG = "SoakControlCommandHandler"
private const val EXTRA_COMMAND = "command"
private const val EXTRA_CONFIG_GZIP_BASE64 = "configGzipB64"
private const val EXTRA_PROFILE_LABEL = "profileLabel"
private const val COMMAND_ACTIVATE = "activate"
private const val COMMAND_STOP = "stop"
private const val COMMAND_RESTART = "restart"
private const val COMMAND_STATUS = "status"
private const val MAX_COMPRESSED_BYTES = 256 * 1024
private const val MAX_CONFIG_BYTES = 1024 * 1024
private const val MAX_LABEL_LENGTH = 96
private const val BUFFER_SIZE = 8192
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package online.dnsai.ivanvpn.qa

import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log

class SoakControlService : Service() {
override fun onBind(intent: Intent?): IBinder? = null

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
requireNotNull(intent) { "Missing QA control intent" }
SoakControlCommandHandler.handle(applicationContext, intent)
} catch (error: Throwable) {
Log.e(TAG, "QA command failed: ${error.message}", error)
} finally {
stopSelf(startId)
}
return START_NOT_STICKY
}

companion object {
private const val TAG = "SoakControlService"
}
}
Loading
Loading