From 1f6f538aaac9c66c2f59e8f1b87c25a412be2722 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 11:41:07 +0800 Subject: [PATCH 01/55] Enable metered connection setting for VPN builder Revoke the default metered connections on Android Q and above. --- .../main/kotlin/com/simplexray/an/service/TProxyService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 1b67c055..a021b0f6 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -295,6 +295,10 @@ class TProxyService : VpnService() { setBlocking(false) setMtu(prefs.tunnelMtu) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + setMetered(false) + } + if (prefs.bypassLan) { addRoute("10.0.0.0", 8) addRoute("172.16.0.0", 12) @@ -440,4 +444,4 @@ tunnel: return tproxyConf } } -} \ No newline at end of file +} From c339760d4429c428b975c7d1554a1236eacd7abc Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 12:24:47 +0800 Subject: [PATCH 02/55] Change build process to assembleDebug APK --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fc23aea..cc3779b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,7 +100,7 @@ jobs: run: chmod +x ./gradlew - name: Build Release APK - run: ./gradlew assembleRelease + run: ./gradlew assembleDebug - name: Find and Copy APKs run: | From 7547b0186b669a1279a6d36f68e1295571659607 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 12:37:30 +0800 Subject: [PATCH 03/55] Disable signingConfig for debug build Comment out signingConfig for debug build type. --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 0ef06b98..f8eae0c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,7 +70,7 @@ android { } debug { minifyEnabled false - signingConfig signingConfigs.release + // signingConfig signingConfigs.release debuggable true } } From 57dccbfc639b119a9c77b227033ce8b1e64bbf40 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 12:48:14 +0800 Subject: [PATCH 04/55] Change APK copy path from release to debug --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc3779b6..c4e88592 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,7 +105,7 @@ jobs: - name: Find and Copy APKs run: | mkdir -p apks - find app/build/outputs/apk/release -name "*.apk" -exec cp {} apks/ \; + find app/build/outputs/apk/debug -name "*.apk" -exec cp {} apks/ \; - name: Upload Artifacts uses: actions/upload-artifact@v4 From b957839f3ba9e32d736e5e8e78e2dce8190987e8 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 13:29:01 +0800 Subject: [PATCH 05/55] Enable signingConfig for debug build --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index f8eae0c6..0ef06b98 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,7 +70,7 @@ android { } debug { minifyEnabled false - // signingConfig signingConfigs.release + signingConfig signingConfigs.release debuggable true } } From e2f8484f949150c41d64ec5319eb9750540ff3af Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Mon, 6 Apr 2026 13:30:06 +0800 Subject: [PATCH 06/55] Change build process to assemble release APK --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c4e88592..6fc23aea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,12 +100,12 @@ jobs: run: chmod +x ./gradlew - name: Build Release APK - run: ./gradlew assembleDebug + run: ./gradlew assembleRelease - name: Find and Copy APKs run: | mkdir -p apks - find app/build/outputs/apk/debug -name "*.apk" -exec cp {} apks/ \; + find app/build/outputs/apk/release -name "*.apk" -exec cp {} apks/ \; - name: Upload Artifacts uses: actions/upload-artifact@v4 From 6cb3194a65aa2cb347d0adb800cc77263cd113a0 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Sun, 12 Apr 2026 21:35:14 +0800 Subject: [PATCH 07/55] Add fd_helper.c to manage file descriptor flags --- app/src/main/jni/fd_helper.c | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 app/src/main/jni/fd_helper.c diff --git a/app/src/main/jni/fd_helper.c b/app/src/main/jni/fd_helper.c new file mode 100644 index 00000000..331d9104 --- /dev/null +++ b/app/src/main/jni/fd_helper.c @@ -0,0 +1,15 @@ +#include +#include +#include + +#define LOG_TAG "FdHelper" +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) + +JNIEXPORT void JNICALL +Java_com_simplexray_an_service_TProxyService_clearFdCloexec(JNIEnv *env, jobject thiz, jint fd) { + int flags = fcntl(fd, F_GETFD); + if (flags >= 0) { + fcntl(fd, F_SETFD, flags & ~FD_CLOEXEC); + LOGD("FD %d: cleared FD_CLOEXEC", fd); + } +} From dd7a6d464d16e661f8b489c8ff96369501a9dd4e Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Sun, 12 Apr 2026 21:36:02 +0800 Subject: [PATCH 08/55] Delete app/src/main/jni/fd_helper.c --- app/src/main/jni/fd_helper.c | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 app/src/main/jni/fd_helper.c diff --git a/app/src/main/jni/fd_helper.c b/app/src/main/jni/fd_helper.c deleted file mode 100644 index 331d9104..00000000 --- a/app/src/main/jni/fd_helper.c +++ /dev/null @@ -1,15 +0,0 @@ -#include -#include -#include - -#define LOG_TAG "FdHelper" -#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) - -JNIEXPORT void JNICALL -Java_com_simplexray_an_service_TProxyService_clearFdCloexec(JNIEnv *env, jobject thiz, jint fd) { - int flags = fcntl(fd, F_GETFD); - if (flags >= 0) { - fcntl(fd, F_SETFD, flags & ~FD_CLOEXEC); - LOGD("FD %d: cleared FD_CLOEXEC", fd); - } -} From 0f2ff6c9968a7fc75067d77527b8530efbaa1777 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:13:34 +0000 Subject: [PATCH 09/55] Initial plan From f0bd93814fbb03dc0735d2177af3768f36d5f0c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:22:12 +0000 Subject: [PATCH 10/55] Add Xray TUN support: preferences, config injection, service logic, UI, and strings Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3bd066db-cd7e-417d-8520-033c51f57684 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/ConfigUtils.kt | 41 ++++++++++ .../com/simplexray/an/prefs/Preferences.kt | 7 ++ .../simplexray/an/service/TProxyService.kt | 77 +++++++++++++------ .../an/ui/screens/SettingsScreen.kt | 14 ++++ .../simplexray/an/viewmodel/MainViewModel.kt | 18 ++--- .../simplexray/an/viewmodel/SettingsState.kt | 1 + app/src/main/res/values-id/strings.xml | 2 + app/src/main/res/values-ru-rRU/strings.xml | 2 + app/src/main/res/values-zh/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + gradlew | 0 11 files changed, 135 insertions(+), 31 deletions(-) mode change 100644 => 100755 gradlew diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 7bc8d88b..a04e0f60 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -8,6 +8,47 @@ import org.json.JSONObject object ConfigUtils { private const val TAG = "ConfigUtils" + fun extractTunMtu(configContent: String): Int? { + try { + val jsonObject = JSONObject(configContent) + val inbounds = jsonObject.optJSONArray("inbounds") ?: return null + for (i in 0 until inbounds.length()) { + val inbound = inbounds.optJSONObject(i) ?: continue + if (inbound.optString("type") == "tun") { + return inbound.optJSONObject("settings")?.optInt("mtu", -1) + ?.takeIf { it > 0 } + } + } + } catch (e: JSONException) { + Log.e(TAG, "Error parsing JSON for TUN MTU extraction", e) + } + return null + } + + fun injectXrayTunFd(configContent: String, fd: Int, tunName: String): String { + return try { + val jsonObject = JSONObject(configContent) + val inbounds = jsonObject.optJSONArray("inbounds") + if (inbounds != null) { + for (i in 0 until inbounds.length()) { + val inbound = inbounds.optJSONObject(i) ?: continue + if (inbound.optString("type") == "tun") { + val settings = inbound.optJSONObject("settings") ?: JSONObject().also { + inbound.put("settings", it) + } + settings.put("fd", fd) + settings.put("name", tunName) + break + } + } + } + jsonObject.toString(2) + } catch (e: JSONException) { + Log.e(TAG, "Error injecting Xray TUN fd", e) + configContent + } + } + @Throws(JSONException::class) fun formatConfigContent(content: String): String { val jsonObject = JSONObject(content) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index f0da40a2..368b5cb3 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -187,6 +187,12 @@ class Preferences(context: Context) { setValueInProvider(DISABLE_VPN, value) } + var useXrayTun: Boolean + get() = getBooleanPref(USE_XRAY_TUN, false) + set(value) { + setValueInProvider(USE_XRAY_TUN, value) + } + val tunnelMtu: Int get() = 8500 @@ -333,6 +339,7 @@ class Preferences(context: Context) { const val CUSTOM_GEOSITE_IMPORTED: String = "CustomGeositeImported" const val CONFIG_FILES_ORDER: String = "ConfigFilesOrder" const val DISABLE_VPN: String = "DisableVpn" + const val USE_XRAY_TUN: String = "UseXrayTun" const val CONNECTIVITY_TEST_TARGET: String = "ConnectivityTestTarget" const val CONNECTIVITY_TEST_TIMEOUT: String = "ConnectivityTestTimeout" const val GEOIP_URL: String = "GeoipUrl" diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index c90c6c5c..9e7b501a 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -15,6 +15,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor +import android.system.Os import android.util.Log import androidx.core.app.NotificationCompat import com.simplexray.an.BuildConfig @@ -194,8 +195,16 @@ class TProxyService : VpnService() { this.xrayProcess = currentProcess Log.d(TAG, "Writing config to xray stdin from: $selectedConfigPath") - val injectedConfigContent = + var injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + if (useXrayTun) { + tunFd?.fd?.let { fd -> + val tunName = getTunName(fd) + injectedConfigContent = ConfigUtils.injectXrayTunFd(injectedConfigContent, fd, tunName) + Log.d(TAG, "Injected Xray TUN fd=$fd, name=$tunName") + } + } currentProcess.outputStream.use { os -> os.write(injectedConfigContent.toByteArray()) os.flush() @@ -263,30 +272,42 @@ class TProxyService : VpnService() { private fun startService() { if (tunFd != null) return val prefs = Preferences(this) - val builder = getVpnBuilder(prefs) + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + val tunMtu = if (useXrayTun) { + val configPath = prefs.selectedConfigPath + val mtu = configPath?.let { path -> + runCatching { ConfigUtils.extractTunMtu(File(path).readText()) }.getOrNull() + } + mtu ?: prefs.tunnelMtu + } else { + prefs.tunnelMtu + } + val builder = getVpnBuilder(prefs, tunMtu) tunFd = builder.establish() if (tunFd == null) { stopXray() return } - val tproxyFile = File(cacheDir, "tproxy.conf") - try { - tproxyFile.createNewFile() - FileOutputStream(tproxyFile, false).use { fos -> - val tproxyConf = getTproxyConf(prefs) - fos.write(tproxyConf.toByteArray()) + if (!useXrayTun) { + val tproxyFile = File(cacheDir, "tproxy.conf") + try { + tproxyFile.createNewFile() + FileOutputStream(tproxyFile, false).use { fos -> + val tproxyConf = getTproxyConf(prefs) + fos.write(tproxyConf.toByteArray()) + } + } catch (e: IOException) { + Log.e(TAG, e.toString()) + stopXray() + return + } + tunFd?.fd?.let { fd -> + TProxyStartService(tproxyFile.absolutePath, fd) + } ?: run { + Log.e(TAG, "tunFd is null after establish()") + stopXray() + return } - } catch (e: IOException) { - Log.e(TAG, e.toString()) - stopXray() - return - } - tunFd?.fd?.let { fd -> - TProxyStartService(tproxyFile.absolutePath, fd) - } ?: run { - Log.e(TAG, "tunFd is null after establish()") - stopXray() - return } val successIntent = Intent(ACTION_START) @@ -297,9 +318,9 @@ class TProxyService : VpnService() { createNotification(channelName) } - private fun getVpnBuilder(prefs: Preferences): Builder = Builder().apply { + private fun getVpnBuilder(prefs: Preferences, mtu: Int = prefs.tunnelMtu): Builder = Builder().apply { setBlocking(false) - setMtu(prefs.tunnelMtu) + setMtu(mtu) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setMetered(false) @@ -348,7 +369,10 @@ class TProxyService : VpnService() { tunFd = null } stopForeground(Service.STOP_FOREGROUND_REMOVE) - TProxyStopService() + val prefs = Preferences(this) + if (!prefs.useXrayTun || prefs.disableVpn) { + TProxyStopService() + } } exit() } @@ -432,6 +456,15 @@ class TProxyService : VpnService() { } } + fun getTunName(fd: Int): String { + return try { + Os.readlink("/proc/self/fd/$fd").substringAfterLast("/") + } catch (e: Exception) { + Log.w(TAG, "Could not determine TUN interface name: ${e.message}") + "tun0" + } + } + private fun getTproxyConf(prefs: Preferences): String { var tproxyConf = """misc: task-stack-size: ${prefs.taskStackSize} diff --git a/app/src/main/kotlin/com/simplexray/an/ui/screens/SettingsScreen.kt b/app/src/main/kotlin/com/simplexray/an/ui/screens/SettingsScreen.kt index 8437fdbe..7b68ae49 100644 --- a/app/src/main/kotlin/com/simplexray/an/ui/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/simplexray/an/ui/screens/SettingsScreen.kt @@ -370,6 +370,20 @@ fun SettingsScreen( } ) + ListItem( + headlineContent = { Text(stringResource(R.string.use_xray_tun_title)) }, + supportingContent = { Text(stringResource(R.string.use_xray_tun_summary)) }, + trailingContent = { + Switch( + checked = settingsState.switches.useXrayTun, + onCheckedChange = { + mainViewModel.setUseXrayTun(it) + }, + enabled = !vpnDisabled + ) + } + ) + EditableListItemWithBottomSheet( headline = stringResource(R.string.socks_port), currentValue = settingsState.socksPort.value, diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 70afe486..7a881dc5 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -94,16 +94,9 @@ class MainViewModel(application: Application) : httpProxyEnabled = prefs.httpProxyEnabled, bypassLanEnabled = prefs.bypassLan, disableVpn = prefs.disableVpn, + useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), - info = InfoStates( - appVersion = BuildConfig.VERSION_NAME, - kernelVersion = "N/A", - geoipSummary = "", - geositeSummary = "", - geoipUrl = prefs.geoipUrl, - geositeUrl = prefs.geositeUrl - ), files = FileStates( isGeoipCustom = prefs.customGeoipImported, isGeositeCustom = prefs.customGeositeImported @@ -192,9 +185,9 @@ class MainViewModel(application: Application) : httpProxyEnabled = prefs.httpProxyEnabled, bypassLanEnabled = prefs.bypassLan, disableVpn = prefs.disableVpn, + useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), - info = _settingsState.value.info.copy( appVersion = BuildConfig.VERSION_NAME, geoipSummary = fileManager.getRuleFileSummary("geoip.dat"), geositeSummary = fileManager.getRuleFileSummary("geosite.dat"), @@ -546,6 +539,13 @@ class MainViewModel(application: Application) : ) } + fun setUseXrayTun(enabled: Boolean) { + prefs.useXrayTun = enabled + _settingsState.value = _settingsState.value.copy( + switches = _settingsState.value.switches.copy(useXrayTun = enabled) + ) + } + fun setTheme(mode: ThemeMode) { prefs.theme = mode _settingsState.value = _settingsState.value.copy( diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/SettingsState.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/SettingsState.kt index 34f59587..f541b1cc 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/SettingsState.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/SettingsState.kt @@ -14,6 +14,7 @@ data class SwitchStates( val httpProxyEnabled: Boolean, val bypassLanEnabled: Boolean, val disableVpn: Boolean, + val useXrayTun: Boolean, val themeMode: ThemeMode ) diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 019e2a67..ad47ac06 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -121,4 +121,6 @@ Gelap Otomatis "Pengguna SOCKS " + Gunakan Xray TUN + Saat diaktifkan, inbound TUN bawaan Xray digunakan sebagai pengganti hev-socks5-tunnel. diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 64532f46..e4342f8c 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -121,4 +121,6 @@ Тёмная Автоматически Пользователь SOCKS + Использовать Xray TUN + При включении используется встроенный TUN-inbound Xray вместо hev-socks5-tunnel. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ff78e747..1014dbca 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -121,4 +121,6 @@ 深色 自动 SOCKS 用户 + 使用 Xray TUN + 启用时,使用 Xray 内置 TUN 入站,而非 hev-socks5-tunnel。 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e459a48..32427981 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,4 +126,6 @@ Light Dark Auto + Use Xray TUN + When enabled, Xray built-in TUN inbound is used instead of hev-socks5-tunnel. \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 211f2c9382cf81d86e28146b26f422257380b9db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:51:46 +0000 Subject: [PATCH 11/55] Fix compile errors and correct Xray TUN fd mechanism (use XRAY_TUN_FD env var) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/08d28c81-952e-4336-9e1a-d3f6a9ad41b5 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/ConfigUtils.kt | 24 -------------- .../simplexray/an/service/TProxyService.kt | 31 ++++++------------- .../simplexray/an/viewmodel/MainViewModel.kt | 9 ++++++ 3 files changed, 19 insertions(+), 45 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index a04e0f60..64e9e4d2 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -25,30 +25,6 @@ object ConfigUtils { return null } - fun injectXrayTunFd(configContent: String, fd: Int, tunName: String): String { - return try { - val jsonObject = JSONObject(configContent) - val inbounds = jsonObject.optJSONArray("inbounds") - if (inbounds != null) { - for (i in 0 until inbounds.length()) { - val inbound = inbounds.optJSONObject(i) ?: continue - if (inbound.optString("type") == "tun") { - val settings = inbound.optJSONObject("settings") ?: JSONObject().also { - inbound.put("settings", it) - } - settings.put("fd", fd) - settings.put("name", tunName) - break - } - } - } - jsonObject.toString(2) - } catch (e: JSONException) { - Log.e(TAG, "Error injecting Xray TUN fd", e) - configContent - } - } - @Throws(JSONException::class) fun formatConfigContent(content: String): String { val jsonObject = JSONObject(content) diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 9e7b501a..fc3f239e 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -15,7 +15,6 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor -import android.system.Os import android.util.Log import androidx.core.app.NotificationCompat import com.simplexray.an.BuildConfig @@ -190,21 +189,15 @@ class TProxyService : VpnService() { prefs.apiAddress = "127.$octet2.$octet3.$octet4" Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") - val processBuilder = getProcessBuilder(xrayPath) + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + val xrayTunFd = if (useXrayTun) tunFd?.fd else null + val processBuilder = getProcessBuilder(xrayPath, xrayTunFd) currentProcess = processBuilder.start() this.xrayProcess = currentProcess Log.d(TAG, "Writing config to xray stdin from: $selectedConfigPath") - var injectedConfigContent = + val injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) - val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - if (useXrayTun) { - tunFd?.fd?.let { fd -> - val tunName = getTunName(fd) - injectedConfigContent = ConfigUtils.injectXrayTunFd(injectedConfigContent, fd, tunName) - Log.d(TAG, "Injected Xray TUN fd=$fd, name=$tunName") - } - } currentProcess.outputStream.use { os -> os.write(injectedConfigContent.toByteArray()) os.flush() @@ -245,12 +238,17 @@ class TProxyService : VpnService() { } } - private fun getProcessBuilder(xrayPath: String): ProcessBuilder { + private fun getProcessBuilder(xrayPath: String, tunFd: Int? = null): ProcessBuilder { val filesDir = applicationContext.filesDir val command: MutableList = mutableListOf(xrayPath) val processBuilder = ProcessBuilder(command) val environment = processBuilder.environment() environment["XRAY_LOCATION_ASSET"] = filesDir.path + if (tunFd != null) { + // Xray TUN on Android reads the VPN fd from this environment variable + environment["XRAY_TUN_FD"] = tunFd.toString() + Log.d(TAG, "Set XRAY_TUN_FD=$tunFd") + } processBuilder.directory(filesDir) processBuilder.redirectErrorStream(true) return processBuilder @@ -456,15 +454,6 @@ class TProxyService : VpnService() { } } - fun getTunName(fd: Int): String { - return try { - Os.readlink("/proc/self/fd/$fd").substringAfterLast("/") - } catch (e: Exception) { - Log.w(TAG, "Could not determine TUN interface name: ${e.message}") - "tun0" - } - } - private fun getTproxyConf(prefs: Preferences): String { var tproxyConf = """misc: task-stack-size: ${prefs.taskStackSize} diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 7a881dc5..9cdd088c 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -97,6 +97,14 @@ class MainViewModel(application: Application) : useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), + info = InfoStates( + appVersion = BuildConfig.VERSION_NAME, + kernelVersion = "N/A", + geoipSummary = "", + geositeSummary = "", + geoipUrl = prefs.geoipUrl, + geositeUrl = prefs.geositeUrl + ), files = FileStates( isGeoipCustom = prefs.customGeoipImported, isGeositeCustom = prefs.customGeositeImported @@ -188,6 +196,7 @@ class MainViewModel(application: Application) : useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), + info = _settingsState.value.info.copy( appVersion = BuildConfig.VERSION_NAME, geoipSummary = fileManager.getRuleFileSummary("geoip.dat"), geositeSummary = fileManager.getRuleFileSummary("geosite.dat"), From 9a04c5aa271a82df618866f448b1ff5186e79e1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:36:31 +0000 Subject: [PATCH 12/55] fix: inherit VPN fd into Xray TUN process via native fork+dup2+exec Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/572a98cb-b4c2-4b83-9749-0c384ee0194f Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/jni/xray-exec/Android.mk | 9 + app/src/main/jni/xray-exec/xray_exec.c | 204 ++++++++++++++++++ .../simplexray/an/service/TProxyService.kt | 145 ++++++++++--- 3 files changed, 328 insertions(+), 30 deletions(-) create mode 100644 app/src/main/jni/xray-exec/Android.mk create mode 100644 app/src/main/jni/xray-exec/xray_exec.c diff --git a/app/src/main/jni/xray-exec/Android.mk b/app/src/main/jni/xray-exec/Android.mk new file mode 100644 index 00000000..0faf6cfe --- /dev/null +++ b/app/src/main/jni/xray-exec/Android.mk @@ -0,0 +1,9 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := xray-exec +LOCAL_SRC_FILES := xray_exec.c +LOCAL_LDLIBS := -llog + +include $(BUILD_SHARED_LIBRARY) diff --git a/app/src/main/jni/xray-exec/xray_exec.c b/app/src/main/jni/xray-exec/xray_exec.c new file mode 100644 index 00000000..5d46c793 --- /dev/null +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -0,0 +1,204 @@ +/* + * xray_exec.c – spawn the Xray binary with the Android VPN fd properly inherited. + * + * Problem: Android's ProcessBuilder (fork + exec) honours FD_CLOEXEC. The fd + * returned by VpnService.Builder.establish() always has FD_CLOEXEC set, so it + * is automatically closed before the child process starts. Passing its number + * via the XRAY_TUN_FD environment variable therefore gives Xray an invalid fd. + * + * Fix: fork() here in native code, then dup2() the VPN fd to a fixed target fd + * (CHILD_TUN_FD = 4) *before* exec(). dup2() does not copy FD_CLOEXEC, so fd + * 4 survives exec and is visible to Xray as its TUN fd. + * + * The function also creates stdin / stdout pipes so that the caller can write + * the Xray config JSON to stdin and read Xray log output from stdout, exactly + * mirroring the existing ProcessBuilder-based code path. + * + * JNI signature: + * int[] TProxyService.nativeSpawnXray( + * String xrayPath, String assetDir, int vpnFd) + * + * Returns int[3] = { pid, stdout_read_fd, stdin_write_fd } on success, or null + * on failure. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "XrayExec" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +/* The fd number that will hold the VPN fd inside the Xray process. */ +#define CHILD_TUN_FD 4 + +/* + * Close every open fd > 2 except keep_fd. We enumerate /proc/self/fd so that + * we do not have to guess the upper bound, and we collect all candidates before + * closing any of them to avoid invalidating the directory stream mid-walk. + */ +static void close_extra_fds(int keep_fd) +{ + int fds[4096]; + int n = 0; + DIR *dir = opendir("/proc/self/fd"); + + if (!dir) { + /* Fallback: brute-force close a reasonable range. */ + long max = sysconf(_SC_OPEN_MAX); + if (max <= 0 || max > 65536) max = 1024; + for (int i = 3; i < (int)max; i++) { + if (i != keep_fd) close(i); + } + return; + } + + int dir_fd = dirfd(dir); + struct dirent *entry; + while ((entry = readdir(dir)) != NULL && n < 4096) { + if (entry->d_name[0] < '0' || entry->d_name[0] > '9') continue; + int fd = atoi(entry->d_name); + if (fd > 2 && fd != keep_fd && fd != dir_fd) + fds[n++] = fd; + } + closedir(dir); + + for (int i = 0; i < n; i++) close(fds[i]); +} + +JNIEXPORT jintArray JNICALL +Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( + JNIEnv *env, jclass clazz, + jstring xray_path_j, + jstring asset_dir_j, + jint vpn_fd) +{ + const char *xray_path = (*env)->GetStringUTFChars(env, xray_path_j, NULL); + const char *asset_dir = (*env)->GetStringUTFChars(env, asset_dir_j, NULL); + + /* stdin pipe: parent writes config → child reads (stdin_pipe[0] = read end) */ + /* stdout pipe: child writes logs → parent reads (stdout_pipe[0] = read end) */ + int stdin_pipe[2] = {-1, -1}; + int stdout_pipe[2] = {-1, -1}; + + if (pipe(stdin_pipe) < 0 || pipe(stdout_pipe) < 0) { + LOGE("pipe() failed: %s", strerror(errno)); + if (stdin_pipe[0] >= 0) { close(stdin_pipe[0]); close(stdin_pipe[1]); } + if (stdout_pipe[0] >= 0) { close(stdout_pipe[0]); close(stdout_pipe[1]); } + (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); + (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); + return NULL; + } + + /* Build the environment that will be given to Xray. */ + char asset_env[4096]; + char tun_fd_env[64]; + snprintf(asset_env, sizeof(asset_env), "XRAY_LOCATION_ASSET=%s", asset_dir); + snprintf(tun_fd_env, sizeof(tun_fd_env), "XRAY_TUN_FD=%d", CHILD_TUN_FD); + + /* Inherit parent environment, overriding / appending the two XRAY_* vars. */ + extern char **environ; + int parent_ec = 0; + while (environ[parent_ec]) parent_ec++; + + char **new_env = (char **)malloc((size_t)(parent_ec + 3) * sizeof(char *)); + if (!new_env) { + LOGE("malloc failed for new_env"); + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); + (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); + return NULL; + } + int ni = 0; + for (int i = 0; i < parent_ec; i++) { + if (strncmp(environ[i], "XRAY_LOCATION_ASSET=", 20) == 0) continue; + if (strncmp(environ[i], "XRAY_TUN_FD=", 12) == 0) continue; + new_env[ni++] = environ[i]; + } + new_env[ni++] = asset_env; + new_env[ni++] = tun_fd_env; + new_env[ni] = NULL; + + /* argv: just the binary path – Xray reads config from stdin when no -config + * argument is given, matching the existing ProcessBuilder code path. */ + char *argv[] = { (char *)xray_path, NULL }; + + pid_t pid = fork(); + if (pid < 0) { + LOGE("fork() failed: %s", strerror(errno)); + free(new_env); + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); + (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); + return NULL; + } + + if (pid == 0) { + /* ===== CHILD PROCESS ===== */ + + /* Send SIGKILL to this process if the parent dies. */ + prctl(PR_SET_PDEATHSIG, SIGKILL); + + /* stdin ← read end of stdin pipe */ + dup2(stdin_pipe[0], STDIN_FILENO); + + /* stdout + stderr → write end of stdout pipe */ + dup2(stdout_pipe[1], STDOUT_FILENO); + dup2(stdout_pipe[1], STDERR_FILENO); + + /* Close the original pipe ends (already dup2'd to 0/1/2). */ + close(stdin_pipe[0]); + close(stdin_pipe[1]); + close(stdout_pipe[0]); + close(stdout_pipe[1]); + + /* Assign the VPN fd to CHILD_TUN_FD (dup2 never sets FD_CLOEXEC). */ + if ((int)vpn_fd >= 0 && (int)vpn_fd != CHILD_TUN_FD) { + if (dup2((int)vpn_fd, CHILD_TUN_FD) < 0) { + _exit(1); + } + } + + /* Close every other inherited fd, keeping only 0, 1, 2, CHILD_TUN_FD. */ + close_extra_fds(CHILD_TUN_FD); + + execve(xray_path, argv, new_env); + /* exec failed */ + _exit(1); + } + + /* ===== PARENT PROCESS ===== */ + free(new_env); + + /* Close the ends that belong to the child. */ + close(stdin_pipe[0]); /* child reads from this; parent must not keep it */ + close(stdout_pipe[1]); /* child writes to this; parent must not keep it */ + + (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); + (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); + + LOGI("Spawned xray pid=%d stdin_write_fd=%d stdout_read_fd=%d", + pid, stdin_pipe[1], stdout_pipe[0]); + + /* Return { pid, stdout_read_fd, stdin_write_fd } */ + jintArray result = (*env)->NewIntArray(env, 3); + if (!result) { + close(stdin_pipe[1]); + close(stdout_pipe[0]); + return NULL; + } + jint arr[3] = { (jint)pid, (jint)stdout_pipe[0], (jint)stdin_pipe[1] }; + (*env)->SetIntArrayRegion(env, result, 0, 3, arr); + return result; +} diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index fc3f239e..7f881dce 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -15,6 +15,9 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants import android.util.Log import androidx.core.app.NotificationCompat import com.simplexray.an.BuildConfig @@ -81,11 +84,31 @@ class TProxyService : VpnService() { @Volatile private var xrayProcess: Process? = null + @Volatile + private var xrayPid: Int = -1 private var tunFd: ParcelFileDescriptor? = null @Volatile private var reloadingRequested = false + /** + * Kill whichever Xray process is currently running, whether it was started + * via ProcessBuilder (managed) or via nativeSpawnXray (native TUN mode). + */ + private fun killXrayProcess() { + xrayProcess?.destroy() + xrayProcess = null + val pid = xrayPid + if (pid > 0) { + xrayPid = -1 + try { + Os.kill(pid, OsConstants.SIGKILL) + } catch (e: ErrnoException) { + Log.w(TAG, "Failed to kill xray pid $pid: ${e.message}") + } + } + } + override fun onCreate() { super.onCreate() logFileManager = LogFileManager(this) @@ -105,7 +128,7 @@ class TProxyService : VpnService() { if (prefs.disableVpn) { Log.d(TAG, "Received RELOAD_CONFIG action (core-only mode)") reloadingRequested = true - xrayProcess?.destroy() + killXrayProcess() serviceScope.launch { runXrayProcess() } return START_STICKY } @@ -115,7 +138,7 @@ class TProxyService : VpnService() { } Log.d(TAG, "Received RELOAD_CONFIG action.") reloadingRequested = true - xrayProcess?.destroy() + killXrayProcess() serviceScope.launch { runXrayProcess() } return START_STICKY } @@ -172,6 +195,9 @@ class TProxyService : VpnService() { private fun runXrayProcess() { var currentProcess: Process? = null + var stdoutPfd: ParcelFileDescriptor? = null + var currentPid: Int = -1 + try { Log.d(TAG, "Attempting to start xray process.") val libraryDir = getNativeLibraryDir(applicationContext) @@ -189,28 +215,68 @@ class TProxyService : VpnService() { prefs.apiAddress = "127.$octet2.$octet3.$octet4" Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") + val injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - val xrayTunFd = if (useXrayTun) tunFd?.fd else null - val processBuilder = getProcessBuilder(xrayPath, xrayTunFd) - currentProcess = processBuilder.start() - this.xrayProcess = currentProcess - - Log.d(TAG, "Writing config to xray stdin from: $selectedConfigPath") - val injectedConfigContent = - ConfigUtils.injectStatsService(prefs, configContent) - currentProcess.outputStream.use { os -> - os.write(injectedConfigContent.toByteArray()) - os.flush() + + val reader: BufferedReader + + if (useXrayTun) { + // ── Native TUN path ──────────────────────────────────────────── + // The VPN fd has FD_CLOEXEC set by Android, so it cannot survive + // a ProcessBuilder fork+exec. nativeSpawnXray() uses fork()+dup2() + // to move the fd to a fixed slot before exec(), bypassing the issue. + val vpnFd = tunFd?.fd ?: run { + Log.e(TAG, "tunFd is null for Xray TUN mode") + return + } + val spawnResult = nativeSpawnXray(xrayPath, filesDir.path, vpnFd) + ?: run { + Log.e(TAG, "nativeSpawnXray returned null – spawn failed") + return + } + currentPid = spawnResult[0] + val stdoutReadFd = spawnResult[1] + val stdinWriteFd = spawnResult[2] + this.xrayPid = currentPid + Log.d(TAG, "Xray TUN process started: pid=$currentPid") + + // Write config to Xray's stdin then close the write end so Xray + // sees EOF and knows the full config has been delivered. + Log.d(TAG, "Writing config to native Xray stdin.") + ParcelFileDescriptor.adoptFd(stdinWriteFd).use { pfd -> + ParcelFileDescriptor.AutoCloseOutputStream(pfd).use { out -> + out.write(injectedConfigContent.toByteArray()) + out.flush() + } + } + + stdoutPfd = ParcelFileDescriptor.adoptFd(stdoutReadFd) + reader = BufferedReader( + InputStreamReader(ParcelFileDescriptor.AutoCloseInputStream(stdoutPfd)) + ) + } else { + // ── Managed (ProcessBuilder) path ────────────────────────────── + currentPid = -1 + val processBuilder = getProcessBuilder(xrayPath) + currentProcess = processBuilder.start() + this.xrayProcess = currentProcess + + Log.d(TAG, "Writing config to xray stdin from: $selectedConfigPath") + currentProcess.outputStream.use { os -> + os.write(injectedConfigContent.toByteArray()) + os.flush() + } + reader = BufferedReader(InputStreamReader(currentProcess.inputStream)) } - val inputStream = currentProcess.inputStream - val reader = BufferedReader(InputStreamReader(inputStream)) - var line: String + // ── Shared log-reading loop ──────────────────────────────────────── Log.d(TAG, "Reading xray process output.") - while ((reader.readLine().also { line = it }) != null) { - logFileManager.appendLog(line) + var line: String? + while (reader.readLine().also { line = it } != null) { + val l = line!! + logFileManager.appendLog(l) synchronized(logBroadcastBuffer) { - logBroadcastBuffer.add(line) + logBroadcastBuffer.add(l) if (!handler.hasCallbacks(broadcastLogsRunnable)) { handler.postDelayed(broadcastLogsRunnable, BROADCAST_DELAY_MS) } @@ -222,6 +288,7 @@ class TProxyService : VpnService() { } catch (e: Exception) { Log.e(TAG, "Error executing xray", e) } finally { + stdoutPfd?.close() Log.d(TAG, "Xray process task finished.") if (reloadingRequested) { Log.d(TAG, "Xray process stopped due to configuration reload.") @@ -232,36 +299,33 @@ class TProxyService : VpnService() { } if (this.xrayProcess === currentProcess) { this.xrayProcess = null - } else { + } else if (currentProcess != null) { Log.w(TAG, "Finishing task for an old xray process instance.") } + if (xrayPid > 0 && xrayPid == currentPid) { + xrayPid = -1 + } } } - private fun getProcessBuilder(xrayPath: String, tunFd: Int? = null): ProcessBuilder { + private fun getProcessBuilder(xrayPath: String): ProcessBuilder { val filesDir = applicationContext.filesDir val command: MutableList = mutableListOf(xrayPath) val processBuilder = ProcessBuilder(command) val environment = processBuilder.environment() environment["XRAY_LOCATION_ASSET"] = filesDir.path - if (tunFd != null) { - // Xray TUN on Android reads the VPN fd from this environment variable - environment["XRAY_TUN_FD"] = tunFd.toString() - Log.d(TAG, "Set XRAY_TUN_FD=$tunFd") - } processBuilder.directory(filesDir) processBuilder.redirectErrorStream(true) return processBuilder } private fun stopXray() { - Log.d(TAG, "stopXray called with keepExecutorAlive=" + false) + Log.d(TAG, "stopXray called") serviceScope.cancel() Log.d(TAG, "CoroutineScope cancelled.") - xrayProcess?.destroy() - xrayProcess = null - Log.d(TAG, "xrayProcess reference nulled.") + killXrayProcess() + Log.d(TAG, "Xray process killed.") Log.d(TAG, "Calling stopService (stopping VPN).") stopService() @@ -419,6 +483,7 @@ class TProxyService : VpnService() { init { System.loadLibrary("hev-socks5-tunnel") + System.loadLibrary("xray-exec") } @JvmStatic @@ -433,6 +498,26 @@ class TProxyService : VpnService() { @Suppress("FunctionName") private external fun TProxyGetStats(): LongArray? + /** + * Fork-exec the Xray binary with the VPN fd properly inherited. + * + * The VPN fd returned by VpnService.Builder.establish() has FD_CLOEXEC set, + * so it is closed before the child process starts when using ProcessBuilder. + * This native function uses fork()+dup2() to assign the fd to a fixed slot + * (fd 4) before exec(), ensuring it survives into the Xray process. + * + * @param xrayPath Absolute path to the Xray binary. + * @param assetDir Directory containing geo-data assets (XRAY_LOCATION_ASSET). + * @param vpnFd The raw fd integer from the VPN ParcelFileDescriptor. + * @return int[3] = { pid, stdout_read_fd, stdin_write_fd }, or null on error. + */ + @JvmStatic + private external fun nativeSpawnXray( + xrayPath: String, + assetDir: String, + vpnFd: Int + ): IntArray? + fun getNativeLibraryDir(context: Context?): String? { if (context == null) { Log.e(TAG, "Context is null") From 36030273ac9ed956024f61b858dde343eb3f62d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:38:59 +0000 Subject: [PATCH 13/55] refactor: address code review comments (malloc array, no !! force-unwrap) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/572a98cb-b4c2-4b83-9749-0c384ee0194f Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/jni/xray-exec/xray_exec.c | 15 +++++++++++++-- .../com/simplexray/an/service/TProxyService.kt | 10 +++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/jni/xray-exec/xray_exec.c b/app/src/main/jni/xray-exec/xray_exec.c index 5d46c793..e6f6ab33 100644 --- a/app/src/main/jni/xray-exec/xray_exec.c +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -45,13 +45,22 @@ * Close every open fd > 2 except keep_fd. We enumerate /proc/self/fd so that * we do not have to guess the upper bound, and we collect all candidates before * closing any of them to avoid invalidating the directory stream mid-walk. + * This also closes the original vpn_fd position once it has been dup2'd to + * CHILD_TUN_FD, so no fd leaks occur. */ static void close_extra_fds(int keep_fd) { - int fds[4096]; + /* 256 open fds is far more than a freshly-forked Android child will have. */ + static const int MAX_FDS = 256; + int *fds = (int *)malloc((size_t)MAX_FDS * sizeof(int)); int n = 0; DIR *dir = opendir("/proc/self/fd"); + if (!fds) { + if (dir) closedir(dir); + return; + } + if (!dir) { /* Fallback: brute-force close a reasonable range. */ long max = sysconf(_SC_OPEN_MAX); @@ -59,12 +68,13 @@ static void close_extra_fds(int keep_fd) for (int i = 3; i < (int)max; i++) { if (i != keep_fd) close(i); } + free(fds); return; } int dir_fd = dirfd(dir); struct dirent *entry; - while ((entry = readdir(dir)) != NULL && n < 4096) { + while ((entry = readdir(dir)) != NULL && n < MAX_FDS) { if (entry->d_name[0] < '0' || entry->d_name[0] > '9') continue; int fd = atoi(entry->d_name); if (fd > 2 && fd != keep_fd && fd != dir_fd) @@ -73,6 +83,7 @@ static void close_extra_fds(int keep_fd) closedir(dir); for (int i = 0; i < n; i++) close(fds[i]); + free(fds); } JNIEXPORT jintArray JNICALL diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 7f881dce..e2a8fd5e 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -271,16 +271,16 @@ class TProxyService : VpnService() { // ── Shared log-reading loop ──────────────────────────────────────── Log.d(TAG, "Reading xray process output.") - var line: String? - while (reader.readLine().also { line = it } != null) { - val l = line!! - logFileManager.appendLog(l) + var line = reader.readLine() + while (line != null) { + logFileManager.appendLog(line) synchronized(logBroadcastBuffer) { - logBroadcastBuffer.add(l) + logBroadcastBuffer.add(line) if (!handler.hasCallbacks(broadcastLogsRunnable)) { handler.postDelayed(broadcastLogsRunnable, BROADCAST_DELAY_MS) } } + line = reader.readLine() } Log.d(TAG, "xray process output stream finished.") } catch (e: InterruptedIOException) { From f571d8a6caeecd8ae4caa8bffd4b15b44b49c1d0 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:12:36 +0800 Subject: [PATCH 14/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20ConfigUtils.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 64e9e4d2..7f90ea8f 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -15,7 +15,7 @@ object ConfigUtils { for (i in 0 until inbounds.length()) { val inbound = inbounds.optJSONObject(i) ?: continue if (inbound.optString("type") == "tun") { - return inbound.optJSONObject("settings")?.optInt("mtu", -1) + return inbound.optJSONObject("settings")?.optInt("MTU", -1) ?.takeIf { it > 0 } } } From 68f43a498605530f5faf156d02e3988fd297ae6d Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:19:14 +0800 Subject: [PATCH 15/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Preferences.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 368b5cb3..71d7c360 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -194,7 +194,7 @@ class Preferences(context: Context) { } val tunnelMtu: Int - get() = 8500 + get() = 1500 val tunnelIpv4Address: String get() = "198.18.0.1" From af4d3cea92bf56da0779f78f0995cd5bf5e7f61b Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:44:15 +0800 Subject: [PATCH 16/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20ConfigUtils.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 7f90ea8f..204546be 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -14,7 +14,7 @@ object ConfigUtils { val inbounds = jsonObject.optJSONArray("inbounds") ?: return null for (i in 0 until inbounds.length()) { val inbound = inbounds.optJSONObject(i) ?: continue - if (inbound.optString("type") == "tun") { + if (inbound.optString("protocol") == "tun") { return inbound.optJSONObject("settings")?.optInt("MTU", -1) ?.takeIf { it > 0 } } From f64257a6407cf2ba5521e333702f4e4dbfce72ee Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:46:42 +0800 Subject: [PATCH 17/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Preferences.kt?= =?UTF-8?q?=EF=BC=88=E6=81=A2=E5=A4=8D=E5=88=B0=E5=8E=9F=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 71d7c360..368b5cb3 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -194,7 +194,7 @@ class Preferences(context: Context) { } val tunnelMtu: Int - get() = 1500 + get() = 8500 val tunnelIpv4Address: String get() = "198.18.0.1" From db95d89bba7515dd6d5b0ef648b38e3ed1b2f684 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:47:40 +0800 Subject: [PATCH 18/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20Preferences.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 368b5cb3..b8358d9e 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -196,6 +196,9 @@ class Preferences(context: Context) { val tunnelMtu: Int get() = 8500 + val tunnelMtuForXrayTun: Int + get() = 1500 + val tunnelIpv4Address: String get() = "198.18.0.1" From 3a46b84b0c4e3941f4d924f9131f43442a3514ab Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Tue, 14 Apr 2026 10:48:54 +0800 Subject: [PATCH 19/55] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20TProxyService.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index e2a8fd5e..3a3b73a9 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -340,7 +340,7 @@ class TProxyService : VpnService() { val mtu = configPath?.let { path -> runCatching { ConfigUtils.extractTunMtu(File(path).readText()) }.getOrNull() } - mtu ?: prefs.tunnelMtu + mtu ?: prefs.tunnelMtuForXrayTun } else { prefs.tunnelMtu } From b81273ff3bf0714014a07ac9e07e6630ebb45a27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:34:21 +0000 Subject: [PATCH 20/55] Fix connectivity test for HEV TUN mode (credential auth) and Xray TUN mode Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/c7c40ab5-44f7-4e0f-88b7-723da01fc594 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 197 ++++++++++++++---- 1 file changed, 158 insertions(+), 39 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 9cdd088c..38cf3dfa 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -48,6 +48,7 @@ import java.io.BufferedReader import java.io.File import java.io.IOException import java.io.InputStreamReader +import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket @@ -782,53 +783,71 @@ class MainViewModel(application: Application) : _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_invalid_url))) return@launch } - val host = url.host - val port = if (url.port > 0) url.port else url.defaultPort - val path = if (url.path.isNullOrEmpty()) "/" else url.path - val isHttps = url.protocol == "https" - val proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) val timeout = prefs.connectivityTestTimeout val start = System.currentTimeMillis() + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn try { - Socket(proxy).use { socket -> - socket.soTimeout = timeout - socket.connect(InetSocketAddress(host, port), timeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) + var success = false + if (useXrayTun) { + // In Xray TUN mode, Xray handles the TUN interface natively and the + // SimpleXray app itself is excluded from VPN routing. Use a direct + // connection (no SOCKS proxy) since there may be no SOCKS inbound. + val connection = url.openConnection(Proxy.NO_PROXY) as HttpURLConnection + connection.connectTimeout = timeout + connection.readTimeout = timeout + connection.instanceFollowRedirects = false + connection.setRequestProperty("Connection", "close") + try { + val responseCode = connection.responseCode + success = responseCode in 100..599 + } finally { + connection.disconnect() } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString( - R.string.connectivity_test_latency, - latency.toInt() - ) + } else { + // In HEV TUN mode or SOCKS-only mode, connect through the SOCKS5 proxy. + // Manual SOCKS5 handshake is used to support username/password authentication. + val host = url.host + val port = if (url.port > 0) url.port else url.defaultPort + val path = if (url.path.isNullOrEmpty()) "/" else url.path + val isHttps = url.protocol == "https" + connectViaSocks5( + prefs.socksAddress, prefs.socksPort, host, port, + prefs.socksUsername, prefs.socksPassword, timeout + ).use { socket -> + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() ) - ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + success = firstLine != null && firstLine.startsWith("HTTP/") } } + val latency = System.currentTimeMillis() - start + if (success) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } } catch (e: Exception) { _uiEvent.trySend( MainViewUiEvent.ShowSnackbar( @@ -839,6 +858,106 @@ class MainViewModel(application: Application) : } } + /** + * Opens a SOCKS5 connection to [targetHost]:[targetPort] via the SOCKS5 proxy at + * [socksHost]:[socksPort], performing username/password authentication when + * [username] and [password] are non-empty (RFC 1929). + * + * Returns a [Socket] that is already connected through the proxy to the target. + * The caller is responsible for closing it. + */ + @Throws(IOException::class) + private fun connectViaSocks5( + socksHost: String, socksPort: Int, + targetHost: String, targetPort: Int, + username: String, password: String, + timeout: Int + ): Socket { + val socket = Socket() + try { + socket.soTimeout = timeout + socket.connect(InetSocketAddress(socksHost, socksPort), timeout) + val input = socket.getInputStream() + val output = socket.getOutputStream() + val hasCredentials = username.isNotEmpty() && password.isNotEmpty() + + // SOCKS5 greeting: advertise supported authentication methods + if (hasCredentials) { + output.write(byteArrayOf(0x05, 0x02, 0x00, 0x02)) // no-auth + user/pass + } else { + output.write(byteArrayOf(0x05, 0x01, 0x00)) // no-auth only + } + output.flush() + + // Server selects a method + val ver = input.read() + val method = input.read() + if (ver != 5) throw IOException("SOCKS5: unexpected server version $ver") + + when (method) { + 0x00 -> { /* No authentication required */ } + 0x02 -> { + // Username/password sub-negotiation (RFC 1929) + if (!hasCredentials) throw IOException("SOCKS5: server requires credentials") + val userBytes = username.toByteArray(Charsets.UTF_8) + val passBytes = password.toByteArray(Charsets.UTF_8) + val authMsg = ByteArray(3 + userBytes.size + passBytes.size) + var idx = 0 + authMsg[idx++] = 0x01 // sub-negotiation version + authMsg[idx++] = userBytes.size.toByte() + userBytes.copyInto(authMsg, idx); idx += userBytes.size + authMsg[idx++] = passBytes.size.toByte() + passBytes.copyInto(authMsg, idx) + output.write(authMsg) + output.flush() + input.read() // sub-negotiation version in response + val status = input.read() + if (status != 0x00) throw IOException("SOCKS5: authentication failed") + } + 0xFF -> throw IOException("SOCKS5: no acceptable authentication method") + else -> throw IOException("SOCKS5: unexpected method $method") + } + + // SOCKS5 CONNECT request using domain-name address type (ATYP=0x03) + val hostBytes = targetHost.toByteArray(Charsets.UTF_8) + val req = ByteArray(7 + hostBytes.size) + var idx = 0 + req[idx++] = 0x05 // VER + req[idx++] = 0x01 // CMD: CONNECT + req[idx++] = 0x00 // RSV + req[idx++] = 0x03 // ATYP: domain name + req[idx++] = hostBytes.size.toByte() + hostBytes.copyInto(req, idx); idx += hostBytes.size + req[idx++] = ((targetPort shr 8) and 0xFF).toByte() + req[idx] = (targetPort and 0xFF).toByte() + output.write(req) + output.flush() + + // Read CONNECT response + input.read() // VER + val rep = input.read() + input.read() // RSV + val atyp = input.read() + // Skip the bound address + val skipBytes = when (atyp) { + 0x01 -> 4 // IPv4 + 0x03 -> input.read() // domain: length byte followed by that many bytes + 0x04 -> 16 // IPv6 + else -> 0 + } + val skipBuf = ByteArray(skipBytes) + var read = 0 + while (read < skipBytes) read += input.read(skipBuf, read, skipBytes - read) + input.read(); input.read() // BND.PORT + + if (rep != 0x00) throw IOException("SOCKS5: CONNECT failed with reply code $rep") + return socket + } catch (e: Exception) { + socket.close() + throw e + } + } + fun registerTProxyServiceReceivers() { val application = application val startSuccessFilter = IntentFilter(TProxyService.ACTION_START) From dcd03d18d5af409be6e6222a42a0c8f931669769 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:35:38 +0000 Subject: [PATCH 21/55] Add EOF checks in SOCKS5 helper to prevent silent -1 parsing Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/c7c40ab5-44f7-4e0f-88b7-723da01fc594 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 38cf3dfa..2b954dc4 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -890,8 +890,8 @@ class MainViewModel(application: Application) : output.flush() // Server selects a method - val ver = input.read() - val method = input.read() + val ver = readByte(input) + val method = readByte(input) if (ver != 5) throw IOException("SOCKS5: unexpected server version $ver") when (method) { @@ -910,8 +910,8 @@ class MainViewModel(application: Application) : passBytes.copyInto(authMsg, idx) output.write(authMsg) output.flush() - input.read() // sub-negotiation version in response - val status = input.read() + readByte(input) // sub-negotiation version in response + val status = readByte(input) if (status != 0x00) throw IOException("SOCKS5: authentication failed") } 0xFF -> throw IOException("SOCKS5: no acceptable authentication method") @@ -934,21 +934,21 @@ class MainViewModel(application: Application) : output.flush() // Read CONNECT response - input.read() // VER - val rep = input.read() - input.read() // RSV - val atyp = input.read() + readByte(input) // VER + val rep = readByte(input) + readByte(input) // RSV + val atyp = readByte(input) // Skip the bound address val skipBytes = when (atyp) { - 0x01 -> 4 // IPv4 - 0x03 -> input.read() // domain: length byte followed by that many bytes - 0x04 -> 16 // IPv6 + 0x01 -> 4 // IPv4 + 0x03 -> readByte(input) // domain: length byte followed by that many bytes + 0x04 -> 16 // IPv6 else -> 0 } val skipBuf = ByteArray(skipBytes) var read = 0 while (read < skipBytes) read += input.read(skipBuf, read, skipBytes - read) - input.read(); input.read() // BND.PORT + readByte(input); readByte(input) // BND.PORT if (rep != 0x00) throw IOException("SOCKS5: CONNECT failed with reply code $rep") return socket @@ -958,6 +958,14 @@ class MainViewModel(application: Application) : } } + /** Reads exactly one byte from [input], throwing [IOException] on end of stream. */ + @Throws(IOException::class) + private fun readByte(input: java.io.InputStream): Int { + val b = input.read() + if (b == -1) throw IOException("SOCKS5: unexpected end of stream") + return b + } + fun registerTProxyServiceReceivers() { val application = application val startSuccessFilter = IntentFilter(TProxyService.ACTION_START) From e3b6a18a6f8f4b2651913145df317a0c48e87166 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:44:12 +0000 Subject: [PATCH 22/55] Revert HEV TUN connectivity to original; keep only Xray TUN fix Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/de9ac6a6-f648-4bec-aed7-a8acc1686048 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 175 ++++-------------- 1 file changed, 41 insertions(+), 134 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 2b954dc4..43db341a 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -783,15 +783,19 @@ class MainViewModel(application: Application) : _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_invalid_url))) return@launch } + val host = url.host + val port = if (url.port > 0) url.port else url.defaultPort + val path = if (url.path.isNullOrEmpty()) "/" else url.path + val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout val start = System.currentTimeMillis() val useXrayTun = prefs.useXrayTun && !prefs.disableVpn try { - var success = false if (useXrayTun) { // In Xray TUN mode, Xray handles the TUN interface natively and the // SimpleXray app itself is excluded from VPN routing. Use a direct // connection (no SOCKS proxy) since there may be no SOCKS inbound. + var success = false val connection = url.openConnection(Proxy.NO_PROXY) as HttpURLConnection connection.connectTimeout = timeout connection.readTimeout = timeout @@ -803,17 +807,26 @@ class MainViewModel(application: Application) : } finally { connection.disconnect() } + val latency = System.currentTimeMillis() - start + if (success) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } } else { - // In HEV TUN mode or SOCKS-only mode, connect through the SOCKS5 proxy. - // Manual SOCKS5 handshake is used to support username/password authentication. - val host = url.host - val port = if (url.port > 0) url.port else url.defaultPort - val path = if (url.path.isNullOrEmpty()) "/" else url.path - val isHttps = url.protocol == "https" - connectViaSocks5( - prefs.socksAddress, prefs.socksPort, host, port, - prefs.socksUsername, prefs.socksPassword, timeout - ).use { socket -> + val proxy = + Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) + Socket(proxy).use { socket -> + socket.soTimeout = timeout + socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket @@ -831,23 +844,25 @@ class MainViewModel(application: Application) : writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") writer.flush() val firstLine = reader.readLine() - success = firstLine != null && firstLine.startsWith("HTTP/") + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString( + R.string.connectivity_test_latency, + latency.toInt() + ) + ) + ) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } } } - val latency = System.currentTimeMillis() - start - if (success) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) - ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) - } } catch (e: Exception) { _uiEvent.trySend( MainViewUiEvent.ShowSnackbar( @@ -858,114 +873,6 @@ class MainViewModel(application: Application) : } } - /** - * Opens a SOCKS5 connection to [targetHost]:[targetPort] via the SOCKS5 proxy at - * [socksHost]:[socksPort], performing username/password authentication when - * [username] and [password] are non-empty (RFC 1929). - * - * Returns a [Socket] that is already connected through the proxy to the target. - * The caller is responsible for closing it. - */ - @Throws(IOException::class) - private fun connectViaSocks5( - socksHost: String, socksPort: Int, - targetHost: String, targetPort: Int, - username: String, password: String, - timeout: Int - ): Socket { - val socket = Socket() - try { - socket.soTimeout = timeout - socket.connect(InetSocketAddress(socksHost, socksPort), timeout) - val input = socket.getInputStream() - val output = socket.getOutputStream() - val hasCredentials = username.isNotEmpty() && password.isNotEmpty() - - // SOCKS5 greeting: advertise supported authentication methods - if (hasCredentials) { - output.write(byteArrayOf(0x05, 0x02, 0x00, 0x02)) // no-auth + user/pass - } else { - output.write(byteArrayOf(0x05, 0x01, 0x00)) // no-auth only - } - output.flush() - - // Server selects a method - val ver = readByte(input) - val method = readByte(input) - if (ver != 5) throw IOException("SOCKS5: unexpected server version $ver") - - when (method) { - 0x00 -> { /* No authentication required */ } - 0x02 -> { - // Username/password sub-negotiation (RFC 1929) - if (!hasCredentials) throw IOException("SOCKS5: server requires credentials") - val userBytes = username.toByteArray(Charsets.UTF_8) - val passBytes = password.toByteArray(Charsets.UTF_8) - val authMsg = ByteArray(3 + userBytes.size + passBytes.size) - var idx = 0 - authMsg[idx++] = 0x01 // sub-negotiation version - authMsg[idx++] = userBytes.size.toByte() - userBytes.copyInto(authMsg, idx); idx += userBytes.size - authMsg[idx++] = passBytes.size.toByte() - passBytes.copyInto(authMsg, idx) - output.write(authMsg) - output.flush() - readByte(input) // sub-negotiation version in response - val status = readByte(input) - if (status != 0x00) throw IOException("SOCKS5: authentication failed") - } - 0xFF -> throw IOException("SOCKS5: no acceptable authentication method") - else -> throw IOException("SOCKS5: unexpected method $method") - } - - // SOCKS5 CONNECT request using domain-name address type (ATYP=0x03) - val hostBytes = targetHost.toByteArray(Charsets.UTF_8) - val req = ByteArray(7 + hostBytes.size) - var idx = 0 - req[idx++] = 0x05 // VER - req[idx++] = 0x01 // CMD: CONNECT - req[idx++] = 0x00 // RSV - req[idx++] = 0x03 // ATYP: domain name - req[idx++] = hostBytes.size.toByte() - hostBytes.copyInto(req, idx); idx += hostBytes.size - req[idx++] = ((targetPort shr 8) and 0xFF).toByte() - req[idx] = (targetPort and 0xFF).toByte() - output.write(req) - output.flush() - - // Read CONNECT response - readByte(input) // VER - val rep = readByte(input) - readByte(input) // RSV - val atyp = readByte(input) - // Skip the bound address - val skipBytes = when (atyp) { - 0x01 -> 4 // IPv4 - 0x03 -> readByte(input) // domain: length byte followed by that many bytes - 0x04 -> 16 // IPv6 - else -> 0 - } - val skipBuf = ByteArray(skipBytes) - var read = 0 - while (read < skipBytes) read += input.read(skipBuf, read, skipBytes - read) - readByte(input); readByte(input) // BND.PORT - - if (rep != 0x00) throw IOException("SOCKS5: CONNECT failed with reply code $rep") - return socket - } catch (e: Exception) { - socket.close() - throw e - } - } - - /** Reads exactly one byte from [input], throwing [IOException] on end of stream. */ - @Throws(IOException::class) - private fun readByte(input: java.io.InputStream): Int { - val b = input.read() - if (b == -1) throw IOException("SOCKS5: unexpected end of stream") - return b - } - fun registerTProxyServiceReceivers() { val application = application val startSuccessFilter = IntentFilter(TProxyService.ACTION_START) From 6bc2a43633ad41588f954bfd2029a01b7d41e395 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:11:19 +0000 Subject: [PATCH 23/55] Fix connectivity test: use SOCKS proxy in Xray TUN mode too Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/35a676a4-5b05-4f24-a540-5917b93d2607 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 90 +++++++------------ 1 file changed, 30 insertions(+), 60 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 43db341a..cbe97f75 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -48,7 +48,6 @@ import java.io.BufferedReader import java.io.File import java.io.IOException import java.io.InputStreamReader -import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket @@ -789,29 +788,41 @@ class MainViewModel(application: Application) : val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout val start = System.currentTimeMillis() - val useXrayTun = prefs.useXrayTun && !prefs.disableVpn try { - if (useXrayTun) { - // In Xray TUN mode, Xray handles the TUN interface natively and the - // SimpleXray app itself is excluded from VPN routing. Use a direct - // connection (no SOCKS proxy) since there may be no SOCKS inbound. - var success = false - val connection = url.openConnection(Proxy.NO_PROXY) as HttpURLConnection - connection.connectTimeout = timeout - connection.readTimeout = timeout - connection.instanceFollowRedirects = false - connection.setRequestProperty("Connection", "close") - try { - val responseCode = connection.responseCode - success = responseCode in 100..599 - } finally { - connection.disconnect() + // Connect through the SOCKS5 proxy for both HEV TUN and Xray TUN modes. + // In Xray TUN mode the SimpleXray app is excluded from VPN routing, so the + // loopback connection to 127.0.0.1:socksPort bypasses the TUN interface and + // reaches Xray's SOCKS inbound directly, testing the full proxy chain. + val proxy = + Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) + Socket(proxy).use { socket -> + socket.soTimeout = timeout + socket.connect(InetSocketAddress(host, port), timeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() + ) } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() val latency = System.currentTimeMillis() - start - if (success) { + if (firstLine != null && firstLine.startsWith("HTTP/")) { _uiEvent.trySend( MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) + application.getString( + R.string.connectivity_test_latency, + latency.toInt() + ) ) ) } else { @@ -821,47 +832,6 @@ class MainViewModel(application: Application) : ) ) } - } else { - val proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) - Socket(proxy).use { socket -> - socket.soTimeout = timeout - socket.connect(InetSocketAddress(host, port), timeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString( - R.string.connectivity_test_latency, - latency.toInt() - ) - ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) - } - } } } catch (e: Exception) { _uiEvent.trySend( From dc8f4684c7fe8c5c733472427a546e30932454f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:20:47 +0000 Subject: [PATCH 24/55] Fix Xray TUN connectivity test: inject ephemeral SOCKS inbound at runtime Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/7a0af217-4909-40a3-82d5-7d32d72b15c1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/ConfigUtils.kt | 22 ++++++++++++++++ .../com/simplexray/an/prefs/Preferences.kt | 10 ++++++++ .../simplexray/an/service/TProxyService.kt | 22 +++++++++++++++- .../simplexray/an/viewmodel/MainViewModel.kt | 25 ++++++++++++++----- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 204546be..fdd77043 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -43,6 +43,28 @@ object ConfigUtils { return formattedContent } + /** + * Inject a temporary SOCKS5 inbound bound exclusively to 127.0.0.1 at the given [port]. + * This inbound is never written to the user's config file; it only exists in the runtime + * config passed to Xray, allowing the app to route its connectivity test through Xray even + * when the user has no SOCKS inbound in their own config. + */ + @Throws(JSONException::class) + fun injectEphemeralSocks(configContent: String, port: Int): String { + val jsonObject = JSONObject(configContent) + val inbounds = jsonObject.optJSONArray("inbounds") ?: org.json.JSONArray() + val socksInbound = JSONObject().apply { + put("listen", "127.0.0.1") + put("port", port) + put("protocol", "socks") + put("settings", JSONObject().apply { put("auth", "noauth") }) + put("tag", "ephemeral-socks") + } + inbounds.put(socksInbound) + jsonObject.put("inbounds", inbounds) + return jsonObject.toString(2) + } + @Throws(JSONException::class) fun injectStatsService(prefs: Preferences, configContent: String): String { val jsonObject = JSONObject(configContent) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index b8358d9e..505c677f 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -309,6 +309,15 @@ class Preferences(context: Context) { setValueInProvider(API_PORT, port.toString()) } + var ephemeralSocksPort: Int + get() { + val value = getPrefData(EPHEMERAL_SOCKS_PORT).first + return value?.toIntOrNull() ?: 0 + } + set(port) { + setValueInProvider(EPHEMERAL_SOCKS_PORT, port.toString()) + } + var bypassSelectedApps: Boolean get() = getBooleanPref(BYPASS_SELECTED_APPS, false) set(enable) { @@ -349,6 +358,7 @@ class Preferences(context: Context) { const val GEOSITE_URL: String = "GeositeUrl" const val API_ADDRESS: String = "ApiAddress" const val API_PORT: String = "ApiPort" + const val EPHEMERAL_SOCKS_PORT: String = "EphemeralSocksPort" const val BYPASS_SELECTED_APPS: String = "BypassSelectedApps" const val THEME: String = "Theme" private const val TAG = "Preferences" diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 3a3b73a9..a82b0ec0 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -215,9 +215,28 @@ class TProxyService : VpnService() { prefs.apiAddress = "127.$octet2.$octet3.$octet4" Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") - val injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) + val statsInjectedContent = ConfigUtils.injectStatsService(prefs, configContent) val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + // In Xray TUN mode, inject a temporary SOCKS5 inbound bound only to 127.0.0.1. + // This lets the app test connectivity even when the user's config has no SOCKS + // inbound (the user may have removed it to avoid exposing a proxy port). + val injectedConfigContent = if (useXrayTun) { + val usedPorts = extractPortsFromJson(configContent) + apiPort + val ephPort = findAvailablePort(usedPorts) + if (ephPort != null) { + prefs.ephemeralSocksPort = ephPort + Log.d(TAG, "Injected ephemeral SOCKS port: $ephPort") + ConfigUtils.injectEphemeralSocks(statsInjectedContent, ephPort) + } else { + Log.w(TAG, "Could not find available port for ephemeral SOCKS") + prefs.ephemeralSocksPort = 0 + statsInjectedContent + } + } else { + statsInjectedContent + } + val reader: BufferedReader if (useXrayTun) { @@ -435,6 +454,7 @@ class TProxyService : VpnService() { if (!prefs.useXrayTun || prefs.disableVpn) { TProxyStopService() } + prefs.ephemeralSocksPort = 0 } exit() } diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index cbe97f75..04aed36e 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -788,13 +788,26 @@ class MainViewModel(application: Application) : val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout val start = System.currentTimeMillis() + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + // In Xray TUN mode, use the ephemeral SOCKS inbound injected at startup + // (127.0.0.1 only, not exposed externally). For HEV TUN mode, use the + // user-configured SOCKS address/port. + val proxyAddress: String + val proxyPort: Int + if (useXrayTun) { + val ephPort = prefs.ephemeralSocksPort + if (ephPort <= 0) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + return@launch + } + proxyAddress = "127.0.0.1" + proxyPort = ephPort + } else { + proxyAddress = prefs.socksAddress + proxyPort = prefs.socksPort + } try { - // Connect through the SOCKS5 proxy for both HEV TUN and Xray TUN modes. - // In Xray TUN mode the SimpleXray app is excluded from VPN routing, so the - // loopback connection to 127.0.0.1:socksPort bypasses the TUN interface and - // reaches Xray's SOCKS inbound directly, testing the full proxy chain. - val proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(proxyAddress, proxyPort)) Socket(proxy).use { socket -> socket.soTimeout = timeout socket.connect(InetSocketAddress(host, port), timeout) From 511e92881d1e3c507441408423dfba27e5fa2f44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:21:40 +0000 Subject: [PATCH 25/55] Fix slash escaping in injectEphemeralSocks for consistency Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/7a0af217-4909-40a3-82d5-7d32d72b15c1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index fdd77043..14f40de4 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -62,7 +62,7 @@ object ConfigUtils { } inbounds.put(socksInbound) jsonObject.put("inbounds", inbounds) - return jsonObject.toString(2) + return jsonObject.toString(2).replace("\\/", "/") } @Throws(JSONException::class) From de44b8c606093b41b0421c7ab611b5d32e75f140 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:33:34 +0000 Subject: [PATCH 26/55] Revert ephemeral SOCKS; test via VPN-network-bound socket in Xray TUN mode Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/c69210c8-3951-44b3-b8a8-6268026a897d Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/ConfigUtils.kt | 22 ------ .../com/simplexray/an/prefs/Preferences.kt | 10 --- .../simplexray/an/service/TProxyService.kt | 22 +----- .../simplexray/an/viewmodel/MainViewModel.kt | 79 ++++++++++++++++--- 4 files changed, 67 insertions(+), 66 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 14f40de4..204546be 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -43,28 +43,6 @@ object ConfigUtils { return formattedContent } - /** - * Inject a temporary SOCKS5 inbound bound exclusively to 127.0.0.1 at the given [port]. - * This inbound is never written to the user's config file; it only exists in the runtime - * config passed to Xray, allowing the app to route its connectivity test through Xray even - * when the user has no SOCKS inbound in their own config. - */ - @Throws(JSONException::class) - fun injectEphemeralSocks(configContent: String, port: Int): String { - val jsonObject = JSONObject(configContent) - val inbounds = jsonObject.optJSONArray("inbounds") ?: org.json.JSONArray() - val socksInbound = JSONObject().apply { - put("listen", "127.0.0.1") - put("port", port) - put("protocol", "socks") - put("settings", JSONObject().apply { put("auth", "noauth") }) - put("tag", "ephemeral-socks") - } - inbounds.put(socksInbound) - jsonObject.put("inbounds", inbounds) - return jsonObject.toString(2).replace("\\/", "/") - } - @Throws(JSONException::class) fun injectStatsService(prefs: Preferences, configContent: String): String { val jsonObject = JSONObject(configContent) diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 505c677f..b8358d9e 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -309,15 +309,6 @@ class Preferences(context: Context) { setValueInProvider(API_PORT, port.toString()) } - var ephemeralSocksPort: Int - get() { - val value = getPrefData(EPHEMERAL_SOCKS_PORT).first - return value?.toIntOrNull() ?: 0 - } - set(port) { - setValueInProvider(EPHEMERAL_SOCKS_PORT, port.toString()) - } - var bypassSelectedApps: Boolean get() = getBooleanPref(BYPASS_SELECTED_APPS, false) set(enable) { @@ -358,7 +349,6 @@ class Preferences(context: Context) { const val GEOSITE_URL: String = "GeositeUrl" const val API_ADDRESS: String = "ApiAddress" const val API_PORT: String = "ApiPort" - const val EPHEMERAL_SOCKS_PORT: String = "EphemeralSocksPort" const val BYPASS_SELECTED_APPS: String = "BypassSelectedApps" const val THEME: String = "Theme" private const val TAG = "Preferences" diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index a82b0ec0..3a3b73a9 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -215,28 +215,9 @@ class TProxyService : VpnService() { prefs.apiAddress = "127.$octet2.$octet3.$octet4" Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") - val statsInjectedContent = ConfigUtils.injectStatsService(prefs, configContent) + val injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - // In Xray TUN mode, inject a temporary SOCKS5 inbound bound only to 127.0.0.1. - // This lets the app test connectivity even when the user's config has no SOCKS - // inbound (the user may have removed it to avoid exposing a proxy port). - val injectedConfigContent = if (useXrayTun) { - val usedPorts = extractPortsFromJson(configContent) + apiPort - val ephPort = findAvailablePort(usedPorts) - if (ephPort != null) { - prefs.ephemeralSocksPort = ephPort - Log.d(TAG, "Injected ephemeral SOCKS port: $ephPort") - ConfigUtils.injectEphemeralSocks(statsInjectedContent, ephPort) - } else { - Log.w(TAG, "Could not find available port for ephemeral SOCKS") - prefs.ephemeralSocksPort = 0 - statsInjectedContent - } - } else { - statsInjectedContent - } - val reader: BufferedReader if (useXrayTun) { @@ -454,7 +435,6 @@ class TProxyService : VpnService() { if (!prefs.useXrayTun || prefs.disableVpn) { TProxyStopService() } - prefs.ephemeralSocksPort = 0 } exit() } diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 04aed36e..ee9bede3 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -7,6 +7,8 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri import android.net.VpnService import android.os.Build @@ -789,25 +791,76 @@ class MainViewModel(application: Application) : val timeout = prefs.connectivityTestTimeout val start = System.currentTimeMillis() val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - // In Xray TUN mode, use the ephemeral SOCKS inbound injected at startup - // (127.0.0.1 only, not exposed externally). For HEV TUN mode, use the - // user-configured SOCKS address/port. - val proxyAddress: String - val proxyPort: Int if (useXrayTun) { - val ephPort = prefs.ephemeralSocksPort - if (ephPort <= 0) { + // In Xray TUN mode the app is excluded from VPN routing via + // addDisallowedApplication, so its sockets normally bypass the TUN. + // By explicitly binding the test socket to the VPN network we override + // that UID-based routing rule (fwmark rules take higher priority) and + // route the traffic through Xray's TUN inbound, which then proxies it + // transparently. No SOCKS inbound is needed, and no port is exposed. + val cm = application.getSystemService(ConnectivityManager::class.java) + val vpnNetwork = cm.allNetworks.firstOrNull { network -> + val caps = cm.getNetworkCapabilities(network) + caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } + if (vpnNetwork == null) { _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) return@launch } - proxyAddress = "127.0.0.1" - proxyPort = ephPort - } else { - proxyAddress = prefs.socksAddress - proxyPort = prefs.socksPort + val socket = Socket() + try { + vpnNetwork.bindSocket(socket) + socket.soTimeout = timeout + socket.connect(InetSocketAddress(host, port), timeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() + ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString( + R.string.connectivity_test_latency, + latency.toInt() + ) + ) + ) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } + } catch (e: Exception) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } finally { + socket.close() + } + return@launch } try { - val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(proxyAddress, proxyPort)) + // Connect through the SOCKS5 proxy for HEV TUN mode. + val proxy = + Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) Socket(proxy).use { socket -> socket.soTimeout = timeout socket.connect(InetSocketAddress(host, port), timeout) From 14b8780d0486744ebe90fbc09724539dec43deac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:35:06 +0000 Subject: [PATCH 27/55] Fix SSL socket timeout and resource cleanup in Xray TUN connectivity test Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/c69210c8-3951-44b3-b8a8-6268026a897d Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/viewmodel/MainViewModel.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index ee9bede3..4551cdfb 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -808,17 +808,20 @@ class MainViewModel(application: Application) : return@launch } val socket = Socket() + var sslSocket: javax.net.ssl.SSLSocket? = null try { vpnNetwork.bindSocket(socket) socket.soTimeout = timeout socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + val ssl = (SSLSocketFactory.getDefault() as SSLSocketFactory) .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() + ssl.soTimeout = timeout + ssl.startHandshake() + sslSocket = ssl Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() + ssl.outputStream.bufferedWriter(), + ssl.inputStream.bufferedReader() ) } else { Pair( @@ -853,6 +856,7 @@ class MainViewModel(application: Application) : ) ) } finally { + sslSocket?.close() socket.close() } return@launch From 67b0c646fca192f113e7dbc2e9145ac2e124ed5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:49:44 +0000 Subject: [PATCH 28/55] Fix crash in connectivity test: add ACCESS_NETWORK_STATE permission and guard allNetworks call Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/8aa8f3fc-bb58-49bf-ae75-4c49214b8f51 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 + .../com/simplexray/an/viewmodel/MainViewModel.kt | 12 ++++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7d5b586..fdf35816 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:required="false" /> + - val caps = cm.getNetworkCapabilities(network) - caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } - if (vpnNetwork == null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@launch - } val socket = Socket() var sslSocket: javax.net.ssl.SSLSocket? = null try { + val vpnNetwork = cm.allNetworks.firstOrNull { network -> + val caps = cm.getNetworkCapabilities(network) + caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } ?: throw Exception("VPN network not found") vpnNetwork.bindSocket(socket) socket.soTimeout = timeout socket.connect(InetSocketAddress(host, port), timeout) From 41f963656243f9f6b4096570845372d22c675be7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:14:18 +0000 Subject: [PATCH 29/55] Fix connectivity test in Xray TUN mode: use Network.openConnection() for correct DNS and routing Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/34a2016c-578b-49b9-a9d5-eb23be62b17f Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 72 ++++++++----------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 276d75db..269aca30 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -50,6 +50,7 @@ import java.io.BufferedReader import java.io.File import java.io.IOException import java.io.InputStreamReader +import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket @@ -794,56 +795,42 @@ class MainViewModel(application: Application) : if (useXrayTun) { // In Xray TUN mode the app is excluded from VPN routing via // addDisallowedApplication, so its sockets normally bypass the TUN. - // By explicitly binding the test socket to the VPN network we override - // that UID-based routing rule (fwmark rules take higher priority) and - // route the traffic through Xray's TUN inbound, which then proxies it - // transparently. No SOCKS inbound is needed, and no port is exposed. + // Network.openConnection() binds both DNS resolution and the TCP + // connection to the VPN network, so traffic is routed through + // Xray's TUN inbound (including FakeDNS support) without needing + // a SOCKS inbound or manual socket manipulation. val cm = application.getSystemService(ConnectivityManager::class.java) - val socket = Socket() - var sslSocket: javax.net.ssl.SSLSocket? = null try { val vpnNetwork = cm.allNetworks.firstOrNull { network -> val caps = cm.getNetworkCapabilities(network) caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } ?: throw Exception("VPN network not found") - vpnNetwork.bindSocket(socket) - socket.soTimeout = timeout - socket.connect(InetSocketAddress(host, port), timeout) - val (writer, reader) = if (isHttps) { - val ssl = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - ssl.soTimeout = timeout - ssl.startHandshake() - sslSocket = ssl - Pair( - ssl.outputStream.bufferedWriter(), - ssl.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString( - R.string.connectivity_test_latency, - latency.toInt() + val conn = vpnNetwork.openConnection(url) as HttpURLConnection + conn.connectTimeout = timeout + conn.readTimeout = timeout + conn.instanceFollowRedirects = false + try { + conn.connect() + val responseCode = conn.responseCode + val latency = System.currentTimeMillis() - start + if (responseCode > 0) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString( + R.string.connectivity_test_latency, + latency.toInt() + ) ) ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) ) - ) + } + } finally { + conn.disconnect() } } catch (e: Exception) { _uiEvent.trySend( @@ -851,9 +838,6 @@ class MainViewModel(application: Application) : application.getString(R.string.connectivity_test_failed) ) ) - } finally { - sslSocket?.close() - socket.close() } return@launch } From b9e57fe5222ceec87f27e9772f58641e2ee65da3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:34:03 +0000 Subject: [PATCH 30/55] Fix HEV TUN connectivity test: use createUnresolved to send hostname to SOCKS5 proxy Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/72025bd5-b60c-41ae-8567-c6067023be5a Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../kotlin/com/simplexray/an/viewmodel/MainViewModel.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 269aca30..e2dce3a0 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -843,14 +843,19 @@ class MainViewModel(application: Application) : } try { // Connect through the SOCKS5 proxy for HEV TUN mode. + // Use createUnresolved so the hostname is forwarded to the SOCKS5 proxy + // rather than being resolved locally via system DNS first. Local DNS + // resolution can return a real (non-CN) Google IP that Xray routes via + // the freedom (direct) outbound instead of the proxy outbound. val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) Socket(proxy).use { socket -> socket.soTimeout = timeout - socket.connect(InetSocketAddress(host, port), timeout) + socket.connect(InetSocketAddress.createUnresolved(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.soTimeout = timeout sslSocket.startHandshake() Pair( sslSocket.outputStream.bufferedWriter(), From 15bbcfc6282dcfac550bbbf38f41d3f01bf68d30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:45:56 +0000 Subject: [PATCH 31/55] Fix Xray TUN connectivity test: remove special branch, use SOCKS5 for both modes Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3880f20b-4c5a-4601-a518-35f22e599235 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 - .../simplexray/an/viewmodel/MainViewModel.kt | 63 +------------------ 2 files changed, 3 insertions(+), 61 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdf35816..e7d5b586 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,6 @@ android:required="false" /> - - val caps = cm.getNetworkCapabilities(network) - caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } ?: throw Exception("VPN network not found") - val conn = vpnNetwork.openConnection(url) as HttpURLConnection - conn.connectTimeout = timeout - conn.readTimeout = timeout - conn.instanceFollowRedirects = false - try { - conn.connect() - val responseCode = conn.responseCode - val latency = System.currentTimeMillis() - start - if (responseCode > 0) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString( - R.string.connectivity_test_latency, - latency.toInt() - ) - ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) - } - } finally { - conn.disconnect() - } - } catch (e: Exception) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) - } - return@launch - } try { - // Connect through the SOCKS5 proxy for HEV TUN mode. - // Use createUnresolved so the hostname is forwarded to the SOCKS5 proxy - // rather than being resolved locally via system DNS first. Local DNS - // resolution can return a real (non-CN) Google IP that Xray routes via - // the freedom (direct) outbound instead of the proxy outbound. + // Connect through the SOCKS5 proxy. Xray always runs a SOCKS5 inbound + // regardless of whether it is in HEV TUN or Xray TUN mode. val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) Socket(proxy).use { socket -> socket.soTimeout = timeout - socket.connect(InetSocketAddress.createUnresolved(host, port), timeout) + socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.soTimeout = timeout sslSocket.startHandshake() Pair( sslSocket.outputStream.bufferedWriter(), From 466d737aa89bc92c1e822b0c6fa4eb6ecfd6a667 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:49:10 +0000 Subject: [PATCH 32/55] Fix Xray TUN connectivity test: bind to VPN network via ConnectivityManager Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/0d76e9e6-73f5-4471-9c66-ae9dce5659d0 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 + .../simplexray/an/viewmodel/MainViewModel.kt | 61 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7d5b586..fdf35816 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:required="false" /> + + val caps = cm.getNetworkCapabilities(network) + caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } + if (vpnNetwork == null) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + return@launch + } + try { + val conn = vpnNetwork.openConnection(url) as HttpURLConnection + conn.connectTimeout = timeout + conn.readTimeout = timeout + conn.instanceFollowRedirects = false + try { + val responseCode = conn.responseCode + val latency = System.currentTimeMillis() - start + if (responseCode in 100..599) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString( + R.string.connectivity_test_latency, + latency.toInt() + ) + ) + ) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } + } finally { + conn.disconnect() + } + } catch (e: Exception) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_failed) + ) + ) + } + return@launch + } try { - // Connect through the SOCKS5 proxy. Xray always runs a SOCKS5 inbound - // regardless of whether it is in HEV TUN or Xray TUN mode. + // HEV TUN mode: connect through the SOCKS5 proxy. val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) Socket(proxy).use { socket -> From 0d95f19584c7cd7124256067bb5034f32fa57b14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:20:44 +0000 Subject: [PATCH 33/55] Fix Xray TUN connectivity test: use direct internet test (excluded UID cannot see VPN network) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/f6a6bb05-a98f-4a2c-9aba-4bb6baa47f6b Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 60ef8062..d32722d8 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -7,8 +7,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager -import android.net.ConnectivityManager -import android.net.NetworkCapabilities import android.net.Uri import android.net.VpnService import android.os.Build @@ -794,24 +792,14 @@ class MainViewModel(application: Application) : val useXrayTun = prefs.useXrayTun && !prefs.disableVpn if (useXrayTun) { // In Xray TUN mode the app is excluded from VPN routing via - // addDisallowedApplication, so its sockets bypass the TUN by default. - // Explicitly bind to the VPN network so the connection goes through - // Xray's TUN inbound without needing a SOCKS inbound. - val cm = application.getSystemService(ConnectivityManager::class.java) - val vpnNetwork = cm.allNetworks.firstOrNull { network -> - val caps = cm.getNetworkCapabilities(network) - caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } - if (vpnNetwork == null) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) - return@launch - } + // addDisallowedApplication (to prevent Xray's outbound from looping through + // the TUN). As a result, the app's own sockets also bypass the TUN and go + // directly to the internet. ConnectivityManager.getAllNetworks() filters out + // VPN networks for excluded UIDs, so vpnNetwork would always be null and + // Network.openConnection() would be unreliable even with a cached reference. + // We therefore perform a direct internet test without any VPN/SOCKS binding. try { - val conn = vpnNetwork.openConnection(url) as HttpURLConnection + val conn = url.openConnection() as HttpURLConnection conn.connectTimeout = timeout conn.readTimeout = timeout conn.instanceFollowRedirects = false From 9ac035aac1083a85932c411656df38290ebd3fff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:56:50 +0000 Subject: [PATCH 34/55] feat: Xray TUN connectivity test via runtime SOCKS inbound (HandlerService gRPC + random credentials) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/6bb4aa98-f76b-492e-992d-2a9400fbad1b Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 1 - .../com/simplexray/an/common/ConfigUtils.kt | 1 + .../an/common/HandlerServiceClient.kt | 81 +++++++++++++ .../simplexray/an/viewmodel/MainViewModel.kt | 107 ++++++++++++------ app/src/main/proto/core_config.proto | 15 +++ app/src/main/proto/handler_command.proto | 27 +++++ app/src/main/proto/proxyman_config.proto | 22 ++++ app/src/main/proto/socks_config.proto | 24 ++++ 8 files changed, 240 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt create mode 100644 app/src/main/proto/core_config.proto create mode 100644 app/src/main/proto/handler_command.proto create mode 100644 app/src/main/proto/proxyman_config.proto create mode 100644 app/src/main/proto/socks_config.proto diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdf35816..e7d5b586 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,6 @@ android:required="false" /> - + socket.soTimeout = timeout + socket.connect(InetSocketAddress.createUnresolved(host, port), timeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() ) - ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } } + } catch (e: Exception) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) } finally { - conn.disconnect() + java.net.Authenticator.setDefault(prevAuth) } - } catch (e: Exception) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) - ) - ) + } finally { + handlerClient.removeInbound(tag) + handlerClient.close() } return@launch } + + // HEV TUN mode: connect through the user-configured SOCKS5 proxy. + val proxy = + Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) try { - // HEV TUN mode: connect through the SOCKS5 proxy. - val proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) Socket(proxy).use { socket -> socket.soTimeout = timeout socket.connect(InetSocketAddress(host, port), timeout) diff --git a/app/src/main/proto/core_config.proto b/app/src/main/proto/core_config.proto new file mode 100644 index 00000000..5e25aeec --- /dev/null +++ b/app/src/main/proto/core_config.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package xray.core; + +option java_package = "com.xray.core"; +option java_multiple_files = true; + +import "google/protobuf/any.proto"; + +// Minimal subset of xray-core/core/config.proto needed for AddInbound. +message InboundHandlerConfig { + string tag = 1; + google.protobuf.Any receiver_settings = 2; + google.protobuf.Any proxy_settings = 3; +} diff --git a/app/src/main/proto/handler_command.proto b/app/src/main/proto/handler_command.proto new file mode 100644 index 00000000..6b0ef552 --- /dev/null +++ b/app/src/main/proto/handler_command.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package xray.app.proxyman.command; + +option java_package = "com.xray.app.proxyman.command"; +option java_multiple_files = true; + +import "core_config.proto"; + +// Minimal subset of xray-core/app/proxyman/command/command.proto. + +message AddInboundRequest { + xray.core.InboundHandlerConfig inbound = 1; +} + +message AddInboundResponse {} + +message RemoveInboundRequest { + string tag = 1; +} + +message RemoveInboundResponse {} + +service HandlerService { + rpc AddInbound(AddInboundRequest) returns (AddInboundResponse) {} + rpc RemoveInbound(RemoveInboundRequest) returns (RemoveInboundResponse) {} +} diff --git a/app/src/main/proto/proxyman_config.proto b/app/src/main/proto/proxyman_config.proto new file mode 100644 index 00000000..6414b549 --- /dev/null +++ b/app/src/main/proto/proxyman_config.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package xray.app.proxyman; + +option java_package = "com.xray.app.proxyman"; +option java_multiple_files = true; + +// Minimal subset of xray-core/app/proxyman/config.proto. +// Field numbers match xray-core exactly so that serialised bytes are compatible. + +message PortRange { + uint32 From = 1; + uint32 To = 2; +} + +message PortList { + repeated PortRange range = 1; +} + +message ReceiverConfig { + PortList port_range = 1; +} diff --git a/app/src/main/proto/socks_config.proto b/app/src/main/proto/socks_config.proto new file mode 100644 index 00000000..85f61076 --- /dev/null +++ b/app/src/main/proto/socks_config.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package xray.proxy.socks; + +option java_package = "com.xray.proxy.socks"; +option java_multiple_files = true; + +// Minimal subset of xray-core/proxy/socks/config.proto. +// Field numbers match xray-core exactly so that serialised bytes are compatible. + +enum AuthType { + NO_AUTH = 0; + PASSWORD = 1; +} + +message Account { + string username = 1; + string password = 2; +} + +message ServerConfig { + AuthType auth_type = 1; + repeated Account accounts = 2; +} From 1b75f673d2f0d605c9f2aed9fcea69ef3abb1d49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:34:04 +0000 Subject: [PATCH 35/55] fix: compilation error, 127.0.0.1 listen, high port, 3s SOCKS timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Authenticator.getDefault() (not on Android API) by extracting the global SOCKS authenticator as a ViewModel field; restore it in finally - Add net_address.proto with IPOrDomain matching xray-core field numbers - Update proxyman_config.proto: rename port_range→port_list (field 1) and add listen=127.0.0.1 at field 2 per actual xray-core ReceiverConfig - HandlerServiceClient: set listen=127.0.0.1 and use setPortList() - Use random high port (32768-60999) to avoid well-known port conflicts - Cap SOCKS inbound lifetime to 3s via socket timeouts + withTimeout guard Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/2c360ef6-8b49-419f-b9e0-0cb011e6910d Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/common/HandlerServiceClient.kt | 14 +- .../simplexray/an/viewmodel/MainViewModel.kt | 127 +++++++++++------- app/src/main/proto/net_address.proto | 16 +++ app/src/main/proto/proxyman_config.proto | 5 +- 4 files changed, 107 insertions(+), 55 deletions(-) create mode 100644 app/src/main/proto/net_address.proto diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index 363a6b44..b360c61f 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -1,12 +1,14 @@ package com.simplexray.an.common import com.google.protobuf.Any +import com.google.protobuf.ByteString import com.xray.app.proxyman.PortList import com.xray.app.proxyman.PortRange import com.xray.app.proxyman.ReceiverConfig import com.xray.app.proxyman.command.AddInboundRequest import com.xray.app.proxyman.command.HandlerServiceGrpc import com.xray.app.proxyman.command.RemoveInboundRequest +import com.xray.common.net.IPOrDomain import com.xray.core.InboundHandlerConfig import com.xray.proxy.socks.Account import com.xray.proxy.socks.AuthType @@ -24,8 +26,8 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { .withDeadlineAfter(5, TimeUnit.SECONDS) /** - * Ask Xray to create a SOCKS5 inbound on [port] with password authentication. - * Returns true if the RPC call succeeded. + * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1, with + * password authentication. Returns true if the RPC call succeeded. */ suspend fun addSocksInbound( tag: String, @@ -36,7 +38,13 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { runCatching { val portRange = PortRange.newBuilder().setFrom(port).setTo(port).build() val portList = PortList.newBuilder().addRange(portRange).build() - val receiverConfig = ReceiverConfig.newBuilder().setPortRange(portList).build() + val listenAddr = IPOrDomain.newBuilder() + .setIp(ByteString.copyFrom(byteArrayOf(127, 0, 0, 1))) + .build() + val receiverConfig = ReceiverConfig.newBuilder() + .setPortList(portList) + .setListen(listenAddr) + .build() val account = Account.newBuilder() .setUsername(username) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index dd6f007c..50ede255 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.Callback import okhttp3.OkHttpClient @@ -59,6 +60,9 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" +/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ +private const val SOCKS_LIFETIME_MS = 3_000L + sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -75,6 +79,19 @@ class MainViewModel(application: Application) : private var coreStatsClient: CoreStatsClient? = null + // Held so we can restore it after the temporary connectivity-test authenticator. + private val globalSocksAuthenticator = object : java.net.Authenticator() { + override fun getPasswordAuthentication(): java.net.PasswordAuthentication? { + val user = prefs.socksUsername + val pass = prefs.socksPassword + return if (user.isNotEmpty() || pass.isNotEmpty()) { + java.net.PasswordAuthentication(user, pass.toCharArray()) + } else { + null + } + } + } + private val fileManager: FileManager = FileManager(application, prefs) var reloadView: (() -> Unit)? = null @@ -237,18 +254,7 @@ class MainViewModel(application: Application) : } private fun setupGlobalSocksAuthenticator() { - java.net.Authenticator.setDefault(object : java.net.Authenticator() { - override fun getPasswordAuthentication(): java.net.PasswordAuthentication? { - val user = prefs.socksUsername - val pass = prefs.socksPassword - - return if (user.isNotEmpty() || pass.isNotEmpty()) { - java.net.PasswordAuthentication(user, pass.toCharArray()) - } else { - null - } - } - }) + java.net.Authenticator.setDefault(globalSocksAuthenticator) } fun setControlMenuClickable(isClickable: Boolean) { @@ -794,12 +800,24 @@ class MainViewModel(application: Application) : if (useXrayTun) { // The app is excluded from VPN routing via addDisallowedApplication so it // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its - // HandlerService gRPC API) to create a temporary SOCKS5 inbound with random - // credentials, run the HTTP test through it, then remove the inbound. + // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to + // 127.0.0.1 with random credentials, run the HTTP test through it, then + // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS + // so the port is never exposed for more than a few seconds. val tag = "connectivity-test-temp" - val testPort = runCatching { - java.net.ServerSocket(0).use { it.localPort } - }.getOrNull() + // Pick a random high port (32768-60999) to avoid well-known port conflicts. + val testPort = run { + val rng = java.util.Random() + var port: Int? = null + repeat(20) { + val candidate = 32768 + rng.nextInt(28232) + if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { + port = candidate + return@repeat + } + } + port + } if (testPort == null) { _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) return@launch @@ -815,50 +833,57 @@ class MainViewModel(application: Application) : return@launch } // Give Xray a moment to bind the new inbound port. - kotlinx.coroutines.delay(500) + kotlinx.coroutines.delay(300) try { + // Cap socket operations to SOCKS_LIFETIME_MS; withTimeout provides an + // outer coroutine-level guard for any suspending calls in the block. + val socksTimeout = SOCKS_LIFETIME_MS.toInt() val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", testPort)) - val prevAuth = java.net.Authenticator.getDefault() - java.net.Authenticator.setDefault(object : java.net.Authenticator() { + val tempAuth = object : java.net.Authenticator() { override fun getPasswordAuthentication() = java.net.PasswordAuthentication(testUser, testPass.toCharArray()) - }) + } + java.net.Authenticator.setDefault(tempAuth) try { - Socket(proxy).use { socket -> - socket.soTimeout = timeout - socket.connect(InetSocketAddress.createUnresolved(host, port), timeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) - ) - ) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + withTimeout(SOCKS_LIFETIME_MS) { + withContext(Dispatchers.IO) { + Socket(proxy).use { socket -> + socket.soTimeout = socksTimeout + socket.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() + ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } + } } } } catch (e: Exception) { _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) } finally { - java.net.Authenticator.setDefault(prevAuth) + java.net.Authenticator.setDefault(globalSocksAuthenticator) } } finally { handlerClient.removeInbound(tag) diff --git a/app/src/main/proto/net_address.proto b/app/src/main/proto/net_address.proto new file mode 100644 index 00000000..50284ad1 --- /dev/null +++ b/app/src/main/proto/net_address.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package xray.common.net; + +option java_package = "com.xray.common.net"; +option java_multiple_files = true; + +// Minimal subset of xray-core/common/net/address.proto. +// Field numbers match xray-core exactly so that serialised bytes are compatible. + +message IPOrDomain { + oneof address { + bytes ip = 1; + string domain = 2; + } +} diff --git a/app/src/main/proto/proxyman_config.proto b/app/src/main/proto/proxyman_config.proto index 6414b549..3edfacfc 100644 --- a/app/src/main/proto/proxyman_config.proto +++ b/app/src/main/proto/proxyman_config.proto @@ -5,6 +5,8 @@ package xray.app.proxyman; option java_package = "com.xray.app.proxyman"; option java_multiple_files = true; +import "net_address.proto"; + // Minimal subset of xray-core/app/proxyman/config.proto. // Field numbers match xray-core exactly so that serialised bytes are compatible. @@ -18,5 +20,6 @@ message PortList { } message ReceiverConfig { - PortList port_range = 1; + PortList port_list = 1; + xray.common.net.IPOrDomain listen = 2; } From 6a74f465e4d4a3e3df0fab86c09b7a247d93bb2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:35:22 +0000 Subject: [PATCH 36/55] refine: SecureRandom for port, named constants, localhost comment Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/2c360ef6-8b49-419f-b9e0-0cb011e6910d Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/HandlerServiceClient.kt | 2 +- .../kotlin/com/simplexray/an/viewmodel/MainViewModel.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index b360c61f..3095432c 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -39,7 +39,7 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { val portRange = PortRange.newBuilder().setFrom(port).setTo(port).build() val portList = PortList.newBuilder().addRange(portRange).build() val listenAddr = IPOrDomain.newBuilder() - .setIp(ByteString.copyFrom(byteArrayOf(127, 0, 0, 1))) + .setIp(ByteString.copyFrom(byteArrayOf(127, 0, 0, 1))) // 127.0.0.1 .build() val receiverConfig = ReceiverConfig.newBuilder() .setPortList(portList) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 50ede255..43ce48f4 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -63,6 +63,9 @@ private const val TAG = "MainViewModel" /** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ private const val SOCKS_LIFETIME_MS = 3_000L +/** Time (ms) to wait after adding the SOCKS5 inbound before Xray has fully bound the port. */ +private const val INBOUND_BIND_DELAY_MS = 300L + sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -807,7 +810,7 @@ class MainViewModel(application: Application) : val tag = "connectivity-test-temp" // Pick a random high port (32768-60999) to avoid well-known port conflicts. val testPort = run { - val rng = java.util.Random() + val rng = java.security.SecureRandom() var port: Int? = null repeat(20) { val candidate = 32768 + rng.nextInt(28232) @@ -833,7 +836,7 @@ class MainViewModel(application: Application) : return@launch } // Give Xray a moment to bind the new inbound port. - kotlinx.coroutines.delay(300) + kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) try { // Cap socket operations to SOCKS_LIFETIME_MS; withTimeout provides an // outer coroutine-level guard for any suspending calls in the block. From dce7f40366b9e783d8e6b3a52d0958cb124a607a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:00:11 +0000 Subject: [PATCH 37/55] fix: xray TUN connectivity - NO_AUTH SOCKS5, retry logic, error logging - HandlerServiceClient: switch temp inbound to NO_AUTH (eliminates Java SOCKS5 + Authenticator unreliability on Android); add Log.e to expose gRPC failures in logcat; remove username/password parameters - MainViewModel: remove testUser/testPass and global Authenticator override; add retry loop (5x, 300ms delay) so xray has time to bind the port after AddInbound returns; raise SOCKS_LIFETIME_MS to 5s to cover retries Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/872967f7-d28f-471e-a2cd-64729f165174 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/common/HandlerServiceClient.kt | 32 +++--- .../simplexray/an/viewmodel/MainViewModel.kt | 102 ++++++++++-------- 2 files changed, 71 insertions(+), 63 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index 3095432c..890a3660 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -1,5 +1,6 @@ package com.simplexray.an.common +import android.util.Log import com.google.protobuf.Any import com.google.protobuf.ByteString import com.xray.app.proxyman.PortList @@ -10,7 +11,6 @@ import com.xray.app.proxyman.command.HandlerServiceGrpc import com.xray.app.proxyman.command.RemoveInboundRequest import com.xray.common.net.IPOrDomain import com.xray.core.InboundHandlerConfig -import com.xray.proxy.socks.Account import com.xray.proxy.socks.AuthType import com.xray.proxy.socks.ServerConfig import io.grpc.ManagedChannel @@ -20,6 +20,8 @@ import kotlinx.coroutines.withContext import java.io.Closeable import java.util.concurrent.TimeUnit +private const val TAG = "HandlerServiceClient" + class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { private val stub = HandlerServiceGrpc.newBlockingStub(channel) @@ -27,14 +29,11 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { /** * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1, with - * password authentication. Returns true if the RPC call succeeded. + * NO_AUTH so that Java's built-in SOCKS5 socket can use it without + * authentication overhead. The short, random port and 3-second lifetime + * keep the exposure window minimal. Returns true if the RPC call succeeded. */ - suspend fun addSocksInbound( - tag: String, - port: Int, - username: String, - password: String, - ): Boolean = withContext(Dispatchers.IO) { + suspend fun addSocksInbound(tag: String, port: Int): Boolean = withContext(Dispatchers.IO) { runCatching { val portRange = PortRange.newBuilder().setFrom(port).setTo(port).build() val portList = PortList.newBuilder().addRange(portRange).build() @@ -46,13 +45,8 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { .setListen(listenAddr) .build() - val account = Account.newBuilder() - .setUsername(username) - .setPassword(password) - .build() val socksConfig = ServerConfig.newBuilder() - .setAuthType(AuthType.PASSWORD) - .addAccounts(account) + .setAuthType(AuthType.NO_AUTH) .build() val inboundConfig = InboundHandlerConfig.newBuilder() @@ -63,7 +57,10 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { stub.addInbound(AddInboundRequest.newBuilder().setInbound(inboundConfig).build()) true - }.getOrElse { false } + }.getOrElse { e -> + Log.e(TAG, "addSocksInbound failed (tag=$tag, port=$port)", e) + false + } } /** Ask Xray to remove the inbound with the given [tag]. Returns true on success. */ @@ -71,7 +68,10 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { runCatching { stub.removeInbound(RemoveInboundRequest.newBuilder().setTag(tag).build()) true - }.getOrElse { false } + }.getOrElse { e -> + Log.e(TAG, "removeInbound failed (tag=$tag)", e) + false + } } override fun close() { diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 43ce48f4..13a8637f 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -61,11 +61,14 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" /** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ -private const val SOCKS_LIFETIME_MS = 3_000L +private const val SOCKS_LIFETIME_MS = 5_000L -/** Time (ms) to wait after adding the SOCKS5 inbound before Xray has fully bound the port. */ +/** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ private const val INBOUND_BIND_DELAY_MS = 300L +/** Number of times to retry connecting to the inbound before giving up. */ +private const val INBOUND_BIND_RETRIES = 5 + sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -803,10 +806,9 @@ class MainViewModel(application: Application) : if (useXrayTun) { // The app is excluded from VPN routing via addDisallowedApplication so it // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its - // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to - // 127.0.0.1 with random credentials, run the HTTP test through it, then - // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS - // so the port is never exposed for more than a few seconds. + // HandlerService gRPC API) to create a temporary NO_AUTH SOCKS5 inbound + // bound to 127.0.0.1 with a random high port, run the HTTP test through it, + // then remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. val tag = "connectivity-test-temp" // Pick a random high port (32768-60999) to avoid well-known port conflicts. val testPort = run { @@ -825,68 +827,74 @@ class MainViewModel(application: Application) : _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) return@launch } - val testUser = java.util.UUID.randomUUID().toString().replace("-", "").take(8) - val testPass = java.util.UUID.randomUUID().toString().replace("-", "") val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) - val added = handlerClient.addSocksInbound(tag, testPort, testUser, testPass) + val added = handlerClient.addSocksInbound(tag, testPort) if (!added) { handlerClient.close() _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) return@launch } - // Give Xray a moment to bind the new inbound port. - kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) try { // Cap socket operations to SOCKS_LIFETIME_MS; withTimeout provides an // outer coroutine-level guard for any suspending calls in the block. val socksTimeout = SOCKS_LIFETIME_MS.toInt() val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", testPort)) - val tempAuth = object : java.net.Authenticator() { - override fun getPasswordAuthentication() = - java.net.PasswordAuthentication(testUser, testPass.toCharArray()) - } - java.net.Authenticator.setDefault(tempAuth) try { withTimeout(SOCKS_LIFETIME_MS) { withContext(Dispatchers.IO) { - Socket(proxy).use { socket -> - socket.soTimeout = socksTimeout - socket.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) - ) - ) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + // Retry connecting to the inbound: xray may need a moment + // to bind the port after AddInbound returns. + var lastException: Exception? = null + for (attempt in 1..INBOUND_BIND_RETRIES) { + Thread.sleep(INBOUND_BIND_DELAY_MS) + val socket = runCatching { Socket(proxy) }.getOrNull() + if (socket == null) continue + lastException = null + try { + socket.use { + it.soTimeout = socksTimeout + it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + it.getOutputStream().bufferedWriter(), + it.getInputStream().bufferedReader() + ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } + } + return@withContext + } catch (e: Exception) { + lastException = e } } + if (lastException != null) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } } } } catch (e: Exception) { _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } finally { - java.net.Authenticator.setDefault(globalSocksAuthenticator) } } finally { handlerClient.removeInbound(tag) From c611bf1e8d79277cd73c44c698f3c073666d17ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:01:25 +0000 Subject: [PATCH 38/55] refine: fix review issues - delay(), socket error capture, comment wording Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/872967f7-d28f-471e-a2cd-64729f165174 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../com/simplexray/an/common/HandlerServiceClient.kt | 2 +- .../kotlin/com/simplexray/an/viewmodel/MainViewModel.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index 890a3660..b79053af 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -30,7 +30,7 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { /** * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1, with * NO_AUTH so that Java's built-in SOCKS5 socket can use it without - * authentication overhead. The short, random port and 3-second lifetime + * authentication overhead. The short, random port and 5-second lifetime * keep the exposure window minimal. Returns true if the RPC call succeeded. */ suspend fun addSocksInbound(tag: String, port: Int): Boolean = withContext(Dispatchers.IO) { diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 13a8637f..01c07957 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -847,8 +847,11 @@ class MainViewModel(application: Application) : // to bind the port after AddInbound returns. var lastException: Exception? = null for (attempt in 1..INBOUND_BIND_RETRIES) { - Thread.sleep(INBOUND_BIND_DELAY_MS) - val socket = runCatching { Socket(proxy) }.getOrNull() + kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) + val socket = runCatching { Socket(proxy) }.getOrElse { e -> + lastException = e as? Exception ?: RuntimeException(e) + null + } if (socket == null) continue lastException = null try { From fde5b9cb51ab1e659188c74b6575068c7d551ed3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:09:04 +0000 Subject: [PATCH 39/55] fix: use GUI SOCKS5 credentials for xray TUN connectivity test inbound Restore PASSWORD auth in HandlerServiceClient (falls back to NO_AUTH if credentials are empty). MainViewModel now reads prefs.socksUsername / prefs.socksPassword (the GUI settings) instead of generating random credentials - the existing globalSocksAuthenticator already holds these values so no temporary Authenticator override is needed. Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/287d5480-27a4-43c1-96b4-18510d000489 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/common/HandlerServiceClient.kt | 32 ++++++++++++++----- .../simplexray/an/viewmodel/MainViewModel.kt | 12 ++++--- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index b79053af..16953895 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -11,6 +11,7 @@ import com.xray.app.proxyman.command.HandlerServiceGrpc import com.xray.app.proxyman.command.RemoveInboundRequest import com.xray.common.net.IPOrDomain import com.xray.core.InboundHandlerConfig +import com.xray.proxy.socks.Account import com.xray.proxy.socks.AuthType import com.xray.proxy.socks.ServerConfig import io.grpc.ManagedChannel @@ -28,12 +29,16 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { .withDeadlineAfter(5, TimeUnit.SECONDS) /** - * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1, with - * NO_AUTH so that Java's built-in SOCKS5 socket can use it without - * authentication overhead. The short, random port and 5-second lifetime - * keep the exposure window minimal. Returns true if the RPC call succeeded. + * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1. + * Uses PASSWORD auth with [username]/[password] when both are non-empty, + * otherwise falls back to NO_AUTH. Returns true if the RPC call succeeded. */ - suspend fun addSocksInbound(tag: String, port: Int): Boolean = withContext(Dispatchers.IO) { + suspend fun addSocksInbound( + tag: String, + port: Int, + username: String, + password: String, + ): Boolean = withContext(Dispatchers.IO) { runCatching { val portRange = PortRange.newBuilder().setFrom(port).setTo(port).build() val portList = PortList.newBuilder().addRange(portRange).build() @@ -45,9 +50,20 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { .setListen(listenAddr) .build() - val socksConfig = ServerConfig.newBuilder() - .setAuthType(AuthType.NO_AUTH) - .build() + val socksConfig = if (username.isNotEmpty() && password.isNotEmpty()) { + val account = Account.newBuilder() + .setUsername(username) + .setPassword(password) + .build() + ServerConfig.newBuilder() + .setAuthType(AuthType.PASSWORD) + .addAccounts(account) + .build() + } else { + ServerConfig.newBuilder() + .setAuthType(AuthType.NO_AUTH) + .build() + } val inboundConfig = InboundHandlerConfig.newBuilder() .setTag(tag) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 01c07957..8d309020 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -806,10 +806,14 @@ class MainViewModel(application: Application) : if (useXrayTun) { // The app is excluded from VPN routing via addDisallowedApplication so it // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its - // HandlerService gRPC API) to create a temporary NO_AUTH SOCKS5 inbound - // bound to 127.0.0.1 with a random high port, run the HTTP test through it, - // then remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. + // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to + // 127.0.0.1 with a random high port, run the HTTP test through it, then + // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. val tag = "connectivity-test-temp" + // Use the GUI-configured SOCKS5 credentials. The globalSocksAuthenticator + // already holds these so no temporary authenticator override is needed. + val testUser = prefs.socksUsername + val testPass = prefs.socksPassword // Pick a random high port (32768-60999) to avoid well-known port conflicts. val testPort = run { val rng = java.security.SecureRandom() @@ -829,7 +833,7 @@ class MainViewModel(application: Application) : } val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) - val added = handlerClient.addSocksInbound(tag, testPort) + val added = handlerClient.addSocksInbound(tag, testPort, testUser, testPass) if (!added) { handlerClient.close() _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) From 38dbefa7b8ece73a9714eb7887d22118bb6159a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:38:25 +0000 Subject: [PATCH 40/55] Fix xray TUN connectivity: use TypedMessage with bare type names instead of Any.pack() Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/b4ebc246-8d5f-4cdf-99d9-49aab1297b81 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/common/HandlerServiceClient.kt | 23 +++++++++++-------- app/src/main/proto/core_config.proto | 7 +++--- app/src/main/proto/socks_config.proto | 7 +----- app/src/main/proto/typed_message.proto | 16 +++++++++++++ 4 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 app/src/main/proto/typed_message.proto diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt index 16953895..02aa392f 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt @@ -1,7 +1,6 @@ package com.simplexray.an.common import android.util.Log -import com.google.protobuf.Any import com.google.protobuf.ByteString import com.xray.app.proxyman.PortList import com.xray.app.proxyman.PortRange @@ -10,8 +9,8 @@ import com.xray.app.proxyman.command.AddInboundRequest import com.xray.app.proxyman.command.HandlerServiceGrpc import com.xray.app.proxyman.command.RemoveInboundRequest import com.xray.common.net.IPOrDomain +import com.xray.common.serial.TypedMessage import com.xray.core.InboundHandlerConfig -import com.xray.proxy.socks.Account import com.xray.proxy.socks.AuthType import com.xray.proxy.socks.ServerConfig import io.grpc.ManagedChannel @@ -51,13 +50,9 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { .build() val socksConfig = if (username.isNotEmpty() && password.isNotEmpty()) { - val account = Account.newBuilder() - .setUsername(username) - .setPassword(password) - .build() ServerConfig.newBuilder() .setAuthType(AuthType.PASSWORD) - .addAccounts(account) + .putAccounts(username, password) .build() } else { ServerConfig.newBuilder() @@ -67,8 +62,18 @@ class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { val inboundConfig = InboundHandlerConfig.newBuilder() .setTag(tag) - .setReceiverSettings(Any.pack(receiverConfig)) - .setProxySettings(Any.pack(socksConfig)) + .setReceiverSettings( + TypedMessage.newBuilder() + .setType("xray.app.proxyman.ReceiverConfig") + .setValue(receiverConfig.toByteString()) + .build() + ) + .setProxySettings( + TypedMessage.newBuilder() + .setType("xray.proxy.socks.ServerConfig") + .setValue(socksConfig.toByteString()) + .build() + ) .build() stub.addInbound(AddInboundRequest.newBuilder().setInbound(inboundConfig).build()) diff --git a/app/src/main/proto/core_config.proto b/app/src/main/proto/core_config.proto index 5e25aeec..18e37c79 100644 --- a/app/src/main/proto/core_config.proto +++ b/app/src/main/proto/core_config.proto @@ -5,11 +5,12 @@ package xray.core; option java_package = "com.xray.core"; option java_multiple_files = true; -import "google/protobuf/any.proto"; +import "typed_message.proto"; // Minimal subset of xray-core/core/config.proto needed for AddInbound. +// Field numbers match xray-core exactly so that serialised bytes are compatible. message InboundHandlerConfig { string tag = 1; - google.protobuf.Any receiver_settings = 2; - google.protobuf.Any proxy_settings = 3; + xray.common.serial.TypedMessage receiver_settings = 2; + xray.common.serial.TypedMessage proxy_settings = 3; } diff --git a/app/src/main/proto/socks_config.proto b/app/src/main/proto/socks_config.proto index 85f61076..fe928dd0 100644 --- a/app/src/main/proto/socks_config.proto +++ b/app/src/main/proto/socks_config.proto @@ -13,12 +13,7 @@ enum AuthType { PASSWORD = 1; } -message Account { - string username = 1; - string password = 2; -} - message ServerConfig { AuthType auth_type = 1; - repeated Account accounts = 2; + map accounts = 2; } diff --git a/app/src/main/proto/typed_message.proto b/app/src/main/proto/typed_message.proto new file mode 100644 index 00000000..c50f5b34 --- /dev/null +++ b/app/src/main/proto/typed_message.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package xray.common.serial; + +option java_package = "com.xray.common.serial"; +option java_multiple_files = true; + +// Mirrors xray-core/common/serial/typed_message.proto exactly. +// Field numbers must match xray-core so that serialised bytes are compatible. + +message TypedMessage { + // The full proto message type name (e.g. "xray.proxy.socks.ServerConfig"). + string type = 1; + // Serialised proto message bytes. + bytes value = 2; +} From 52b758e3d7b8a7dc9998607816276fd4b7a3c89a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:52:41 +0000 Subject: [PATCH 41/55] Fix latency timing: measure only network round-trip, exclude xray TUN setup overhead Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/cacc38c1-3f37-4952-b93f-c3f808d614d1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 8d309020..6808645f 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -800,8 +800,6 @@ class MainViewModel(application: Application) : val path = if (url.path.isNullOrEmpty()) "/" else url.path val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout - val start = System.currentTimeMillis() - val useXrayTun = prefs.useXrayTun && !prefs.disableVpn if (useXrayTun) { // The app is excluded from VPN routing via addDisallowedApplication so it @@ -861,6 +859,7 @@ class MainViewModel(application: Application) : try { socket.use { it.soTimeout = socksTimeout + val start = System.currentTimeMillis() it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) @@ -916,6 +915,7 @@ class MainViewModel(application: Application) : try { Socket(proxy).use { socket -> socket.soTimeout = timeout + val start = System.currentTimeMillis() socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) From 853b9859e08fd29d77a054c13d242df2ceab53e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:45:28 +0000 Subject: [PATCH 42/55] fix: measure latency after tunnel+SSL established, not before connect() Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/d59a4d93-d619-479b-9280-304cd0bca7a9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 6808645f..00f7ca1c 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -859,7 +859,6 @@ class MainViewModel(application: Application) : try { socket.use { it.soTimeout = socksTimeout - val start = System.currentTimeMillis() it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) @@ -875,6 +874,7 @@ class MainViewModel(application: Application) : it.getInputStream().bufferedReader() ) } + val start = System.currentTimeMillis() writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") writer.flush() val firstLine = reader.readLine() @@ -915,7 +915,6 @@ class MainViewModel(application: Application) : try { Socket(proxy).use { socket -> socket.soTimeout = timeout - val start = System.currentTimeMillis() socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) @@ -931,6 +930,7 @@ class MainViewModel(application: Application) : socket.getInputStream().bufferedReader() ) } + val start = System.currentTimeMillis() writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") writer.flush() val firstLine = reader.readLine() From 1fe509524f457b0900661a31b864971136c2af96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:08:05 +0000 Subject: [PATCH 43/55] fix: xray TUN uses existing SOCKS proxy instead of cold-start temp inbound Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/e05f2080-ac35-4002-9d58-9bb5d2c99d04 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 129 +----------------- 1 file changed, 7 insertions(+), 122 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 00f7ca1c..3ccea5a0 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -20,7 +20,6 @@ import androidx.lifecycle.viewModelScope import com.simplexray.an.BuildConfig import com.simplexray.an.R import com.simplexray.an.common.CoreStatsClient -import com.simplexray.an.common.HandlerServiceClient import com.simplexray.an.common.ROUTE_APP_LIST import com.simplexray.an.common.ROUTE_CONFIG_EDIT import com.simplexray.an.common.ThemeMode @@ -40,7 +39,6 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.Callback import okhttp3.OkHttpClient @@ -60,15 +58,6 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" -/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ -private const val SOCKS_LIFETIME_MS = 5_000L - -/** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ -private const val INBOUND_BIND_DELAY_MS = 300L - -/** Number of times to retry connecting to the inbound before giving up. */ -private const val INBOUND_BIND_RETRIES = 5 - sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -800,121 +789,18 @@ class MainViewModel(application: Application) : val path = if (url.path.isNullOrEmpty()) "/" else url.path val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout - val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - if (useXrayTun) { - // The app is excluded from VPN routing via addDisallowedApplication so it - // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its - // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to - // 127.0.0.1 with a random high port, run the HTTP test through it, then - // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. - val tag = "connectivity-test-temp" - // Use the GUI-configured SOCKS5 credentials. The globalSocksAuthenticator - // already holds these so no temporary authenticator override is needed. - val testUser = prefs.socksUsername - val testPass = prefs.socksPassword - // Pick a random high port (32768-60999) to avoid well-known port conflicts. - val testPort = run { - val rng = java.security.SecureRandom() - var port: Int? = null - repeat(20) { - val candidate = 32768 + rng.nextInt(28232) - if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { - port = candidate - return@repeat - } - } - port - } - if (testPort == null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@launch - } - - val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) - val added = handlerClient.addSocksInbound(tag, testPort, testUser, testPass) - if (!added) { - handlerClient.close() - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@launch - } - try { - // Cap socket operations to SOCKS_LIFETIME_MS; withTimeout provides an - // outer coroutine-level guard for any suspending calls in the block. - val socksTimeout = SOCKS_LIFETIME_MS.toInt() - val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", testPort)) - try { - withTimeout(SOCKS_LIFETIME_MS) { - withContext(Dispatchers.IO) { - // Retry connecting to the inbound: xray may need a moment - // to bind the port after AddInbound returns. - var lastException: Exception? = null - for (attempt in 1..INBOUND_BIND_RETRIES) { - kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) - val socket = runCatching { Socket(proxy) }.getOrElse { e -> - lastException = e as? Exception ?: RuntimeException(e) - null - } - if (socket == null) continue - lastException = null - try { - socket.use { - it.soTimeout = socksTimeout - it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - it.getOutputStream().bufferedWriter(), - it.getInputStream().bufferedReader() - ) - } - val start = System.currentTimeMillis() - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) - ) - ) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } - return@withContext - } catch (e: Exception) { - lastException = e - } - } - if (lastException != null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } - } - } catch (e: Exception) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } finally { - handlerClient.removeInbound(tag) - handlerClient.close() - } - return@launch - } - - // HEV TUN mode: connect through the user-configured SOCKS5 proxy. + // Both xray TUN and HEV TUN: connect through the user-configured SOCKS5 proxy. + // The app is excluded from VPN routing so its traffic goes directly to xray's + // SOCKS5 inbound, which applies the normal routing rules (direct / proxy). + // A dedicated temporary inbound was previously used for xray TUN but it lacked + // any matching routing rule and therefore always cold-started a fresh VLESS + // connection (~400 ms overhead), producing inflated latency readings. val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) try { Socket(proxy).use { socket -> socket.soTimeout = timeout + val start = System.currentTimeMillis() socket.connect(InetSocketAddress(host, port), timeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) @@ -930,7 +816,6 @@ class MainViewModel(application: Application) : socket.getInputStream().bufferedReader() ) } - val start = System.currentTimeMillis() writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") writer.flush() val firstLine = reader.readLine() From edb075ff3d966ad2ba5d8159aad1c3ef0cb77881 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:18:00 +0000 Subject: [PATCH 44/55] fix: restore xray TUN temp inbound with warmup+timed dual-request, SOCKS_LIFETIME=10s Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/2ccc3582-412a-495f-8a96-9ed899319e00 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 163 +++++++++++++++++- 1 file changed, 157 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 3ccea5a0..226a7303 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.viewModelScope import com.simplexray.an.BuildConfig import com.simplexray.an.R import com.simplexray.an.common.CoreStatsClient +import com.simplexray.an.common.HandlerServiceClient import com.simplexray.an.common.ROUTE_APP_LIST import com.simplexray.an.common.ROUTE_CONFIG_EDIT import com.simplexray.an.common.ThemeMode @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.Callback import okhttp3.OkHttpClient @@ -58,6 +60,15 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" +/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ +private const val SOCKS_LIFETIME_MS = 10_000L + +/** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ +private const val INBOUND_BIND_DELAY_MS = 300L + +/** Number of times to retry connecting to the inbound before giving up. */ +private const val INBOUND_BIND_RETRIES = 5 + sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -789,12 +800,152 @@ class MainViewModel(application: Application) : val path = if (url.path.isNullOrEmpty()) "/" else url.path val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout - // Both xray TUN and HEV TUN: connect through the user-configured SOCKS5 proxy. - // The app is excluded from VPN routing so its traffic goes directly to xray's - // SOCKS5 inbound, which applies the normal routing rules (direct / proxy). - // A dedicated temporary inbound was previously used for xray TUN but it lacked - // any matching routing rule and therefore always cold-started a fresh VLESS - // connection (~400 ms overhead), producing inflated latency readings. + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn + if (useXrayTun) { + // The app is excluded from VPN routing via addDisallowedApplication so it + // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its + // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to + // 127.0.0.1 with a random high port, run the HTTP test through it, then + // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. + val tag = "connectivity-test-temp" + // Use the GUI-configured SOCKS5 credentials. The globalSocksAuthenticator + // already holds these so no temporary authenticator override is needed. + val testUser = prefs.socksUsername + val testPass = prefs.socksPassword + // Pick a random high port (32768-60999) to avoid well-known port conflicts. + val testPort = run { + val rng = java.security.SecureRandom() + var port: Int? = null + repeat(20) { + val candidate = 32768 + rng.nextInt(28232) + if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { + port = candidate + return@repeat + } + } + port + } + if (testPort == null) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + return@launch + } + + val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) + val added = handlerClient.addSocksInbound(tag, testPort, testUser, testPass) + if (!added) { + handlerClient.close() + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + return@launch + } + try { + val socksTimeout = SOCKS_LIFETIME_MS.toInt() + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", testPort)) + try { + withTimeout(SOCKS_LIFETIME_MS) { + withContext(Dispatchers.IO) { + // Retry connecting to the inbound: xray may need a moment + // to bind the port after AddInbound returns. + var lastException: Exception? = null + var inboundReady = false + for (attempt in 1..INBOUND_BIND_RETRIES) { + kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) + val socket = runCatching { Socket(proxy) }.getOrElse { e -> + lastException = e as? Exception ?: RuntimeException(e) + null + } + if (socket == null) continue + lastException = null + inboundReady = true + // Step 1: Warmup request – let xray establish the upstream + // VLESS connection so the second (timed) request finds it warm. + runCatching { + socket.use { + it.soTimeout = socksTimeout + it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + it.getOutputStream().bufferedWriter(), + it.getInputStream().bufferedReader() + ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + reader.readLine() // wait for warmup response + } + } + break + } + if (!inboundReady) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + return@withContext + } + // Step 2: Timed request – connection is now warm; measure latency + // from connect() through to first response byte. + val timedSocket = runCatching { Socket(proxy) }.getOrElse { e -> + lastException = e as? Exception ?: RuntimeException(e) + null + } + if (timedSocket == null) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + return@withContext + } + try { + timedSocket.use { + it.soTimeout = socksTimeout + val start = System.currentTimeMillis() + it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + it.getOutputStream().bufferedWriter(), + it.getInputStream().bufferedReader() + ) + } + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) + ) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } + } + } catch (e: Exception) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } + } + } + } catch (e: Exception) { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } + } finally { + handlerClient.removeInbound(tag) + handlerClient.close() + } + return@launch + } + + // HEV TUN mode: connect through the user-configured SOCKS5 proxy. val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) try { From c7647bda8c71772ef179b4e10f4f4d488e06a1ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:30:08 +0000 Subject: [PATCH 45/55] revert: SOCKS_LIFETIME_MS back to 5s, remove warmup request Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/8958be36-4ee3-4d12-a582-adb61879b5d1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../simplexray/an/viewmodel/MainViewModel.kt | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index 226a7303..b3e9e893 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -61,7 +61,7 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" /** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ -private const val SOCKS_LIFETIME_MS = 10_000L +private const val SOCKS_LIFETIME_MS = 5_000L /** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ private const val INBOUND_BIND_DELAY_MS = 300L @@ -846,7 +846,6 @@ class MainViewModel(application: Application) : // Retry connecting to the inbound: xray may need a moment // to bind the port after AddInbound returns. var lastException: Exception? = null - var inboundReady = false for (attempt in 1..INBOUND_BIND_RETRIES) { kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) val socket = runCatching { Socket(proxy) }.getOrElse { e -> @@ -855,12 +854,10 @@ class MainViewModel(application: Application) : } if (socket == null) continue lastException = null - inboundReady = true - // Step 1: Warmup request – let xray establish the upstream - // VLESS connection so the second (timed) request finds it warm. - runCatching { + try { socket.use { it.soTimeout = socksTimeout + val start = System.currentTimeMillis() it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) val (writer, reader) = if (isHttps) { val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) @@ -878,59 +875,24 @@ class MainViewModel(application: Application) : } writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") writer.flush() - reader.readLine() // wait for warmup response - } - } - break - } - if (!inboundReady) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@withContext - } - // Step 2: Timed request – connection is now warm; measure latency - // from connect() through to first response byte. - val timedSocket = runCatching { Socket(proxy) }.getOrElse { e -> - lastException = e as? Exception ?: RuntimeException(e) - null - } - if (timedSocket == null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@withContext - } - try { - timedSocket.use { - it.soTimeout = socksTimeout - val start = System.currentTimeMillis() - it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - it.getOutputStream().bufferedWriter(), - it.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) + ) ) - ) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) + } } + return@withContext + } catch (e: Exception) { + lastException = e } - } catch (e: Exception) { + } + if (lastException != null) { _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) } } From 21aa521652e885af75e0132aa03e4ea9558de28f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:10:58 +0800 Subject: [PATCH 46/55] feat: route TUN-mode app traffic through ephemeral SOCKS5 inbound with random credentials (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: bypass VPN tunnel for downloads via ProtectedSocketFactory - TProxyService: expose static protectSocket() via WeakReference to running instance; register in onCreate(), deregister in onDestroy() - Add ProtectedSocketFactory: delegates to default SocketFactory and calls TProxyService.protectSocket() on every new socket so OkHttp connections bypass the active TUN tunnel - MainViewModel: add buildHttpClient() helper – uses ProtectedSocketFactory in Xray TUN mode, SOCKS proxy in hev-socks5-tunnel mode, plain direct otherwise; wire it into downloadRuleFile() and checkForUpdates() - getVpnBuilder: unconditionally exclude the app itself from VPN routing Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/5b5b5a4c-4128-4764-9bf4-e867a0da2718 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * refactor: address code review feedback - Clarify protectSocket() KDoc: false = service not running (not a failure) - Add Preferences.isXrayTunActive computed property so the TUN-mode condition is defined once; use it in MainViewModel.buildHttpClient() - Add inline comment in ProtectedSocketFactory.protect() explaining exception-safety Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/5b5b5a4c-4128-4764-9bf4-e867a0da2718 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: use explicit SocketFactory.getDefault() in ProtectedSocketFactory Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/5b5b5a4c-4128-4764-9bf4-e867a0da2718 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * feat: add withTempSocksProxiedClient for TUN-mode downloads and update checks - New withTempSocksProxiedClient suspend helper: picks a random high port (32768-60999), generates 8-byte random hex username/password (independent of user-configured SOCKS credentials), creates a temporary Xray SOCKS5 inbound via HandlerService gRPC API, waits for the port to bind via a plain TCP connect probe, installs a dedicated per-session java.net.Authenticator (restores globalSocksAuthenticator in finally), builds an OkHttpClient with 30 s connect / 5 min read / 30 s write timeouts, runs the caller's block, and removes the inbound in finally. - downloadRuleFile(): in Xray TUN mode call withTempSocksProxiedClient instead of buildHttpClient/ProtectedSocketFactory; non-TUN path unchanged. - checkForUpdates(): same TUN-mode branching. - buildHttpClient(): remove dead ProtectedSocketFactory TUN branch; update KDoc. Remove ProtectedSocketFactory import; add java.util.concurrent.TimeUnit import. Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3c4be36a-a687-4a79-a2b8-e794a4678fb9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * refactor: address code review feedback on withTempSocksProxiedClient - Extract magic port numbers into named constants EPHEMERAL_PORT_RANGE_START (32768) and EPHEMERAL_PORT_RANGE_SIZE (60999 - 32768 + 1 = 28232); use them in both testConnectivity() and withTempSocksProxiedClient(). - In withTempSocksProxiedClient() finally block, restore globalSocksAuthenticator directly instead of a saved getDefault() snapshot, eliminating any risk of restoring a stale reference. Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3c4be36a-a687-4a79-a2b8-e794a4678fb9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/common/ProtectedSocketFactory.kt | 53 +++ .../com/simplexray/an/prefs/Preferences.kt | 8 + .../simplexray/an/service/TProxyService.kt | 24 +- .../simplexray/an/viewmodel/MainViewModel.kt | 335 +++++++++++++----- 4 files changed, 325 insertions(+), 95 deletions(-) create mode 100644 app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt diff --git a/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt b/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt new file mode 100644 index 00000000..0c8c6c20 --- /dev/null +++ b/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt @@ -0,0 +1,53 @@ +package com.simplexray.an.common + +import com.simplexray.an.service.TProxyService +import java.net.InetAddress +import java.net.Socket +import javax.net.SocketFactory + +/** + * A [SocketFactory] that calls [TProxyService.protectSocket] on every newly created socket. + * + * When the VPN service is active, [TProxyService.protectSocket] marks the socket's file + * descriptor as "protected" via [android.net.VpnService.protect], so its traffic is routed + * through the real network interface instead of the TUN tunnel. When the service is not + * running the call is a no-op and the socket behaves like an ordinary socket. + * + * Pass an instance to [okhttp3.OkHttpClient.Builder.socketFactory] to make all OkHttp + * connections bypass the active VPN tunnel. + */ +class ProtectedSocketFactory( + private val delegate: SocketFactory = SocketFactory.getDefault() +) : SocketFactory() { + + /** + * Protect [socket] and return it. If [delegate]'s `createSocket` throws, this method + * never runs and the exception propagates normally – there is no socket to protect. + */ + private fun protect(socket: Socket): Socket { + TProxyService.protectSocket(socket) + return socket + } + + override fun createSocket(): Socket = protect(delegate.createSocket()) + + override fun createSocket(host: String, port: Int): Socket = + protect(delegate.createSocket(host, port)) + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int + ): Socket = protect(delegate.createSocket(host, port, localHost, localPort)) + + override fun createSocket(host: InetAddress, port: Int): Socket = + protect(delegate.createSocket(host, port)) + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int + ): Socket = protect(delegate.createSocket(address, port, localAddress, localPort)) +} diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 7326e449..339074e3 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -196,6 +196,14 @@ class Preferences(context: Context) { setValueInProvider(USE_XRAY_TUN, value) } + /** + * True when Xray is running its own TUN inbound (rather than hev-socks5-tunnel). + * This is the single authoritative check used by both the service and the ViewModel + * to decide whether VPN-bypass protection is required for outbound sockets. + */ + val isXrayTunActive: Boolean + get() = useXrayTun && !disableVpn + val tunnelMtu: Int get() = 8500 diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 3a3b73a9..f869ccdd 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -39,6 +39,8 @@ import java.io.IOException import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.ServerSocket +import java.net.Socket +import java.lang.ref.WeakReference import kotlin.concurrent.Volatile import kotlin.system.exitProcess @@ -111,6 +113,7 @@ class TProxyService : VpnService() { override fun onCreate() { super.onCreate() + instance = WeakReference(this) logFileManager = LogFileManager(this) Log.d(TAG, "TProxyService created.") } @@ -175,6 +178,7 @@ class TProxyService : VpnService() { } override fun onDestroy() { + instance = null super.onDestroy() handler.removeCallbacks(broadcastLogsRunnable) broadcastLogsRunnable.run() @@ -418,8 +422,10 @@ class TProxyService : VpnService() { } } } - if (prefs.bypassSelectedApps || prefs.apps.isNullOrEmpty()) - addDisallowedApplication(BuildConfig.APPLICATION_ID) + // Always exclude the app itself from the VPN tunnel so that its own + // network traffic (e.g. rule-file downloads, update checks) is never + // routed through the TUN interface. + addDisallowedApplication(BuildConfig.APPLICATION_ID) } private fun stopService() { @@ -481,6 +487,20 @@ class TProxyService : VpnService() { private const val TAG = "VpnService" private const val BROADCAST_DELAY_MS: Long = 3000 + /** Weak reference to the running service instance; null when the service is not running. */ + @Volatile + private var instance: WeakReference? = null + + /** + * Calls [VpnService.protect] on [socket] so its traffic bypasses the TUN tunnel and + * reaches the real network directly. + * + * Returns `true` if the service is running and the socket was successfully protected. + * Returns `false` when the service is **not** running – this is not an error: when no + * VPN tunnel is active, all sockets already reach the real network without protection. + */ + fun protectSocket(socket: Socket): Boolean = instance?.get()?.protect(socket) ?: false + init { System.loadLibrary("hev-socks5-tunnel") System.loadLibrary("xray-exec") diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index ff78cafe..f8a56ef9 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -54,13 +54,14 @@ import java.net.InetSocketAddress import java.net.Proxy import java.net.Socket import java.net.URL +import java.util.concurrent.TimeUnit import java.util.regex.Pattern import javax.net.ssl.SSLSocketFactory import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" -/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive. */ +/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive during a connectivity test. */ private const val SOCKS_LIFETIME_MS = 5_000L /** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ @@ -69,6 +70,12 @@ private const val INBOUND_BIND_DELAY_MS = 300L /** Number of times to retry connecting to the inbound before giving up. */ private const val INBOUND_BIND_RETRIES = 5 +/** Inclusive lower bound of the random ephemeral port range used for temporary inbounds. */ +private const val EPHEMERAL_PORT_RANGE_START = 32768 + +/** Number of candidate ports in the random ephemeral range (32768–60999 inclusive). */ +private const val EPHEMERAL_PORT_RANGE_SIZE = 60999 - EPHEMERAL_PORT_RANGE_START + 1 + sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -85,7 +92,8 @@ class MainViewModel(application: Application) : private var coreStatsClient: CoreStatsClient? = null - // Held so we can restore it after the temporary connectivity-test authenticator. + // Held so it can be restored after a withTempSocksProxiedClient() call temporarily + // replaces the global authenticator with a per-session random-credential authenticator. private val globalSocksAuthenticator = object : java.net.Authenticator() { override fun getPasswordAuthentication(): java.net.PasswordAuthentication? { val user = prefs.socksUsername @@ -846,7 +854,7 @@ class MainViewModel(application: Application) : val rng = java.security.SecureRandom() var port: Int? = null repeat(20) { - val candidate = 32768 + rng.nextInt(28232) + val candidate = EPHEMERAL_PORT_RANGE_START + rng.nextInt(EPHEMERAL_PORT_RANGE_SIZE) if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { port = candidate return@repeat @@ -1073,6 +1081,115 @@ class MainViewModel(application: Application) : } } + /** + * Builds an [OkHttpClient] appropriate for the current service state: + * + * - **hev-socks5-tunnel mode** (VPN active, non-TUN): routes through the upstream SOCKS5 + * proxy so the request goes through the user's proxy chain. + * - **Service not running / core-only mode, or Xray TUN mode**: plain direct connection. + * Callers operating in Xray TUN mode should use [withTempSocksProxiedClient] instead so + * the request travels through the proxy chain with a temporary, short-lived inbound. + */ + private fun buildHttpClient(): OkHttpClient { + val serviceActive = _isServiceEnabled.value + return OkHttpClient.Builder().apply { + if (serviceActive && !prefs.isXrayTunActive) { + proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", prefs.socksPort))) + } + }.build() + } + + /** + * Allocates a temporary SOCKS5 inbound via the Xray HandlerService API, using a random port + * and randomly generated credentials that are unrelated to the user-configured SOCKS settings. + * Builds an [OkHttpClient] routed through this temporary proxy and calls [block] with it. + * The inbound is always removed in the `finally` block, regardless of how [block] exits. + * + * The client is configured with a 30-second connect timeout and a 5-minute per-read timeout + * so that large rule-file downloads on slow connections complete successfully. + * + * A dedicated [java.net.Authenticator] is installed for the duration of the call and + * [globalSocksAuthenticator] is restored unconditionally in the `finally` block. This + * authenticator is purposely isolated from [globalSocksAuthenticator] and the user-visible + * SOCKS credentials. + * + * Used exclusively in Xray TUN mode so the app can reach the internet for its own network + * tasks (rule-file downloads, update checks) while still routing through the proxy chain. + * + * @throws IOException if a free local port cannot be found or the inbound cannot be created. + */ + private suspend fun withTempSocksProxiedClient( + tag: String, + block: suspend (OkHttpClient) -> T, + ): T = withContext(Dispatchers.IO) { + val rng = java.security.SecureRandom() + // 8 random bytes each for username and password, hex-encoded. + val randomUser = ByteArray(8).also(rng::nextBytes).joinToString("") { "%02x".format(it) } + val randomPass = ByteArray(8).also(rng::nextBytes).joinToString("") { "%02x".format(it) } + + val port = run { + var p: Int? = null + repeat(20) { + val candidate = EPHEMERAL_PORT_RANGE_START + rng.nextInt(EPHEMERAL_PORT_RANGE_SIZE) + if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { + p = candidate + return@repeat + } + } + p + } ?: throw IOException("No free local port available for temporary SOCKS5 inbound") + + val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) + val added = handlerClient.addSocksInbound(tag, port, randomUser, randomPass) + if (!added) { + handlerClient.close() + throw IOException("Failed to create temporary SOCKS5 inbound (tag=$tag)") + } + + // Install a per-session authenticator for these random credentials. + // globalSocksAuthenticator is restored unconditionally in the finally block. + val tempAuth = object : java.net.Authenticator() { + override fun getPasswordAuthentication() = + java.net.PasswordAuthentication(randomUser, randomPass.toCharArray()) + } + java.net.Authenticator.setDefault(tempAuth) + try { + // Wait for Xray to bind the port before handing out the client. + var bound = false + for (attempt in 1..INBOUND_BIND_RETRIES) { + kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) + if (runCatching { + java.net.Socket().use { s -> + s.connect( + InetSocketAddress("127.0.0.1", port), + INBOUND_BIND_DELAY_MS.toInt(), + ) + } + }.isSuccess + ) { + bound = true + break + } + } + if (!bound) { + throw IOException("Temporary SOCKS5 inbound did not bind within the expected time (tag=$tag)") + } + + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)) + val client = OkHttpClient.Builder() + .proxy(proxy) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.MINUTES) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + block(client) + } finally { + java.net.Authenticator.setDefault(globalSocksAuthenticator) + handlerClient.removeInbound(tag) + handlerClient.close() + } + } + fun downloadRuleFile(url: String, fileName: String) { val currentJob = if (fileName == "geoip.dat") geoipDownloadJob else geositeDownloadJob if (currentJob?.isActive == true) { @@ -1089,85 +1206,102 @@ class MainViewModel(application: Application) : _geositeDownloadProgress } - val client = OkHttpClient.Builder().apply { - if (_isServiceEnabled.value) { - proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", prefs.socksPort))) - } - }.build() - - try { - progressFlow.value = application.getString(R.string.connecting) + // Core download logic, independent of how the HTTP client was obtained. + suspend fun doDownload(client: OkHttpClient) { + try { + progressFlow.value = application.getString(R.string.connecting) - val request = Request.Builder().url(url).build() - val call = client.newCall(request) - val response = call.await() + val request = Request.Builder().url(url).build() + val call = client.newCall(request) + val response = call.await() - if (!response.isSuccessful) { - throw IOException("Failed to download file: ${response.code}") - } + if (!response.isSuccessful) { + throw IOException("Failed to download file: ${response.code}") + } - val body = response.body ?: throw IOException("Response body is null") - val totalBytes = body.contentLength() - var bytesRead = 0L - var lastProgress = -1 - - body.byteStream().use { inputStream -> - val success = fileManager.saveRuleFile(inputStream, fileName) { read -> - ensureActive() - bytesRead += read - if (totalBytes > 0) { - val progress = (bytesRead * 100 / totalBytes).toInt() - if (progress != lastProgress) { - progressFlow.value = - application.getString(R.string.downloading, progress) - lastProgress = progress - } - } else { - if (lastProgress == -1) { - progressFlow.value = - application.getString(R.string.downloading_no_size) - lastProgress = 0 + val body = response.body ?: throw IOException("Response body is null") + val totalBytes = body.contentLength() + var bytesRead = 0L + var lastProgress = -1 + + body.byteStream().use { inputStream -> + val success = fileManager.saveRuleFile(inputStream, fileName) { read -> + ensureActive() + bytesRead += read + if (totalBytes > 0) { + val progress = (bytesRead * 100 / totalBytes).toInt() + if (progress != lastProgress) { + progressFlow.value = + application.getString(R.string.downloading, progress) + lastProgress = progress + } + } else { + if (lastProgress == -1) { + progressFlow.value = + application.getString(R.string.downloading_no_size) + lastProgress = 0 + } } } - } - if (success) { - when (fileName) { - "geoip.dat" -> { - _settingsState.value = _settingsState.value.copy( - files = _settingsState.value.files.copy( - isGeoipCustom = prefs.customGeoipImported - ), - info = _settingsState.value.info.copy( - geoipSummary = fileManager.getRuleFileSummary("geoip.dat") + if (success) { + when (fileName) { + "geoip.dat" -> { + _settingsState.value = _settingsState.value.copy( + files = _settingsState.value.files.copy( + isGeoipCustom = prefs.customGeoipImported + ), + info = _settingsState.value.info.copy( + geoipSummary = fileManager.getRuleFileSummary("geoip.dat") + ) ) - ) - } + } - "geosite.dat" -> { - _settingsState.value = _settingsState.value.copy( - files = _settingsState.value.files.copy( - isGeositeCustom = prefs.customGeositeImported - ), - info = _settingsState.value.info.copy( - geositeSummary = fileManager.getRuleFileSummary("geosite.dat") + "geosite.dat" -> { + _settingsState.value = _settingsState.value.copy( + files = _settingsState.value.files.copy( + isGeositeCustom = prefs.customGeositeImported + ), + info = _settingsState.value.info.copy( + geositeSummary = fileManager.getRuleFileSummary("geosite.dat") + ) ) - ) + } } + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_success))) + } else { + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_failed))) } - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_success))) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_failed))) } + } catch (e: CancellationException) { + Log.d(TAG, "Download cancelled for $fileName") + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_cancelled))) + } catch (e: Exception) { + Log.e(TAG, "Failed to download rule file", e) + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_failed) + ": " + e.message)) + } finally { + progressFlow.value = null + updateSettingsState() } - } catch (e: CancellationException) { - Log.d(TAG, "Download cancelled for $fileName") - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_cancelled))) - } catch (e: Exception) { - Log.e(TAG, "Failed to download rule file", e) - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_failed) + ": " + e.message)) - } finally { - progressFlow.value = null - updateSettingsState() + } + + val tag = "internal-dl-${fileName.removeSuffix(".dat")}" + if (_isServiceEnabled.value && prefs.isXrayTunActive) { + // In Xray TUN mode the app is excluded from VPN routing, so a plain connection + // would bypass the proxy chain. Create a temporary inbound with random + // credentials so the download goes through the proxy chain. + try { + withTempSocksProxiedClient(tag) { client -> doDownload(client) } + } catch (e: CancellationException) { + Log.d(TAG, "Download cancelled while setting up proxy for $fileName") + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_cancelled))) + progressFlow.value = null + } catch (e: Exception) { + Log.e(TAG, "Failed to set up temporary proxy for download of $fileName", e) + _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_failed) + ": " + e.message)) + progressFlow.value = null + } + } else { + doDownload(buildHttpClient()) } } @@ -1209,41 +1343,56 @@ class MainViewModel(application: Application) : fun checkForUpdates() { viewModelScope.launch(Dispatchers.IO) { _isCheckingForUpdates.value = true - val client = OkHttpClient.Builder().apply { - if (_isServiceEnabled.value) { - proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", prefs.socksPort))) - } - }.build() val request = Request.Builder() .url(application.getString(R.string.source_url) + "/releases/latest") .head() .build() - try { - val response = client.newCall(request).await() - val location = response.request.url.toString() - val latestTag = location.substringAfterLast("/tag/v") - Log.d(TAG, "Latest version tag: $latestTag") - val updateAvailable = compareVersions(latestTag) > 0 - if (updateAvailable) { - _newVersionAvailable.value = latestTag - } else { + suspend fun doCheck(client: OkHttpClient) { + try { + val response = client.newCall(request).await() + val location = response.request.url.toString() + val latestTag = location.substringAfterLast("/tag/v") + Log.d(TAG, "Latest version tag: $latestTag") + val updateAvailable = compareVersions(latestTag) > 0 + if (updateAvailable) { + _newVersionAvailable.value = latestTag + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.no_new_version_available) + ) + ) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to check for updates", e) _uiEvent.trySend( MainViewUiEvent.ShowSnackbar( - application.getString(R.string.no_new_version_available) + application.getString(R.string.failed_to_check_for_updates) + ": " + e.message ) ) + } finally { + _isCheckingForUpdates.value = false } - } catch (e: Exception) { - Log.e(TAG, "Failed to check for updates", e) - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.failed_to_check_for_updates) + ": " + e.message + } + + if (_isServiceEnabled.value && prefs.isXrayTunActive) { + // In Xray TUN mode the app is excluded from VPN routing. Use a temporary + // inbound with random credentials so the request goes through the proxy chain. + try { + withTempSocksProxiedClient("internal-update-check") { client -> doCheck(client) } + } catch (e: Exception) { + Log.e(TAG, "Failed to set up temporary proxy for update check", e) + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.failed_to_check_for_updates) + ": " + e.message + ) ) - ) - } finally { - _isCheckingForUpdates.value = false + _isCheckingForUpdates.value = false + } + } else { + doCheck(buildHttpClient()) } } } From ef662e354571074d8a21ff56ae03228082b4593a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:39:01 +0800 Subject: [PATCH 47/55] fix: restore raw Socket connectivity test to match upstream (#5) * security: disable connectivity test in xray TUN mode, replace HandlerService with multi-file SOCKS injection Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/cc9a2adb-eef3-4bf5-9e67-0de097312ae1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * address code review: improve tag prefix, add port validation, improve state detection Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/cc9a2adb-eef3-4bf5-9e67-0de097312ae1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * refactor: xray multi-file, random 127.x.x.x, min lifetime, re-enable connectivity test, 10s timeout, remove dead code Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/764f2a62-bd35-49ca-91a7-38989479523e Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: address code review - cleaner random loopback range, Log.e for inconsistent state, add comment Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/764f2a62-bd35-49ca-91a7-38989479523e Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: revert to stdin config inject for security, switch temp SOCKS5 to noauth to fix connectivity test Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3140f723-857d-43fe-b37a-862d2122c9b9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * docs: fix comments to accurately describe noauth temp SOCKS and stdin config flow Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/3140f723-857d-43fe-b37a-862d2122c9b9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: restore SOCKS5 password auth, pass temp config in-memory (never disk) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/ed455583-d235-4c41-b2e3-98b1a3d220d4 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: restore disk file for temp SOCKS config (TProxyService runs in :native process), clean up comments Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/255bfd61-cf5e-4b97-be9b-bec8a4674d6e Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * refactor: pass temp SOCKS config via intent extra instead of file Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/52f0d1d0-7763-4a3b-b690-913bb0b1d0ec Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: restore missing java.io.File import in MainViewModel Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/725c31a0-2f20-4553-ae89-eefc3b799366 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: use separate connect/read timeouts in connectivity check Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/eef5de99-de2c-4491-9a2a-99e00330d14e Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: restore raw Socket approach for connectivity test (matches original lhear/SimpleXray) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/482cadaa-470b-4c42-91ec-951df6b524f0 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/jni/xray-exec/xray_exec.c | 40 +- .../com/simplexray/an/common/ConfigUtils.kt | 63 ++- .../an/common/HandlerServiceClient.kt | 110 ----- .../an/common/ProtectedSocketFactory.kt | 53 -- .../simplexray/an/service/TProxyService.kt | 30 +- .../simplexray/an/viewmodel/MainViewModel.kt | 455 +++++++++--------- app/src/main/proto/core_config.proto | 16 - app/src/main/proto/handler_command.proto | 27 -- app/src/main/proto/net_address.proto | 16 - app/src/main/proto/proxyman_config.proto | 25 - app/src/main/proto/socks_config.proto | 19 - app/src/main/proto/typed_message.proto | 16 - 12 files changed, 323 insertions(+), 547 deletions(-) delete mode 100644 app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt delete mode 100644 app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt delete mode 100644 app/src/main/proto/core_config.proto delete mode 100644 app/src/main/proto/handler_command.proto delete mode 100644 app/src/main/proto/net_address.proto delete mode 100644 app/src/main/proto/proxyman_config.proto delete mode 100644 app/src/main/proto/socks_config.proto delete mode 100644 app/src/main/proto/typed_message.proto diff --git a/app/src/main/jni/xray-exec/xray_exec.c b/app/src/main/jni/xray-exec/xray_exec.c index e6f6ab33..c8593d09 100644 --- a/app/src/main/jni/xray-exec/xray_exec.c +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -10,16 +10,16 @@ * (CHILD_TUN_FD = 4) *before* exec(). dup2() does not copy FD_CLOEXEC, so fd * 4 survives exec and is visible to Xray as its TUN fd. * - * The function also creates stdin / stdout pipes so that the caller can write - * the Xray config JSON to stdin and read Xray log output from stdout, exactly - * mirroring the existing ProcessBuilder-based code path. + * The merged Xray config JSON (already assembled in Kotlin) is delivered to + * Xray via stdin so that no sensitive data (API address, temp-SOCKS port) ever + * touches the file system. A stdout pipe allows the caller to read Xray logs. * * JNI signature: * int[] TProxyService.nativeSpawnXray( * String xrayPath, String assetDir, int vpnFd) * - * Returns int[3] = { pid, stdout_read_fd, stdin_write_fd } on success, or null - * on failure. + * Returns int[3] = { pid, stdout_read_fd, stdin_write_fd } on success, + * or null on failure. */ #include @@ -96,15 +96,15 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( const char *xray_path = (*env)->GetStringUTFChars(env, xray_path_j, NULL); const char *asset_dir = (*env)->GetStringUTFChars(env, asset_dir_j, NULL); - /* stdin pipe: parent writes config → child reads (stdin_pipe[0] = read end) */ + /* stdin pipe: parent writes config → child reads (stdin_pipe[0] = read end) */ /* stdout pipe: child writes logs → parent reads (stdout_pipe[0] = read end) */ int stdin_pipe[2] = {-1, -1}; int stdout_pipe[2] = {-1, -1}; if (pipe(stdin_pipe) < 0 || pipe(stdout_pipe) < 0) { LOGE("pipe() failed: %s", strerror(errno)); - if (stdin_pipe[0] >= 0) { close(stdin_pipe[0]); close(stdin_pipe[1]); } - if (stdout_pipe[0] >= 0) { close(stdout_pipe[0]); close(stdout_pipe[1]); } + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); return NULL; @@ -140,8 +140,7 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( new_env[ni++] = tun_fd_env; new_env[ni] = NULL; - /* argv: just the binary path – Xray reads config from stdin when no -config - * argument is given, matching the existing ProcessBuilder code path. */ + /* argv: xray_path only – Xray reads its JSON config from stdin. */ char *argv[] = { (char *)xray_path, NULL }; pid_t pid = fork(); @@ -162,17 +161,15 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( prctl(PR_SET_PDEATHSIG, SIGKILL); /* stdin ← read end of stdin pipe */ - dup2(stdin_pipe[0], STDIN_FILENO); + dup2(stdin_pipe[0], STDIN_FILENO); /* stdout + stderr → write end of stdout pipe */ dup2(stdout_pipe[1], STDOUT_FILENO); dup2(stdout_pipe[1], STDERR_FILENO); - /* Close the original pipe ends (already dup2'd to 0/1/2). */ - close(stdin_pipe[0]); - close(stdin_pipe[1]); - close(stdout_pipe[0]); - close(stdout_pipe[1]); + /* Close the original pipe ends (already dup2'd). */ + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); /* Assign the VPN fd to CHILD_TUN_FD (dup2 never sets FD_CLOEXEC). */ if ((int)vpn_fd >= 0 && (int)vpn_fd != CHILD_TUN_FD) { @@ -193,20 +190,19 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( free(new_env); /* Close the ends that belong to the child. */ - close(stdin_pipe[0]); /* child reads from this; parent must not keep it */ - close(stdout_pipe[1]); /* child writes to this; parent must not keep it */ + close(stdin_pipe[0]); /* child reads from this; parent must not keep it */ + close(stdout_pipe[1]); /* child writes to this; parent must not keep it */ (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); - LOGI("Spawned xray pid=%d stdin_write_fd=%d stdout_read_fd=%d", - pid, stdin_pipe[1], stdout_pipe[0]); + LOGI("Spawned xray pid=%d stdout_read_fd=%d stdin_write_fd=%d", + pid, stdout_pipe[0], stdin_pipe[1]); /* Return { pid, stdout_read_fd, stdin_write_fd } */ jintArray result = (*env)->NewIntArray(env, 3); if (!result) { - close(stdin_pipe[1]); - close(stdout_pipe[0]); + close(stdout_pipe[0]); close(stdin_pipe[1]); return NULL; } jint arr[3] = { (jint)pid, (jint)stdout_pipe[0], (jint)stdin_pipe[1] }; diff --git a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 22046169..3db49cad 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt @@ -52,21 +52,39 @@ object ConfigUtils { apiObject.put("listen", "${prefs.apiAddress}:${prefs.apiPort}") val servicesArray = org.json.JSONArray() servicesArray.put("StatsService") - servicesArray.put("HandlerService") apiObject.put("services", servicesArray) - jsonObject.put("api", apiObject) - jsonObject.put("stats", JSONObject()) - val policyObject = JSONObject() val systemObject = JSONObject() systemObject.put("statsOutboundUplink", true) systemObject.put("statsOutboundDownlink", true) policyObject.put("system", systemObject) + jsonObject.put("api", apiObject) + jsonObject.put("stats", JSONObject()) jsonObject.put("policy", policyObject) - return jsonObject.toString(2) + var result = jsonObject.toString(2) + result = result.replace("\\/", "/") + return result + } + + @Throws(JSONException::class) + fun mergeAdditionalInbounds(baseConfig: String, extraInboundsJson: String): String { + val base = JSONObject(baseConfig) + val extra = JSONObject(extraInboundsJson) + + val baseInbounds = base.optJSONArray("inbounds") ?: org.json.JSONArray() + val extraInbounds = extra.optJSONArray("inbounds") ?: return baseConfig + + for (i in 0 until extraInbounds.length()) { + baseInbounds.put(extraInbounds.get(i)) + } + base.put("inbounds", baseInbounds) + + var result = base.toString(2) + result = result.replace("\\/", "/") + return result } fun extractPortsFromJson(jsonContent: String): Set { @@ -105,5 +123,40 @@ object ConfigUtils { } } } + + fun buildTempSocksConfigJson( + listenAddress: String, + port: Int, + tag: String, + username: String, + password: String, + ): String { + require(port in 1..65535) { "port must be in 1..65535, got $port" } + + val account = JSONObject() + account.put("user", username) + account.put("pass", password) + val accountsArray = org.json.JSONArray() + accountsArray.put(account) + + val settings = JSONObject() + settings.put("auth", "password") + settings.put("udp", false) + settings.put("accounts", accountsArray) + + val inbound = JSONObject() + inbound.put("tag", tag) + inbound.put("port", port) + inbound.put("listen", listenAddress) + inbound.put("protocol", "socks") + inbound.put("settings", settings) + + val inboundsArray = org.json.JSONArray() + inboundsArray.put(inbound) + + val root = JSONObject() + root.put("inbounds", inboundsArray) + return root.toString(2) + } } diff --git a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt b/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt deleted file mode 100644 index 02aa392f..00000000 --- a/app/src/main/kotlin/com/simplexray/an/common/HandlerServiceClient.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.simplexray.an.common - -import android.util.Log -import com.google.protobuf.ByteString -import com.xray.app.proxyman.PortList -import com.xray.app.proxyman.PortRange -import com.xray.app.proxyman.ReceiverConfig -import com.xray.app.proxyman.command.AddInboundRequest -import com.xray.app.proxyman.command.HandlerServiceGrpc -import com.xray.app.proxyman.command.RemoveInboundRequest -import com.xray.common.net.IPOrDomain -import com.xray.common.serial.TypedMessage -import com.xray.core.InboundHandlerConfig -import com.xray.proxy.socks.AuthType -import com.xray.proxy.socks.ServerConfig -import io.grpc.ManagedChannel -import io.grpc.ManagedChannelBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.Closeable -import java.util.concurrent.TimeUnit - -private const val TAG = "HandlerServiceClient" - -class HandlerServiceClient(private val channel: ManagedChannel) : Closeable { - - private val stub = HandlerServiceGrpc.newBlockingStub(channel) - .withDeadlineAfter(5, TimeUnit.SECONDS) - - /** - * Ask Xray to create a SOCKS5 inbound on [port], bound to 127.0.0.1. - * Uses PASSWORD auth with [username]/[password] when both are non-empty, - * otherwise falls back to NO_AUTH. Returns true if the RPC call succeeded. - */ - suspend fun addSocksInbound( - tag: String, - port: Int, - username: String, - password: String, - ): Boolean = withContext(Dispatchers.IO) { - runCatching { - val portRange = PortRange.newBuilder().setFrom(port).setTo(port).build() - val portList = PortList.newBuilder().addRange(portRange).build() - val listenAddr = IPOrDomain.newBuilder() - .setIp(ByteString.copyFrom(byteArrayOf(127, 0, 0, 1))) // 127.0.0.1 - .build() - val receiverConfig = ReceiverConfig.newBuilder() - .setPortList(portList) - .setListen(listenAddr) - .build() - - val socksConfig = if (username.isNotEmpty() && password.isNotEmpty()) { - ServerConfig.newBuilder() - .setAuthType(AuthType.PASSWORD) - .putAccounts(username, password) - .build() - } else { - ServerConfig.newBuilder() - .setAuthType(AuthType.NO_AUTH) - .build() - } - - val inboundConfig = InboundHandlerConfig.newBuilder() - .setTag(tag) - .setReceiverSettings( - TypedMessage.newBuilder() - .setType("xray.app.proxyman.ReceiverConfig") - .setValue(receiverConfig.toByteString()) - .build() - ) - .setProxySettings( - TypedMessage.newBuilder() - .setType("xray.proxy.socks.ServerConfig") - .setValue(socksConfig.toByteString()) - .build() - ) - .build() - - stub.addInbound(AddInboundRequest.newBuilder().setInbound(inboundConfig).build()) - true - }.getOrElse { e -> - Log.e(TAG, "addSocksInbound failed (tag=$tag, port=$port)", e) - false - } - } - - /** Ask Xray to remove the inbound with the given [tag]. Returns true on success. */ - suspend fun removeInbound(tag: String): Boolean = withContext(Dispatchers.IO) { - runCatching { - stub.removeInbound(RemoveInboundRequest.newBuilder().setTag(tag).build()) - true - }.getOrElse { e -> - Log.e(TAG, "removeInbound failed (tag=$tag)", e) - false - } - } - - override fun close() { - channel.shutdown().awaitTermination(5, TimeUnit.SECONDS) - } - - companion object { - fun create(host: String, port: Int): HandlerServiceClient { - val channel = ManagedChannelBuilder.forAddress(host, port) - .usePlaintext() - .build() - return HandlerServiceClient(channel) - } - } -} diff --git a/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt b/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt deleted file mode 100644 index 0c8c6c20..00000000 --- a/app/src/main/kotlin/com/simplexray/an/common/ProtectedSocketFactory.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.simplexray.an.common - -import com.simplexray.an.service.TProxyService -import java.net.InetAddress -import java.net.Socket -import javax.net.SocketFactory - -/** - * A [SocketFactory] that calls [TProxyService.protectSocket] on every newly created socket. - * - * When the VPN service is active, [TProxyService.protectSocket] marks the socket's file - * descriptor as "protected" via [android.net.VpnService.protect], so its traffic is routed - * through the real network interface instead of the TUN tunnel. When the service is not - * running the call is a no-op and the socket behaves like an ordinary socket. - * - * Pass an instance to [okhttp3.OkHttpClient.Builder.socketFactory] to make all OkHttp - * connections bypass the active VPN tunnel. - */ -class ProtectedSocketFactory( - private val delegate: SocketFactory = SocketFactory.getDefault() -) : SocketFactory() { - - /** - * Protect [socket] and return it. If [delegate]'s `createSocket` throws, this method - * never runs and the exception propagates normally – there is no socket to protect. - */ - private fun protect(socket: Socket): Socket { - TProxyService.protectSocket(socket) - return socket - } - - override fun createSocket(): Socket = protect(delegate.createSocket()) - - override fun createSocket(host: String, port: Int): Socket = - protect(delegate.createSocket(host, port)) - - override fun createSocket( - host: String, - port: Int, - localHost: InetAddress, - localPort: Int - ): Socket = protect(delegate.createSocket(host, port, localHost, localPort)) - - override fun createSocket(host: InetAddress, port: Int): Socket = - protect(delegate.createSocket(host, port)) - - override fun createSocket( - address: InetAddress, - port: Int, - localAddress: InetAddress, - localPort: Int - ): Socket = protect(delegate.createSocket(address, port, localAddress, localPort)) -} diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index f869ccdd..46b1e311 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -93,6 +93,10 @@ class TProxyService : VpnService() { @Volatile private var reloadingRequested = false + /** Temp SOCKS5 config fragment delivered via intent extra; consumed once by [runXrayProcess]. */ + @Volatile + private var pendingTempSocksConfig: String? = null + /** * Kill whichever Xray process is currently running, whether it was started * via ProcessBuilder (managed) or via nativeSpawnXray (native TUN mode). @@ -127,6 +131,7 @@ class TProxyService : VpnService() { } ACTION_RELOAD_CONFIG -> { + pendingTempSocksConfig = intent.getStringExtra(EXTRA_TEMP_SOCKS_CONFIG) val prefs = Preferences(this) if (prefs.disableVpn) { Log.d(TAG, "Received RELOAD_CONFIG action (core-only mode)") @@ -209,7 +214,14 @@ class TProxyService : VpnService() { val selectedConfigPath = prefs.selectedConfigPath ?: return val xrayPath = "$libraryDir/libxray.so" val configContent = File(selectedConfigPath).readText() - val apiPort = findAvailablePort(extractPortsFromJson(configContent)) ?: return + + // Read temp SOCKS config fragment if delivered via intent extra. + val tempSocksContent = pendingTempSocksConfig.also { pendingTempSocksConfig = null } + + val apiPort = findAvailablePort( + extractPortsFromJson(configContent) + + (tempSocksContent?.let { extractPortsFromJson(it) } ?: emptySet()) + ) ?: return prefs.apiPort = apiPort Log.d(TAG, "Found and set API port: $apiPort") @@ -220,6 +232,17 @@ class TProxyService : VpnService() { Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") val injectedConfigContent = ConfigUtils.injectStatsService(prefs, configContent) + val finalConfigContent = if (tempSocksContent != null) { + try { + ConfigUtils.mergeAdditionalInbounds(injectedConfigContent, tempSocksContent) + } catch (e: Exception) { + Log.w(TAG, "Failed to merge temp SOCKS config into xray config, ignoring it", e) + injectedConfigContent + } + } else { + injectedConfigContent + } + val useXrayTun = prefs.useXrayTun && !prefs.disableVpn val reader: BufferedReader @@ -249,7 +272,7 @@ class TProxyService : VpnService() { Log.d(TAG, "Writing config to native Xray stdin.") ParcelFileDescriptor.adoptFd(stdinWriteFd).use { pfd -> ParcelFileDescriptor.AutoCloseOutputStream(pfd).use { out -> - out.write(injectedConfigContent.toByteArray()) + out.write(finalConfigContent.toByteArray()) out.flush() } } @@ -267,7 +290,7 @@ class TProxyService : VpnService() { Log.d(TAG, "Writing config to xray stdin from: $selectedConfigPath") currentProcess.outputStream.use { os -> - os.write(injectedConfigContent.toByteArray()) + os.write(finalConfigContent.toByteArray()) os.flush() } reader = BufferedReader(InputStreamReader(currentProcess.inputStream)) @@ -484,6 +507,7 @@ class TProxyService : VpnService() { const val ACTION_LOG_UPDATE: String = "com.simplexray.an.LOG_UPDATE" const val ACTION_RELOAD_CONFIG: String = "com.simplexray.an.RELOAD_CONFIG" const val EXTRA_LOG_DATA: String = "log_data" + const val EXTRA_TEMP_SOCKS_CONFIG: String = "temp_socks_config" private const val TAG = "VpnService" private const val BROADCAST_DELAY_MS: Long = 3000 diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index f8a56ef9..a557f66a 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -20,11 +20,11 @@ import androidx.lifecycle.viewModelScope import com.simplexray.an.BuildConfig import com.simplexray.an.R import com.simplexray.an.common.CoreStatsClient -import com.simplexray.an.common.HandlerServiceClient import com.simplexray.an.common.ROUTE_APP_LIST import com.simplexray.an.common.ROUTE_CONFIG_EDIT import com.simplexray.an.common.ThemeMode import com.simplexray.an.data.source.FileManager +import com.simplexray.an.data.source.LogFileManager import com.simplexray.an.prefs.Preferences import com.simplexray.an.service.TProxyService import kotlinx.coroutines.CoroutineScope @@ -32,6 +32,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -39,16 +40,17 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout import okhttp3.Call import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import java.io.BufferedReader import java.io.File import java.io.IOException +import java.io.BufferedReader import java.io.InputStreamReader import java.net.InetSocketAddress import java.net.Proxy @@ -61,20 +63,12 @@ import kotlin.coroutines.cancellation.CancellationException private const val TAG = "MainViewModel" -/** Maximum time (ms) the temporary SOCKS5 inbound is allowed to stay alive during a connectivity test. */ -private const val SOCKS_LIFETIME_MS = 5_000L - -/** Time (ms) to wait between connection attempts to the newly added SOCKS5 inbound. */ -private const val INBOUND_BIND_DELAY_MS = 300L - -/** Number of times to retry connecting to the inbound before giving up. */ -private const val INBOUND_BIND_RETRIES = 5 - -/** Inclusive lower bound of the random ephemeral port range used for temporary inbounds. */ private const val EPHEMERAL_PORT_RANGE_START = 32768 - -/** Number of candidate ports in the random ephemeral range (32768–60999 inclusive). */ private const val EPHEMERAL_PORT_RANGE_SIZE = 60999 - EPHEMERAL_PORT_RANGE_START + 1 +private const val TEMP_SOCKS_PROBE_DELAY_MS = 500L +private const val TEMP_SOCKS_MAX_PROBES = 30 +private const val TEMP_SOCKS_MIN_LIFETIME_MS = 10_000L +private const val TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS = 10_000L sealed class MainViewUiEvent { data class ShowSnackbar(val message: String) : MainViewUiEvent() @@ -92,8 +86,16 @@ class MainViewModel(application: Application) : private var coreStatsClient: CoreStatsClient? = null - // Held so it can be restored after a withTempSocksProxiedClient() call temporarily - // replaces the global authenticator with a per-session random-credential authenticator. + // Temporary SOCKS5 state for Xray TUN mode. All fields protected by tempSocksMutex. + private val tempSocksMutex = Mutex() + private var tempSocksAddress: String = "" + private var tempSocksPort: Int = -1 + private var tempSocksTag: String = "" + private var tempSocksUser: String = "" + private var tempSocksPass: String = "" + private var activeProxiedTaskCount: Int = 0 + private var cleanupJob: Job? = null + private val globalSocksAuthenticator = object : java.net.Authenticator() { override fun getPasswordAuthentication(): java.net.PasswordAuthentication? { val user = prefs.socksUsername @@ -196,6 +198,9 @@ class MainViewModel(application: Application) : _coreStatsState.value = CoreStatsState() coreStatsClient?.close() coreStatsClient = null + // If the service stopped while downloads were using the temp SOCKS inbound, + // clean up the state so subsequent downloads start fresh. + viewModelScope.launch(Dispatchers.IO) { cleanupTempSocksIfActive() } } } @@ -832,167 +837,78 @@ class MainViewModel(application: Application) : _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_invalid_url))) return@launch } + val host = url.host - val port = if (url.port > 0) url.port else url.defaultPort + val urlPort = if (url.port > 0) url.port else url.defaultPort val path = if (url.path.isNullOrEmpty()) "/" else url.path val isHttps = url.protocol == "https" val timeout = prefs.connectivityTestTimeout - val useXrayTun = prefs.useXrayTun && !prefs.disableVpn - if (useXrayTun) { - // The app is excluded from VPN routing via addDisallowedApplication so it - // cannot reach the TUN directly. Instead, we ask Xray at runtime (via its - // HandlerService gRPC API) to create a temporary SOCKS5 inbound bound to - // 127.0.0.1 with a random high port, run the HTTP test through it, then - // remove the inbound. The inbound lifetime is capped at SOCKS_LIFETIME_MS. - val tag = "connectivity-test-temp" - // Use the GUI-configured SOCKS5 credentials. The globalSocksAuthenticator - // already holds these so no temporary authenticator override is needed. - val testUser = prefs.socksUsername - val testPass = prefs.socksPassword - // Pick a random high port (32768-60999) to avoid well-known port conflicts. - val testPort = run { - val rng = java.security.SecureRandom() - var port: Int? = null - repeat(20) { - val candidate = EPHEMERAL_PORT_RANGE_START + rng.nextInt(EPHEMERAL_PORT_RANGE_SIZE) - if (runCatching { java.net.ServerSocket(candidate).close() }.isSuccess) { - port = candidate - return@repeat - } - } - port - } - if (testPort == null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@launch - } - val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) - val added = handlerClient.addSocksInbound(tag, testPort, testUser, testPass) - if (!added) { - handlerClient.close() - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - return@launch - } + fun doTest(proxy: Proxy) { + val start = System.currentTimeMillis() try { - val socksTimeout = SOCKS_LIFETIME_MS.toInt() - val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", testPort)) - try { - withTimeout(SOCKS_LIFETIME_MS) { - withContext(Dispatchers.IO) { - // Retry connecting to the inbound: xray may need a moment - // to bind the port after AddInbound returns. - var lastException: Exception? = null - for (attempt in 1..INBOUND_BIND_RETRIES) { - kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) - val socket = runCatching { Socket(proxy) }.getOrElse { e -> - lastException = e as? Exception ?: RuntimeException(e) - null - } - if (socket == null) continue - lastException = null - try { - socket.use { - it.soTimeout = socksTimeout - val start = System.currentTimeMillis() - it.connect(InetSocketAddress.createUnresolved(host, port), socksTimeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(it, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - it.getOutputStream().bufferedWriter(), - it.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_latency, latency.toInt()) - ) - ) - } else { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } - return@withContext - } catch (e: Exception) { - lastException = e - } - } - if (lastException != null) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } + Socket(proxy).use { socket -> + socket.soTimeout = timeout + socket.connect(InetSocketAddress(host, urlPort), timeout) + val (writer, reader) = if (isHttps) { + val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(socket, host, urlPort, true) as javax.net.ssl.SSLSocket + sslSocket.startHandshake() + Pair( + sslSocket.outputStream.bufferedWriter(), + sslSocket.inputStream.bufferedReader() + ) + } else { + Pair( + socket.getOutputStream().bufferedWriter(), + socket.getInputStream().bufferedReader() + ) } - } catch (e: Exception) { - _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed))) - } - } finally { - handlerClient.removeInbound(tag) - handlerClient.close() - } - return@launch - } - - // HEV TUN mode: connect through the user-configured SOCKS5 proxy. - val proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) - try { - Socket(proxy).use { socket -> - socket.soTimeout = timeout - val start = System.currentTimeMillis() - socket.connect(InetSocketAddress(host, port), timeout) - val (writer, reader) = if (isHttps) { - val sslSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(socket, host, port, true) as javax.net.ssl.SSLSocket - sslSocket.startHandshake() - Pair( - sslSocket.outputStream.bufferedWriter(), - sslSocket.inputStream.bufferedReader() - ) - } else { - Pair( - socket.getOutputStream().bufferedWriter(), - socket.getInputStream().bufferedReader() - ) - } - writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") - writer.flush() - val firstLine = reader.readLine() - val latency = System.currentTimeMillis() - start - if (firstLine != null && firstLine.startsWith("HTTP/")) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString( - R.string.connectivity_test_latency, - latency.toInt() + writer.write("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n") + writer.flush() + val firstLine = reader.readLine() + val latency = System.currentTimeMillis() - start + if (firstLine != null && firstLine.startsWith("HTTP/")) { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar( + application.getString(R.string.connectivity_test_latency, latency.toInt()) ) ) - ) - } else { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) + } else { + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed)) ) - ) + } } + } catch (e: Exception) { + Log.e(TAG, "Connectivity test failed for ${prefs.connectivityTestTarget}", e) + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed)) + ) } - } catch (e: Exception) { - _uiEvent.trySend( - MainViewUiEvent.ShowSnackbar( - application.getString(R.string.connectivity_test_failed) + } + + if (_isServiceEnabled.value && prefs.isXrayTunActive) { + // In Xray TUN mode the app is excluded from VPN routing. Inject a temporary + // SOCKS5 inbound so the test goes through the proxy chain, then use a raw + // Socket (same as the other modes) to perform the HTTP request through it. + try { + val (address, socksPort) = ensureTempSocksReady() + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(address, socksPort)) + try { + doTest(proxy) + } finally { + decrementAndCleanupIfNeeded() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to set up temporary proxy for connectivity test", e) + _uiEvent.trySend( + MainViewUiEvent.ShowSnackbar(application.getString(R.string.connectivity_test_failed)) ) - ) + } + } else { + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) + doTest(proxy) } } } @@ -1086,9 +1002,9 @@ class MainViewModel(application: Application) : * * - **hev-socks5-tunnel mode** (VPN active, non-TUN): routes through the upstream SOCKS5 * proxy so the request goes through the user's proxy chain. - * - **Service not running / core-only mode, or Xray TUN mode**: plain direct connection. - * Callers operating in Xray TUN mode should use [withTempSocksProxiedClient] instead so - * the request travels through the proxy chain with a temporary, short-lived inbound. + * - **Service not running / core-only mode**: plain direct connection. + * + * A [TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS] read timeout stops stalled downloads automatically. */ private fun buildHttpClient(): OkHttpClient { val serviceActive = _isServiceEnabled.value @@ -1096,36 +1012,75 @@ class MainViewModel(application: Application) : if (serviceActive && !prefs.isXrayTunActive) { proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", prefs.socksPort))) } + readTimeout(TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS, TimeUnit.MILLISECONDS) }.build() } + // ── Temporary SOCKS5 helpers for Xray TUN mode ──────────────────────────────────────────── + + private fun appendToAppLog(message: String) { + try { + LogFileManager(application).appendLog(message) + val intent = Intent(TProxyService.ACTION_LOG_UPDATE) + intent.setPackage(application.packageName) + intent.putStringArrayListExtra(TProxyService.EXTRA_LOG_DATA, arrayListOf(message)) + application.sendBroadcast(intent) + } catch (e: Exception) { + Log.w(TAG, "Failed to append message to app log", e) + } + } + + private suspend fun waitForSocksProxy(address: String, port: Int) { + repeat(TEMP_SOCKS_MAX_PROBES) { + delay(TEMP_SOCKS_PROBE_DELAY_MS) + if (runCatching { + Socket().use { s -> + s.connect(InetSocketAddress(address, port), TEMP_SOCKS_PROBE_DELAY_MS.toInt()) + } + }.isSuccess + ) return + } + throw IOException("Temporary SOCKS5 inbound did not bind on $address:$port within the expected time") + } + + // Must be called while holding tempSocksMutex. + private fun cleanupTempSocksLocked(restartProxy: Boolean) { + activeProxiedTaskCount = 0 + tempSocksAddress = "" + tempSocksPort = -1 + tempSocksTag = "" + tempSocksUser = "" + tempSocksPass = "" + cleanupJob?.cancel() + cleanupJob = null + java.net.Authenticator.setDefault(globalSocksAuthenticator) + if (restartProxy && _isServiceEnabled.value) { + appendToAppLog("[SimpleXray] Removing temporary SOCKS5 inbound, reloading proxy.") + application.startService( + Intent(application, TProxyService::class.java) + .setAction(TProxyService.ACTION_RELOAD_CONFIG) + ) + } + } + /** - * Allocates a temporary SOCKS5 inbound via the Xray HandlerService API, using a random port - * and randomly generated credentials that are unrelated to the user-configured SOCKS settings. - * Builds an [OkHttpClient] routed through this temporary proxy and calls [block] with it. - * The inbound is always removed in the `finally` block, regardless of how [block] exits. - * - * The client is configured with a 30-second connect timeout and a 5-minute per-read timeout - * so that large rule-file downloads on slow connections complete successfully. - * - * A dedicated [java.net.Authenticator] is installed for the duration of the call and - * [globalSocksAuthenticator] is restored unconditionally in the `finally` block. This - * authenticator is purposely isolated from [globalSocksAuthenticator] and the user-visible - * SOCKS credentials. - * - * Used exclusively in Xray TUN mode so the app can reach the internet for its own network - * tasks (rule-file downloads, update checks) while still routing through the proxy chain. - * - * @throws IOException if a free local port cannot be found or the inbound cannot be created. + * Ensures the shared temporary SOCKS5 inbound is running and returns its address/port. + * Passes the config fragment as an intent extra on [TProxyService.ACTION_RELOAD_CONFIG] + * so no sensitive data touches the file system. */ - private suspend fun withTempSocksProxiedClient( - tag: String, - block: suspend (OkHttpClient) -> T, - ): T = withContext(Dispatchers.IO) { + private suspend fun ensureTempSocksReady(): Pair = tempSocksMutex.withLock { + if (tempSocksPort > 0) { + cleanupJob?.cancel() + cleanupJob = null + activeProxiedTaskCount++ + return@withLock Pair(tempSocksAddress, tempSocksPort) + } + val rng = java.security.SecureRandom() - // 8 random bytes each for username and password, hex-encoded. + val randomAddr = "127.${rng.nextInt(254) + 1}.${rng.nextInt(254) + 1}.${rng.nextInt(254) + 1}" val randomUser = ByteArray(8).also(rng::nextBytes).joinToString("") { "%02x".format(it) } val randomPass = ByteArray(8).also(rng::nextBytes).joinToString("") { "%02x".format(it) } + val tag = "temp-socks-${ByteArray(4).also(rng::nextBytes).joinToString("") { "%02x".format(it) }}" val port = run { var p: Int? = null @@ -1139,54 +1094,84 @@ class MainViewModel(application: Application) : p } ?: throw IOException("No free local port available for temporary SOCKS5 inbound") - val handlerClient = HandlerServiceClient.create(prefs.apiAddress, prefs.apiPort) - val added = handlerClient.addSocksInbound(tag, port, randomUser, randomPass) - if (!added) { - handlerClient.close() - throw IOException("Failed to create temporary SOCKS5 inbound (tag=$tag)") - } + val tempConfigJson = com.simplexray.an.common.ConfigUtils + .buildTempSocksConfigJson(randomAddr, port, tag, randomUser, randomPass) - // Install a per-session authenticator for these random credentials. - // globalSocksAuthenticator is restored unconditionally in the finally block. - val tempAuth = object : java.net.Authenticator() { + java.net.Authenticator.setDefault(object : java.net.Authenticator() { override fun getPasswordAuthentication() = java.net.PasswordAuthentication(randomUser, randomPass.toCharArray()) - } - java.net.Authenticator.setDefault(tempAuth) + }) + + tempSocksAddress = randomAddr + tempSocksPort = port + tempSocksTag = tag + tempSocksUser = randomUser + tempSocksPass = randomPass + activeProxiedTaskCount = 1 + + appendToAppLog("[SimpleXray] Injecting temporary SOCKS5 inbound at $randomAddr:$port, reloading proxy.") + application.startService( + Intent(application, TProxyService::class.java) + .setAction(TProxyService.ACTION_RELOAD_CONFIG) + .putExtra(TProxyService.EXTRA_TEMP_SOCKS_CONFIG, tempConfigJson) + ) + try { - // Wait for Xray to bind the port before handing out the client. - var bound = false - for (attempt in 1..INBOUND_BIND_RETRIES) { - kotlinx.coroutines.delay(INBOUND_BIND_DELAY_MS) - if (runCatching { - java.net.Socket().use { s -> - s.connect( - InetSocketAddress("127.0.0.1", port), - INBOUND_BIND_DELAY_MS.toInt(), - ) + waitForSocksProxy(randomAddr, port) + } catch (e: IOException) { + cleanupTempSocksLocked(restartProxy = true) + throw e + } + + Pair(tempSocksAddress, tempSocksPort) + } + + private suspend fun decrementAndCleanupIfNeeded() { + tempSocksMutex.withLock { + activeProxiedTaskCount = (activeProxiedTaskCount - 1).coerceAtLeast(0) + if (activeProxiedTaskCount == 0 && tempSocksPort > 0) { + cleanupJob?.cancel() + cleanupJob = viewModelScope.launch(Dispatchers.IO) { + delay(TEMP_SOCKS_MIN_LIFETIME_MS) + tempSocksMutex.withLock { + if (activeProxiedTaskCount == 0 && tempSocksPort > 0) { + cleanupTempSocksLocked(restartProxy = true) } - }.isSuccess - ) { - bound = true - break + cleanupJob = null + } } + } else if (activeProxiedTaskCount > 0 && tempSocksPort <= 0) { + Log.e(TAG, "Inconsistent temp SOCKS state: count=$activeProxiedTaskCount but port=$tempSocksPort; forcing cleanup") + cleanupTempSocksLocked(restartProxy = true) } - if (!bound) { - throw IOException("Temporary SOCKS5 inbound did not bind within the expected time (tag=$tag)") + } + } + + private suspend fun cleanupTempSocksIfActive() { + tempSocksMutex.withLock { + if (activeProxiedTaskCount > 0 || tempSocksPort > 0 || cleanupJob != null) { + cleanupTempSocksLocked(restartProxy = false) } + } + } - val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)) - val client = OkHttpClient.Builder() - .proxy(proxy) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.MINUTES) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - block(client) + private suspend fun withTempSocksProxiedClient( + connectTimeoutMs: Long = 30_000L, + readTimeoutMs: Long = TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS, + block: suspend (OkHttpClient) -> T + ): T { + val (address, port) = ensureTempSocksReady() + val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(address, port)) + val client = OkHttpClient.Builder() + .proxy(proxy) + .connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS) + .build() + try { + return block(client) } finally { - java.net.Authenticator.setDefault(globalSocksAuthenticator) - handlerClient.removeInbound(tag) - handlerClient.close() + decrementAndCleanupIfNeeded() } } @@ -1284,13 +1269,12 @@ class MainViewModel(application: Application) : } } - val tag = "internal-dl-${fileName.removeSuffix(".dat")}" if (_isServiceEnabled.value && prefs.isXrayTunActive) { // In Xray TUN mode the app is excluded from VPN routing, so a plain connection - // would bypass the proxy chain. Create a temporary inbound with random - // credentials so the download goes through the proxy chain. + // would bypass the proxy chain. Inject a temporary SOCKS5 inbound via a config + // file fragment and restart xray so the download goes through the proxy chain. try { - withTempSocksProxiedClient(tag) { client -> doDownload(client) } + withTempSocksProxiedClient { client -> doDownload(client) } } catch (e: CancellationException) { Log.d(TAG, "Download cancelled while setting up proxy for $fileName") _uiEvent.trySend(MainViewUiEvent.ShowSnackbar(application.getString(R.string.download_cancelled))) @@ -1378,10 +1362,11 @@ class MainViewModel(application: Application) : } if (_isServiceEnabled.value && prefs.isXrayTunActive) { - // In Xray TUN mode the app is excluded from VPN routing. Use a temporary - // inbound with random credentials so the request goes through the proxy chain. + // In Xray TUN mode the app is excluded from VPN routing. Inject a temporary + // SOCKS5 inbound via a config file fragment and restart xray so the request + // goes through the proxy chain. try { - withTempSocksProxiedClient("internal-update-check") { client -> doCheck(client) } + withTempSocksProxiedClient { client -> doCheck(client) } } catch (e: Exception) { Log.e(TAG, "Failed to set up temporary proxy for update check", e) _uiEvent.trySend( diff --git a/app/src/main/proto/core_config.proto b/app/src/main/proto/core_config.proto deleted file mode 100644 index 18e37c79..00000000 --- a/app/src/main/proto/core_config.proto +++ /dev/null @@ -1,16 +0,0 @@ -syntax = "proto3"; - -package xray.core; - -option java_package = "com.xray.core"; -option java_multiple_files = true; - -import "typed_message.proto"; - -// Minimal subset of xray-core/core/config.proto needed for AddInbound. -// Field numbers match xray-core exactly so that serialised bytes are compatible. -message InboundHandlerConfig { - string tag = 1; - xray.common.serial.TypedMessage receiver_settings = 2; - xray.common.serial.TypedMessage proxy_settings = 3; -} diff --git a/app/src/main/proto/handler_command.proto b/app/src/main/proto/handler_command.proto deleted file mode 100644 index 6b0ef552..00000000 --- a/app/src/main/proto/handler_command.proto +++ /dev/null @@ -1,27 +0,0 @@ -syntax = "proto3"; - -package xray.app.proxyman.command; - -option java_package = "com.xray.app.proxyman.command"; -option java_multiple_files = true; - -import "core_config.proto"; - -// Minimal subset of xray-core/app/proxyman/command/command.proto. - -message AddInboundRequest { - xray.core.InboundHandlerConfig inbound = 1; -} - -message AddInboundResponse {} - -message RemoveInboundRequest { - string tag = 1; -} - -message RemoveInboundResponse {} - -service HandlerService { - rpc AddInbound(AddInboundRequest) returns (AddInboundResponse) {} - rpc RemoveInbound(RemoveInboundRequest) returns (RemoveInboundResponse) {} -} diff --git a/app/src/main/proto/net_address.proto b/app/src/main/proto/net_address.proto deleted file mode 100644 index 50284ad1..00000000 --- a/app/src/main/proto/net_address.proto +++ /dev/null @@ -1,16 +0,0 @@ -syntax = "proto3"; - -package xray.common.net; - -option java_package = "com.xray.common.net"; -option java_multiple_files = true; - -// Minimal subset of xray-core/common/net/address.proto. -// Field numbers match xray-core exactly so that serialised bytes are compatible. - -message IPOrDomain { - oneof address { - bytes ip = 1; - string domain = 2; - } -} diff --git a/app/src/main/proto/proxyman_config.proto b/app/src/main/proto/proxyman_config.proto deleted file mode 100644 index 3edfacfc..00000000 --- a/app/src/main/proto/proxyman_config.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto3"; - -package xray.app.proxyman; - -option java_package = "com.xray.app.proxyman"; -option java_multiple_files = true; - -import "net_address.proto"; - -// Minimal subset of xray-core/app/proxyman/config.proto. -// Field numbers match xray-core exactly so that serialised bytes are compatible. - -message PortRange { - uint32 From = 1; - uint32 To = 2; -} - -message PortList { - repeated PortRange range = 1; -} - -message ReceiverConfig { - PortList port_list = 1; - xray.common.net.IPOrDomain listen = 2; -} diff --git a/app/src/main/proto/socks_config.proto b/app/src/main/proto/socks_config.proto deleted file mode 100644 index fe928dd0..00000000 --- a/app/src/main/proto/socks_config.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto3"; - -package xray.proxy.socks; - -option java_package = "com.xray.proxy.socks"; -option java_multiple_files = true; - -// Minimal subset of xray-core/proxy/socks/config.proto. -// Field numbers match xray-core exactly so that serialised bytes are compatible. - -enum AuthType { - NO_AUTH = 0; - PASSWORD = 1; -} - -message ServerConfig { - AuthType auth_type = 1; - map accounts = 2; -} diff --git a/app/src/main/proto/typed_message.proto b/app/src/main/proto/typed_message.proto deleted file mode 100644 index c50f5b34..00000000 --- a/app/src/main/proto/typed_message.proto +++ /dev/null @@ -1,16 +0,0 @@ -syntax = "proto3"; - -package xray.common.serial; - -option java_package = "com.xray.common.serial"; -option java_multiple_files = true; - -// Mirrors xray-core/common/serial/typed_message.proto exactly. -// Field numbers must match xray-core so that serialised bytes are compatible. - -message TypedMessage { - // The full proto message type name (e.g. "xray.proxy.socks.ServerConfig"). - string type = 1; - // Serialised proto message bytes. - bytes value = 2; -} From ec1efd9e58e8ec357f4ba3bf040e685a9e0e6173 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:16:07 +0800 Subject: [PATCH 48/55] style: align comment density with lhear/simpleXray upstream (#6) * style: remove over-commented docs inconsistent with project style Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/4b916543-3f50-4398-8e1d-20810010f132 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * style: remove all comments added by this PR to match lhear/simpleXray zero-comment style Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/65099cfc-cde4-4a6b-bded-aa73afbd43a1 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * style: remove/condense dfliaoyue-introduced comments to match upstream zero-comment style Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/cf4133b3-3f37-4ffe-b284-b7fc49cb9be9 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../common/configFormat/VlessLinkConverter.kt | 1 - .../com/simplexray/an/prefs/Preferences.kt | 5 --- .../simplexray/an/service/TProxyService.kt | 41 +------------------ .../simplexray/an/viewmodel/MainViewModel.kt | 30 -------------- 4 files changed, 2 insertions(+), 75 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/common/configFormat/VlessLinkConverter.kt b/app/src/main/kotlin/com/simplexray/an/common/configFormat/VlessLinkConverter.kt index 832eb71f..deebdbca 100644 --- a/app/src/main/kotlin/com/simplexray/an/common/configFormat/VlessLinkConverter.kt +++ b/app/src/main/kotlin/com/simplexray/an/common/configFormat/VlessLinkConverter.kt @@ -36,7 +36,6 @@ class VlessLinkConverter: ConfigFormatConverter { val socksPort = Preferences(context).socksPort - // Build config JSON val config = JSONObject( mapOf( "log" to mapOf("loglevel" to "warning"), diff --git a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt index 339074e3..2c335a75 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -196,11 +196,6 @@ class Preferences(context: Context) { setValueInProvider(USE_XRAY_TUN, value) } - /** - * True when Xray is running its own TUN inbound (rather than hev-socks5-tunnel). - * This is the single authoritative check used by both the service and the ViewModel - * to decide whether VPN-bypass protection is required for outbound sockets. - */ val isXrayTunActive: Boolean get() = useXrayTun && !disableVpn diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index 46b1e311..dd9a4ada 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -93,14 +93,9 @@ class TProxyService : VpnService() { @Volatile private var reloadingRequested = false - /** Temp SOCKS5 config fragment delivered via intent extra; consumed once by [runXrayProcess]. */ @Volatile private var pendingTempSocksConfig: String? = null - /** - * Kill whichever Xray process is currently running, whether it was started - * via ProcessBuilder (managed) or via nativeSpawnXray (native TUN mode). - */ private fun killXrayProcess() { xrayProcess?.destroy() xrayProcess = null @@ -215,7 +210,6 @@ class TProxyService : VpnService() { val xrayPath = "$libraryDir/libxray.so" val configContent = File(selectedConfigPath).readText() - // Read temp SOCKS config fragment if delivered via intent extra. val tempSocksContent = pendingTempSocksConfig.also { pendingTempSocksConfig = null } val apiPort = findAvailablePort( @@ -248,10 +242,7 @@ class TProxyService : VpnService() { val reader: BufferedReader if (useXrayTun) { - // ── Native TUN path ──────────────────────────────────────────── - // The VPN fd has FD_CLOEXEC set by Android, so it cannot survive - // a ProcessBuilder fork+exec. nativeSpawnXray() uses fork()+dup2() - // to move the fd to a fixed slot before exec(), bypassing the issue. + // VPN fd has FD_CLOEXEC set; nativeSpawnXray() uses fork()+dup2() to pass it to the child. val vpnFd = tunFd?.fd ?: run { Log.e(TAG, "tunFd is null for Xray TUN mode") return @@ -267,8 +258,6 @@ class TProxyService : VpnService() { this.xrayPid = currentPid Log.d(TAG, "Xray TUN process started: pid=$currentPid") - // Write config to Xray's stdin then close the write end so Xray - // sees EOF and knows the full config has been delivered. Log.d(TAG, "Writing config to native Xray stdin.") ParcelFileDescriptor.adoptFd(stdinWriteFd).use { pfd -> ParcelFileDescriptor.AutoCloseOutputStream(pfd).use { out -> @@ -282,7 +271,6 @@ class TProxyService : VpnService() { InputStreamReader(ParcelFileDescriptor.AutoCloseInputStream(stdoutPfd)) ) } else { - // ── Managed (ProcessBuilder) path ────────────────────────────── currentPid = -1 val processBuilder = getProcessBuilder(xrayPath) currentProcess = processBuilder.start() @@ -296,7 +284,6 @@ class TProxyService : VpnService() { reader = BufferedReader(InputStreamReader(currentProcess.inputStream)) } - // ── Shared log-reading loop ──────────────────────────────────────── Log.d(TAG, "Reading xray process output.") var line = reader.readLine() while (line != null) { @@ -445,9 +432,6 @@ class TProxyService : VpnService() { } } } - // Always exclude the app itself from the VPN tunnel so that its own - // network traffic (e.g. rule-file downloads, update checks) is never - // routed through the TUN interface. addDisallowedApplication(BuildConfig.APPLICATION_ID) } @@ -511,18 +495,9 @@ class TProxyService : VpnService() { private const val TAG = "VpnService" private const val BROADCAST_DELAY_MS: Long = 3000 - /** Weak reference to the running service instance; null when the service is not running. */ @Volatile private var instance: WeakReference? = null - /** - * Calls [VpnService.protect] on [socket] so its traffic bypasses the TUN tunnel and - * reaches the real network directly. - * - * Returns `true` if the service is running and the socket was successfully protected. - * Returns `false` when the service is **not** running – this is not an error: when no - * VPN tunnel is active, all sockets already reach the real network without protection. - */ fun protectSocket(socket: Socket): Boolean = instance?.get()?.protect(socket) ?: false init { @@ -542,19 +517,7 @@ class TProxyService : VpnService() { @Suppress("FunctionName") private external fun TProxyGetStats(): LongArray? - /** - * Fork-exec the Xray binary with the VPN fd properly inherited. - * - * The VPN fd returned by VpnService.Builder.establish() has FD_CLOEXEC set, - * so it is closed before the child process starts when using ProcessBuilder. - * This native function uses fork()+dup2() to assign the fd to a fixed slot - * (fd 4) before exec(), ensuring it survives into the Xray process. - * - * @param xrayPath Absolute path to the Xray binary. - * @param assetDir Directory containing geo-data assets (XRAY_LOCATION_ASSET). - * @param vpnFd The raw fd integer from the VPN ParcelFileDescriptor. - * @return int[3] = { pid, stdout_read_fd, stdin_write_fd }, or null on error. - */ + /** Returns [pid, stdout_read_fd, stdin_write_fd], or null on error. */ @JvmStatic private external fun nativeSpawnXray( xrayPath: String, diff --git a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt index a557f66a..3a20ff0d 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -86,7 +86,6 @@ class MainViewModel(application: Application) : private var coreStatsClient: CoreStatsClient? = null - // Temporary SOCKS5 state for Xray TUN mode. All fields protected by tempSocksMutex. private val tempSocksMutex = Mutex() private var tempSocksAddress: String = "" private var tempSocksPort: Int = -1 @@ -198,8 +197,6 @@ class MainViewModel(application: Application) : _coreStatsState.value = CoreStatsState() coreStatsClient?.close() coreStatsClient = null - // If the service stopped while downloads were using the temp SOCKS inbound, - // clean up the state so subsequent downloads start fresh. viewModelScope.launch(Dispatchers.IO) { cleanupTempSocksIfActive() } } } @@ -889,9 +886,6 @@ class MainViewModel(application: Application) : } if (_isServiceEnabled.value && prefs.isXrayTunActive) { - // In Xray TUN mode the app is excluded from VPN routing. Inject a temporary - // SOCKS5 inbound so the test goes through the proxy chain, then use a raw - // Socket (same as the other modes) to perform the HTTP request through it. try { val (address, socksPort) = ensureTempSocksReady() val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(address, socksPort)) @@ -997,15 +991,6 @@ class MainViewModel(application: Application) : } } - /** - * Builds an [OkHttpClient] appropriate for the current service state: - * - * - **hev-socks5-tunnel mode** (VPN active, non-TUN): routes through the upstream SOCKS5 - * proxy so the request goes through the user's proxy chain. - * - **Service not running / core-only mode**: plain direct connection. - * - * A [TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS] read timeout stops stalled downloads automatically. - */ private fun buildHttpClient(): OkHttpClient { val serviceActive = _isServiceEnabled.value return OkHttpClient.Builder().apply { @@ -1016,8 +1001,6 @@ class MainViewModel(application: Application) : }.build() } - // ── Temporary SOCKS5 helpers for Xray TUN mode ──────────────────────────────────────────── - private fun appendToAppLog(message: String) { try { LogFileManager(application).appendLog(message) @@ -1043,7 +1026,6 @@ class MainViewModel(application: Application) : throw IOException("Temporary SOCKS5 inbound did not bind on $address:$port within the expected time") } - // Must be called while holding tempSocksMutex. private fun cleanupTempSocksLocked(restartProxy: Boolean) { activeProxiedTaskCount = 0 tempSocksAddress = "" @@ -1063,11 +1045,6 @@ class MainViewModel(application: Application) : } } - /** - * Ensures the shared temporary SOCKS5 inbound is running and returns its address/port. - * Passes the config fragment as an intent extra on [TProxyService.ACTION_RELOAD_CONFIG] - * so no sensitive data touches the file system. - */ private suspend fun ensureTempSocksReady(): Pair = tempSocksMutex.withLock { if (tempSocksPort > 0) { cleanupJob?.cancel() @@ -1191,7 +1168,6 @@ class MainViewModel(application: Application) : _geositeDownloadProgress } - // Core download logic, independent of how the HTTP client was obtained. suspend fun doDownload(client: OkHttpClient) { try { progressFlow.value = application.getString(R.string.connecting) @@ -1270,9 +1246,6 @@ class MainViewModel(application: Application) : } if (_isServiceEnabled.value && prefs.isXrayTunActive) { - // In Xray TUN mode the app is excluded from VPN routing, so a plain connection - // would bypass the proxy chain. Inject a temporary SOCKS5 inbound via a config - // file fragment and restart xray so the download goes through the proxy chain. try { withTempSocksProxiedClient { client -> doDownload(client) } } catch (e: CancellationException) { @@ -1362,9 +1335,6 @@ class MainViewModel(application: Application) : } if (_isServiceEnabled.value && prefs.isXrayTunActive) { - // In Xray TUN mode the app is excluded from VPN routing. Inject a temporary - // SOCKS5 inbound via a config file fragment and restart xray so the request - // goes through the proxy chain. try { withTempSocksProxiedClient { client -> doCheck(client) } } catch (e: Exception) { From 893bed2ddd6cd2a0fdb014b271c706d6563de1e5 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Wed, 15 Apr 2026 17:24:21 +0800 Subject: [PATCH 49/55] Add test purpose note to README Add a note indicating the README is for test purposes only. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6660ef6e..1e3142f5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +for test purpose only. + # SimpleXray icon From 954134425de7cbc151dc59db77b89b7dea622806 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Wed, 15 Apr 2026 17:26:28 +0800 Subject: [PATCH 50/55] Update README to clarify project origin and purpose --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e3142f5..4561c59f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -for test purpose only. +Forked from lhear/simpleXray for testing new functions only. # SimpleXray From 60588f125a636833231996dc7d62181373af3f13 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Wed, 15 Apr 2026 20:20:21 +0800 Subject: [PATCH 51/55] =?UTF-8?q?=E8=BF=98=E5=8E=9F=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 4561c59f..6660ef6e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -Forked from lhear/simpleXray for testing new functions only. - # SimpleXray icon From 58a549275057e96f1c0ef19e4d565acbc4eddb63 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:40:10 +0800 Subject: [PATCH 52/55] fix(xray_exec): eliminate malloc/opendir in child after fork to prevent deadlock on Android 11/13 (#7) * fix: remove redundant Q check, use ContextCompat.registerReceiver() for compatibility Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/aac58c99-1708-44c8-8dfe-8dd7fd314d12 Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * Revert "fix: remove redundant Q check, use ContextCompat.registerReceiver() for compatibility" This reverts commit 76b85fde6447dc312b692bca32fdfb10148e7883. Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: make close_extra_fds fork-safe, remove malloc/opendir in child process Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/9d5b0ada-b5cd-4948-a4b1-629dc79185df Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> * fix: revert unconditional self-exclusion in getVpnBuilder, remove dead protectSocket machinery Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/12a808d0-2ea6-48d7-be8e-5b8df399c0bf Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/jni/xray-exec/xray_exec.c | 46 +++---------------- .../simplexray/an/service/TProxyService.kt | 12 +---- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/app/src/main/jni/xray-exec/xray_exec.c b/app/src/main/jni/xray-exec/xray_exec.c index c8593d09..27c405f3 100644 --- a/app/src/main/jni/xray-exec/xray_exec.c +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -25,7 +25,6 @@ #include #include -#include #include #include #include @@ -42,48 +41,17 @@ #define CHILD_TUN_FD 4 /* - * Close every open fd > 2 except keep_fd. We enumerate /proc/self/fd so that - * we do not have to guess the upper bound, and we collect all candidates before - * closing any of them to avoid invalidating the directory stream mid-walk. - * This also closes the original vpn_fd position once it has been dup2'd to - * CHILD_TUN_FD, so no fd leaks occur. + * Close every open fd > 2 except keep_fd. Uses only async-signal-safe + * syscalls (no malloc, no opendir) so it is safe to call after fork() in a + * multi-threaded process. */ static void close_extra_fds(int keep_fd) { - /* 256 open fds is far more than a freshly-forked Android child will have. */ - static const int MAX_FDS = 256; - int *fds = (int *)malloc((size_t)MAX_FDS * sizeof(int)); - int n = 0; - DIR *dir = opendir("/proc/self/fd"); - - if (!fds) { - if (dir) closedir(dir); - return; + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd <= 0 || max_fd > 65536) max_fd = 1024; + for (int fd = 3; fd < (int)max_fd; fd++) { + if (fd != keep_fd) close(fd); } - - if (!dir) { - /* Fallback: brute-force close a reasonable range. */ - long max = sysconf(_SC_OPEN_MAX); - if (max <= 0 || max > 65536) max = 1024; - for (int i = 3; i < (int)max; i++) { - if (i != keep_fd) close(i); - } - free(fds); - return; - } - - int dir_fd = dirfd(dir); - struct dirent *entry; - while ((entry = readdir(dir)) != NULL && n < MAX_FDS) { - if (entry->d_name[0] < '0' || entry->d_name[0] > '9') continue; - int fd = atoi(entry->d_name); - if (fd > 2 && fd != keep_fd && fd != dir_fd) - fds[n++] = fd; - } - closedir(dir); - - for (int i = 0; i < n; i++) close(fds[i]); - free(fds); } JNIEXPORT jintArray JNICALL diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index dd9a4ada..ea4e5503 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -39,8 +39,6 @@ import java.io.IOException import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.ServerSocket -import java.net.Socket -import java.lang.ref.WeakReference import kotlin.concurrent.Volatile import kotlin.system.exitProcess @@ -112,7 +110,6 @@ class TProxyService : VpnService() { override fun onCreate() { super.onCreate() - instance = WeakReference(this) logFileManager = LogFileManager(this) Log.d(TAG, "TProxyService created.") } @@ -178,7 +175,6 @@ class TProxyService : VpnService() { } override fun onDestroy() { - instance = null super.onDestroy() handler.removeCallbacks(broadcastLogsRunnable) broadcastLogsRunnable.run() @@ -432,7 +428,8 @@ class TProxyService : VpnService() { } } } - addDisallowedApplication(BuildConfig.APPLICATION_ID) + if (prefs.bypassSelectedApps || prefs.apps.isNullOrEmpty()) + addDisallowedApplication(BuildConfig.APPLICATION_ID) } private fun stopService() { @@ -495,11 +492,6 @@ class TProxyService : VpnService() { private const val TAG = "VpnService" private const val BROADCAST_DELAY_MS: Long = 3000 - @Volatile - private var instance: WeakReference? = null - - fun protectSocket(socket: Socket): Boolean = instance?.get()?.protect(socket) ?: false - init { System.loadLibrary("hev-socks5-tunnel") System.loadLibrary("xray-exec") From 7fc9110d057ad1e82e5d27c6baddad751ed4df80 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:43:56 +0800 Subject: [PATCH 53/55] Clean up inline comments in xray_exec.c, keep only necessary ones (#8) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/917c623d-7ff4-4613-b1de-8bcf20471ce7 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- app/src/main/jni/xray-exec/xray_exec.c | 51 ++++++++------------------ 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/app/src/main/jni/xray-exec/xray_exec.c b/app/src/main/jni/xray-exec/xray_exec.c index 27c405f3..f98eb810 100644 --- a/app/src/main/jni/xray-exec/xray_exec.c +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -7,19 +7,16 @@ * via the XRAY_TUN_FD environment variable therefore gives Xray an invalid fd. * * Fix: fork() here in native code, then dup2() the VPN fd to a fixed target fd - * (CHILD_TUN_FD = 4) *before* exec(). dup2() does not copy FD_CLOEXEC, so fd - * 4 survives exec and is visible to Xray as its TUN fd. + * (CHILD_TUN_FD = 4) before exec(). dup2() does not copy FD_CLOEXEC, so fd 4 + * survives exec and is visible to Xray as its TUN fd. * - * The merged Xray config JSON (already assembled in Kotlin) is delivered to - * Xray via stdin so that no sensitive data (API address, temp-SOCKS port) ever - * touches the file system. A stdout pipe allows the caller to read Xray logs. + * The merged Xray config JSON is delivered to Xray via stdin so that no + * sensitive data ever touches the file system. A stdout pipe lets the caller + * read Xray logs. * * JNI signature: - * int[] TProxyService.nativeSpawnXray( - * String xrayPath, String assetDir, int vpnFd) - * - * Returns int[3] = { pid, stdout_read_fd, stdin_write_fd } on success, - * or null on failure. + * int[] TProxyService.nativeSpawnXray(String xrayPath, String assetDir, int vpnFd) + * Returns int[3] = { pid, stdout_read_fd, stdin_write_fd }, or null on failure. */ #include @@ -37,13 +34,12 @@ #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -/* The fd number that will hold the VPN fd inside the Xray process. */ +/* fd number reserved for the VPN fd inside the Xray process */ #define CHILD_TUN_FD 4 /* - * Close every open fd > 2 except keep_fd. Uses only async-signal-safe - * syscalls (no malloc, no opendir) so it is safe to call after fork() in a - * multi-threaded process. + * Close every open fd > 2 except keep_fd. + * Uses only async-signal-safe syscalls so it is safe to call after fork(). */ static void close_extra_fds(int keep_fd) { @@ -64,8 +60,6 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( const char *xray_path = (*env)->GetStringUTFChars(env, xray_path_j, NULL); const char *asset_dir = (*env)->GetStringUTFChars(env, asset_dir_j, NULL); - /* stdin pipe: parent writes config → child reads (stdin_pipe[0] = read end) */ - /* stdout pipe: child writes logs → parent reads (stdout_pipe[0] = read end) */ int stdin_pipe[2] = {-1, -1}; int stdout_pipe[2] = {-1, -1}; @@ -78,13 +72,11 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( return NULL; } - /* Build the environment that will be given to Xray. */ char asset_env[4096]; char tun_fd_env[64]; snprintf(asset_env, sizeof(asset_env), "XRAY_LOCATION_ASSET=%s", asset_dir); snprintf(tun_fd_env, sizeof(tun_fd_env), "XRAY_TUN_FD=%d", CHILD_TUN_FD); - /* Inherit parent environment, overriding / appending the two XRAY_* vars. */ extern char **environ; int parent_ec = 0; while (environ[parent_ec]) parent_ec++; @@ -108,7 +100,6 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( new_env[ni++] = tun_fd_env; new_env[ni] = NULL; - /* argv: xray_path only – Xray reads its JSON config from stdin. */ char *argv[] = { (char *)xray_path, NULL }; pid_t pid = fork(); @@ -123,43 +114,34 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( } if (pid == 0) { - /* ===== CHILD PROCESS ===== */ - - /* Send SIGKILL to this process if the parent dies. */ - prctl(PR_SET_PDEATHSIG, SIGKILL); + /* child */ + prctl(PR_SET_PDEATHSIG, SIGKILL); /* die with parent */ - /* stdin ← read end of stdin pipe */ dup2(stdin_pipe[0], STDIN_FILENO); - - /* stdout + stderr → write end of stdout pipe */ dup2(stdout_pipe[1], STDOUT_FILENO); dup2(stdout_pipe[1], STDERR_FILENO); - /* Close the original pipe ends (already dup2'd). */ close(stdin_pipe[0]); close(stdin_pipe[1]); close(stdout_pipe[0]); close(stdout_pipe[1]); - /* Assign the VPN fd to CHILD_TUN_FD (dup2 never sets FD_CLOEXEC). */ + /* dup2 does not copy FD_CLOEXEC, so CHILD_TUN_FD survives exec */ if ((int)vpn_fd >= 0 && (int)vpn_fd != CHILD_TUN_FD) { if (dup2((int)vpn_fd, CHILD_TUN_FD) < 0) { _exit(1); } } - /* Close every other inherited fd, keeping only 0, 1, 2, CHILD_TUN_FD. */ close_extra_fds(CHILD_TUN_FD); execve(xray_path, argv, new_env); - /* exec failed */ _exit(1); } - /* ===== PARENT PROCESS ===== */ + /* parent */ free(new_env); - /* Close the ends that belong to the child. */ - close(stdin_pipe[0]); /* child reads from this; parent must not keep it */ - close(stdout_pipe[1]); /* child writes to this; parent must not keep it */ + close(stdin_pipe[0]); + close(stdout_pipe[1]); (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); @@ -167,7 +149,6 @@ Java_com_simplexray_an_service_TProxyService_nativeSpawnXray( LOGI("Spawned xray pid=%d stdout_read_fd=%d stdin_write_fd=%d", pid, stdout_pipe[0], stdin_pipe[1]); - /* Return { pid, stdout_read_fd, stdin_write_fd } */ jintArray result = (*env)->NewIntArray(env, 3); if (!result) { close(stdout_pipe[0]); close(stdin_pipe[1]); From 37639c744ac057829758c8429dceeea8afd52492 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:00:50 +0800 Subject: [PATCH 54/55] perf: buffer log file writes instead of per-line flush (#10) Agent-Logs-Url: https://github.com/dfliaoyue/SimpleXray/sessions/a12e69ec-af96-4f51-a27b-ad2602fcffc3 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dfliaoyue <7167345+dfliaoyue@users.noreply.github.com> --- .../an/data/source/LogFileManager.kt | 17 +++++++++++++++++ .../com/simplexray/an/service/TProxyService.kt | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt b/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt index 6665d743..e28c5329 100644 --- a/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt +++ b/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt @@ -37,6 +37,23 @@ class LogFileManager(context: Context) { } } + @Synchronized + fun appendLogs(logEntries: List) { + if (logEntries.isEmpty()) return + try { + FileWriter(logFile, true).buffered().use { writer -> + for (entry in logEntries) { + writer.write(entry) + writer.newLine() + } + } + } catch (e: IOException) { + Log.e(TAG, "Error appending logs to file", e) + } finally { + checkAndTruncateLogFile() + } + } + fun readLogs(): String? { val logContent = StringBuilder() if (!logFile.exists()) { diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index ea4e5503..e1ae4762 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -60,6 +60,15 @@ class TProxyService : VpnService() { } } } + private val logFileBuffer: MutableList = mutableListOf() + private val flushLogsToFileRunnable = Runnable { + synchronized(logFileBuffer) { + if (logFileBuffer.isNotEmpty()) { + logFileManager.appendLogs(ArrayList(logFileBuffer)) + logFileBuffer.clear() + } + } + } private fun findAvailablePort(excludedPorts: Set): Int? { (10000..65535) @@ -178,6 +187,8 @@ class TProxyService : VpnService() { super.onDestroy() handler.removeCallbacks(broadcastLogsRunnable) broadcastLogsRunnable.run() + handler.removeCallbacks(flushLogsToFileRunnable) + flushLogsToFileRunnable.run() serviceScope.cancel() Log.d(TAG, "TProxyService destroyed.") exitProcess(0) @@ -283,7 +294,12 @@ class TProxyService : VpnService() { Log.d(TAG, "Reading xray process output.") var line = reader.readLine() while (line != null) { - logFileManager.appendLog(line) + synchronized(logFileBuffer) { + logFileBuffer.add(line) + if (!handler.hasCallbacks(flushLogsToFileRunnable)) { + handler.postDelayed(flushLogsToFileRunnable, BROADCAST_DELAY_MS) + } + } synchronized(logBroadcastBuffer) { logBroadcastBuffer.add(line) if (!handler.hasCallbacks(broadcastLogsRunnable)) { From 790cc5b4419300ba66282a50413e051c40b626d7 Mon Sep 17 00:00:00 2001 From: dfliaoyue Date: Fri, 17 Apr 2026 22:38:34 +0800 Subject: [PATCH 55/55] Revert "perf: buffer log file writes instead of per-line flush (#10)" (#11) This reverts commit 37639c744ac057829758c8429dceeea8afd52492. --- .../an/data/source/LogFileManager.kt | 17 ----------------- .../com/simplexray/an/service/TProxyService.kt | 18 +----------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt b/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt index e28c5329..6665d743 100644 --- a/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt +++ b/app/src/main/kotlin/com/simplexray/an/data/source/LogFileManager.kt @@ -37,23 +37,6 @@ class LogFileManager(context: Context) { } } - @Synchronized - fun appendLogs(logEntries: List) { - if (logEntries.isEmpty()) return - try { - FileWriter(logFile, true).buffered().use { writer -> - for (entry in logEntries) { - writer.write(entry) - writer.newLine() - } - } - } catch (e: IOException) { - Log.e(TAG, "Error appending logs to file", e) - } finally { - checkAndTruncateLogFile() - } - } - fun readLogs(): String? { val logContent = StringBuilder() if (!logFile.exists()) { diff --git a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt index e1ae4762..ea4e5503 100644 --- a/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt +++ b/app/src/main/kotlin/com/simplexray/an/service/TProxyService.kt @@ -60,15 +60,6 @@ class TProxyService : VpnService() { } } } - private val logFileBuffer: MutableList = mutableListOf() - private val flushLogsToFileRunnable = Runnable { - synchronized(logFileBuffer) { - if (logFileBuffer.isNotEmpty()) { - logFileManager.appendLogs(ArrayList(logFileBuffer)) - logFileBuffer.clear() - } - } - } private fun findAvailablePort(excludedPorts: Set): Int? { (10000..65535) @@ -187,8 +178,6 @@ class TProxyService : VpnService() { super.onDestroy() handler.removeCallbacks(broadcastLogsRunnable) broadcastLogsRunnable.run() - handler.removeCallbacks(flushLogsToFileRunnable) - flushLogsToFileRunnable.run() serviceScope.cancel() Log.d(TAG, "TProxyService destroyed.") exitProcess(0) @@ -294,12 +283,7 @@ class TProxyService : VpnService() { Log.d(TAG, "Reading xray process output.") var line = reader.readLine() while (line != null) { - synchronized(logFileBuffer) { - logFileBuffer.add(line) - if (!handler.hasCallbacks(flushLogsToFileRunnable)) { - handler.postDelayed(flushLogsToFileRunnable, BROADCAST_DELAY_MS) - } - } + logFileManager.appendLog(line) synchronized(logBroadcastBuffer) { logBroadcastBuffer.add(line) if (!handler.hasCallbacks(broadcastLogsRunnable)) {