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..f98eb810 --- /dev/null +++ b/app/src/main/jni/xray-exec/xray_exec.c @@ -0,0 +1,160 @@ +/* + * 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 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 }, or null on failure. + */ + +#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__) + +/* 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 so it is safe to call after fork(). + */ +static void close_extra_fds(int keep_fd) +{ + 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); + } +} + +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); + + 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)); + 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; + } + + 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); + + 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; + + 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 */ + prctl(PR_SET_PDEATHSIG, SIGKILL); /* die with parent */ + + dup2(stdin_pipe[0], STDIN_FILENO); + dup2(stdout_pipe[1], STDOUT_FILENO); + dup2(stdout_pipe[1], STDERR_FILENO); + + close(stdin_pipe[0]); close(stdin_pipe[1]); + close(stdout_pipe[0]); close(stdout_pipe[1]); + + /* 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_extra_fds(CHILD_TUN_FD); + + execve(xray_path, argv, new_env); + _exit(1); + } + + /* parent */ + free(new_env); + + close(stdin_pipe[0]); + close(stdout_pipe[1]); + + (*env)->ReleaseStringUTFChars(env, xray_path_j, xray_path); + (*env)->ReleaseStringUTFChars(env, asset_dir_j, asset_dir); + + LOGI("Spawned xray pid=%d stdout_read_fd=%d stdin_write_fd=%d", + pid, stdout_pipe[0], stdin_pipe[1]); + + jintArray result = (*env)->NewIntArray(env, 3); + if (!result) { + close(stdout_pipe[0]); close(stdin_pipe[1]); + 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/common/ConfigUtils.kt b/app/src/main/kotlin/com/simplexray/an/common/ConfigUtils.kt index 7bc8d88b..3db49cad 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,23 @@ 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("protocol") == "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 + } + @Throws(JSONException::class) fun formatConfigContent(content: String): String { val jsonObject = JSONObject(content) @@ -37,18 +54,37 @@ object ConfigUtils { servicesArray.put("StatsService") 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 { @@ -87,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/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 3e18bef9..2c335a75 100644 --- a/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt +++ b/app/src/main/kotlin/com/simplexray/an/prefs/Preferences.kt @@ -190,9 +190,21 @@ 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 isXrayTunActive: Boolean + get() = useXrayTun && !disableVpn + val tunnelMtu: Int get() = 8500 + val tunnelMtuForXrayTun: Int + get() = 1500 + val tunnelIpv4Address: String get() = "198.18.0.1" @@ -336,6 +348,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..ea4e5503 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,30 @@ 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 + @Volatile + private var pendingTempSocksConfig: String? = null + + 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) @@ -101,11 +123,12 @@ 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)") 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) @@ -179,7 +205,13 @@ class TProxyService : VpnService() { val selectedConfigPath = prefs.selectedConfigPath ?: return val xrayPath = "$libraryDir/libxray.so" val configContent = File(selectedConfigPath).readText() - val apiPort = findAvailablePort(extractPortsFromJson(configContent)) ?: return + + 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") @@ -189,23 +221,68 @@ class TProxyService : VpnService() { prefs.apiAddress = "127.$octet2.$octet3.$octet4" Log.d(TAG, "Randomized API address: ${prefs.apiAddress}") - val processBuilder = getProcessBuilder(xrayPath) - currentProcess = processBuilder.start() - this.xrayProcess = currentProcess + 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 + + if (useXrayTun) { + // 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 + } + 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") + + Log.d(TAG, "Writing config to native Xray stdin.") + ParcelFileDescriptor.adoptFd(stdinWriteFd).use { pfd -> + ParcelFileDescriptor.AutoCloseOutputStream(pfd).use { out -> + out.write(finalConfigContent.toByteArray()) + out.flush() + } + } - 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() + stdoutPfd = ParcelFileDescriptor.adoptFd(stdoutReadFd) + reader = BufferedReader( + InputStreamReader(ParcelFileDescriptor.AutoCloseInputStream(stdoutPfd)) + ) + } else { + 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(finalConfigContent.toByteArray()) + os.flush() + } + reader = BufferedReader(InputStreamReader(currentProcess.inputStream)) } - val inputStream = currentProcess.inputStream - val reader = BufferedReader(InputStreamReader(inputStream)) - var line: String Log.d(TAG, "Reading xray process output.") - while ((reader.readLine().also { line = it }) != null) { + var line = reader.readLine() + while (line != null) { logFileManager.appendLog(line) synchronized(logBroadcastBuffer) { logBroadcastBuffer.add(line) @@ -213,6 +290,7 @@ class TProxyService : VpnService() { handler.postDelayed(broadcastLogsRunnable, BROADCAST_DELAY_MS) } } + line = reader.readLine() } Log.d(TAG, "xray process output stream finished.") } catch (e: InterruptedIOException) { @@ -220,6 +298,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.") @@ -230,9 +309,12 @@ 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 + } } } @@ -248,13 +330,12 @@ class TProxyService : VpnService() { } 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() @@ -263,30 +344,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.tunnelMtuForXrayTun + } 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 +390,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 +441,10 @@ class TProxyService : VpnService() { tunFd = null } stopForeground(Service.STOP_FOREGROUND_REMOVE) - TProxyStopService() + val prefs = Preferences(this) + if (!prefs.useXrayTun || prefs.disableVpn) { + TProxyStopService() + } } exit() } @@ -392,11 +488,13 @@ 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 init { System.loadLibrary("hev-socks5-tunnel") + System.loadLibrary("xray-exec") } @JvmStatic @@ -411,6 +509,14 @@ class TProxyService : VpnService() { @Suppress("FunctionName") private external fun TProxyGetStats(): LongArray? + /** Returns [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") 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 424f6e86..62fe8cf5 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_address), currentValue = settingsState.socksAddress.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 c6c2dc4d..3a20ff0d 100644 --- a/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt +++ b/app/src/main/kotlin/com/simplexray/an/viewmodel/MainViewModel.kt @@ -24,6 +24,7 @@ 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 @@ -31,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 @@ -38,26 +40,36 @@ 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 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 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" +private const val EPHEMERAL_PORT_RANGE_START = 32768 +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() data class ShareLauncher(val intent: Intent) : MainViewUiEvent() @@ -74,6 +86,27 @@ class MainViewModel(application: Application) : private var coreStatsClient: CoreStatsClient? = null + 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 + 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 @@ -95,6 +128,7 @@ class MainViewModel(application: Application) : httpProxyEnabled = prefs.httpProxyEnabled, bypassLanEnabled = prefs.bypassLan, disableVpn = prefs.disableVpn, + useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), info = InfoStates( @@ -163,6 +197,7 @@ class MainViewModel(application: Application) : _coreStatsState.value = CoreStatsState() coreStatsClient?.close() coreStatsClient = null + viewModelScope.launch(Dispatchers.IO) { cleanupTempSocksIfActive() } } } @@ -194,6 +229,7 @@ class MainViewModel(application: Application) : httpProxyEnabled = prefs.httpProxyEnabled, bypassLanEnabled = prefs.bypassLan, disableVpn = prefs.disableVpn, + useXrayTun = prefs.useXrayTun, themeMode = prefs.theme ), info = _settingsState.value.info.copy( @@ -236,18 +272,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) { @@ -575,6 +600,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( @@ -802,59 +834,75 @@ 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 proxy = - Proxy(Proxy.Type.SOCKS, InetSocketAddress(prefs.socksAddress, prefs.socksPort)) val timeout = prefs.connectivityTestTimeout - val start = System.currentTimeMillis() - 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() - ) - } - 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() + + fun doTest(proxy: Proxy) { + val start = System.currentTimeMillis() + try { + 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() + ) + } + 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) { + 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) } } } @@ -943,6 +991,167 @@ class MainViewModel(application: Application) : } } + 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))) + } + readTimeout(TEMP_SOCKS_NO_TRAFFIC_TIMEOUT_MS, TimeUnit.MILLISECONDS) + }.build() + } + + 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") + } + + 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) + ) + } + } + + 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() + 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 + 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 tempConfigJson = com.simplexray.an.common.ConfigUtils + .buildTempSocksConfigJson(randomAddr, port, tag, randomUser, randomPass) + + java.net.Authenticator.setDefault(object : java.net.Authenticator() { + override fun getPasswordAuthentication() = + java.net.PasswordAuthentication(randomUser, randomPass.toCharArray()) + }) + + 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 { + 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) + } + 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) + } + } + } + + private suspend fun cleanupTempSocksIfActive() { + tempSocksMutex.withLock { + if (activeProxiedTaskCount > 0 || tempSocksPort > 0 || cleanupJob != null) { + cleanupTempSocksLocked(restartProxy = false) + } + } + } + + 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 { + decrementAndCleanupIfNeeded() + } + } + fun downloadRuleFile(url: String, fileName: String) { val currentJob = if (fileName == "geoip.dat") geoipDownloadJob else geositeDownloadJob if (currentJob?.isActive == true) { @@ -959,85 +1168,97 @@ 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) + 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() + } + + if (_isServiceEnabled.value && prefs.isXrayTunActive) { + try { + 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))) + 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()) } } @@ -1079,41 +1300,54 @@ 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) { + try { + withTempSocksProxiedClient { 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()) } } } 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 4a402484..7965e84c 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 858eedf2..09bcf280 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -123,4 +123,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 10aeb67e..d968a780 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -123,4 +123,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 2c3d5c21..366b615a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -123,4 +123,6 @@ 深色 自动 SOCKS 用户 + 使用 Xray TUN + 启用时,使用 Xray 内置 TUN 入站,而非 hev-socks5-tunnel。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab792ea7..5d5ec067 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,4 +128,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