diff --git a/V2rayNG/app/src/main/AndroidManifest.xml b/V2rayNG/app/src/main/AndroidManifest.xml
index 4d150c65b..69651a3a3 100644
--- a/V2rayNG/app/src/main/AndroidManifest.xml
+++ b/V2rayNG/app/src/main/AndroidManifest.xml
@@ -201,6 +201,17 @@
android:value="proxy" />
+
+
+
+
/proc/$corePid/oom_score_adj 2>/dev/null; sleep 5; done' >/dev/null 2>&1 &")
+ appendLine("echo \$! > '$oomGuardPid'")
+ // tun device node
+ appendLine("if [ ! -e /dev/net/tun ]; then mkdir -p /dev/net; mknod /dev/net/tun c 10 200; chmod 666 /dev/net/tun; fi")
+ // hev-socks5-tunnel config: it creates the tun ($TUN) itself and forwards it to the
+ // in-process core's SOCKS inbound on loopback. MTU comes from the existing VPN MTU
+ // setting. No fwmark on hev's sockets: its only upstream connection is to 127.0.0.1
+ // (loopback, already RETURNed by the 127.0.0.0/8 bypass) and the core's real outbound
+ // runs as the app uid (RETURNed by the uid-owner rule), so traffic can't loop.
+ appendLine("cat > '$cfgFile' <<'HEVCFG'")
+ append(buildHevConfig(port, ipv6))
+ appendLine("HEVCFG")
+ appendLine("nohup \"\$BIN\" '$cfgFile' >'$logFile' 2>&1 &")
+ appendLine("T2S_PID=\$!")
+ appendLine("echo \$T2S_PID > '$pidFile'")
+ appendLine("echo ${AppConfig.ROOT_OOM_SCORE} > /proc/\$T2S_PID/oom_score_adj 2>/dev/null || true")
+ // wait for the interface hev creates to appear
+ appendLine("i=0; while [ \$i -lt 20 ]; do ip link show $TUN >/dev/null 2>&1 && break; sleep 0.3; i=\$((i+1)); done")
+ appendLine("ip link show $TUN >/dev/null 2>&1 || { echo 'tun device did not come up'; cat '$logFile' 2>/dev/null; exit 1; }")
+ // relax reverse-path filtering for the tun
+ appendLine("echo 0 > /proc/sys/net/ipv4/conf/$TUN/rp_filter 2>/dev/null || true")
+ appendLine("echo 0 > /proc/sys/net/ipv4/conf/all/rp_filter 2>/dev/null || true")
+ // address + default route in a dedicated table
+ appendLine("ip addr add ${AppConfig.ROOT_TUN_ADDR_V4} dev $TUN 2>/dev/null || true")
+ appendLine("ip link set dev $TUN up")
+ appendLine("ip route replace default dev $TUN table $TABLE")
+ appendLine("ip rule add fwmark $MARK table $TABLE priority $PRIORITY")
+ // mark the device's own packets into the tun (Root mode only)
+ if (captureDeviceTraffic) {
+ append(buildMangleMarking("iptables", appUid, perAppEnabled, bypassApps, selectedUids))
+ }
+ // optionally route hotspot / USB-tethered clients through the tun too
+ if (lanShare) {
+ append(buildLanShareSetup(captureDeviceTraffic, ipv6))
+ }
+ if (captureDeviceTraffic) {
+ // IPv6 is best-effort: never fail the (working) IPv4 setup over it.
+ appendLine("set +e")
+ if (ipv6) {
+ // route the device's v6 into the tun, same as v4
+ appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true")
+ appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true")
+ appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
+ append(buildMangleMarking("ip6tables", appUid, perAppEnabled, bypassApps, selectedUids))
+ } else {
+ // v6 disabled: blackhole native v6 egress for the captured apps so they
+ // fall back to v4-through-proxy, matching what a v4-only VpnService does.
+ append(buildV6Blackhole(appUid, perAppEnabled, bypassApps, selectedUids))
+ }
+ }
+ }
+ }
+
+ /**
+ * hev-socks5-tunnel YAML config. hev creates the tun device named [TUN] itself, assigns it
+ * the tun addresses, and forwards everything it receives to the core's SOCKS inbound on
+ * loopback (TCP + UDP). MTU is taken from the existing VPN MTU setting. v6 is only given a
+ * tun address when IPv6 is enabled; whether v6 actually flows in is decided separately by
+ * the v6 route into [TABLE].
+ */
+ private fun buildHevConfig(socksPort: Int, ipv6: Boolean): String {
+ val v4 = AppConfig.ROOT_TUN_ADDR_V4.substringBefore("/")
+ val v6 = AppConfig.ROOT_TUN_ADDR_V6.substringBefore("/")
+ return buildString {
+ appendLine("tunnel:")
+ appendLine(" name: '$TUN'")
+ appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
+ appendLine(" multi-queue: true")
+ appendLine(" ipv4: '$v4'")
+ if (ipv6) appendLine(" ipv6: '$v6'")
+ appendLine("socks5:")
+ appendLine(" port: $socksPort")
+ appendLine(" address: '${AppConfig.LOOPBACK}'")
+ appendLine(" udp: 'udp'")
+ appendLine(" tcp-fastopen: true")
+ }
+ }
+
+ /**
+ * mangle OUTPUT marking chain (ipv4/ipv6). Mirrors VpnService's capture behavior:
+ * - all-apps (no per-app): mark EVERY remaining uid (incl uid 0 + all system uids), so
+ * nothing is missed;
+ * - bypass mode: the selected apps go fully direct, everything else is captured;
+ * - proxy mode: only the selected apps are captured.
+ */
+ private fun buildMangleMarking(
+ cmd: String,
+ appUid: Int,
+ perAppEnabled: Boolean,
+ bypassApps: Boolean,
+ selectedUids: List,
+ ): String {
+ val allowMode = perAppEnabled && !bypassApps
+ val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty()
+ return buildString {
+ appendLine("$cmd -t mangle -N $CHAIN 2>/dev/null || true")
+ appendLine("$cmd -t mangle -F $CHAIN")
+ // the app's own core traffic (the real outbound) must not loop back into the tun.
+ // The $FWMARK RETURN is kept defensively (hev itself only talks to loopback, which
+ // the 127.0.0.0/8 bypass below already RETURNs).
+ appendLine("$cmd -t mangle -A $CHAIN -m mark --mark $FWMARK -j RETURN")
+ appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $appUid -j RETURN")
+ // bypass mode: selected apps go fully direct (incl their DNS)
+ if (bypassSelected) {
+ selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j RETURN") }
+ }
+ // Route DNS through the core for ALL modes, with no uid filter. On Android the
+ // DNS query is sent by netd (a shared system uid) on behalf of the app, not under
+ // the app's own uid, so it can't be attributed to a selected uid via owner-match.
+ // This MUST also run before the LAN-bypass RETURNs below, otherwise a query to a
+ // LAN/router resolver (192.168.x / 10.x) would be returned direct and resolved by
+ // the local ISP resolver (DNS leak + CDN mis-resolution, e.g. Instagram media).
+ // The MARK survives a later RETURN, so the marked query still routes into the tun.
+ appendLine("$cmd -t mangle -A $CHAIN -p udp --dport 53 -j MARK --set-xmark $MARK")
+ appendLine("$cmd -t mangle -A $CHAIN -p tcp --dport 53 -j MARK --set-xmark $MARK")
+ // keep LAN / private destinations direct (per-family CIDR list)
+ val cidrs = if (cmd == "ip6tables") bypassCidrsV6 else bypassCidrs
+ cidrs.forEach { appendLine("$cmd -t mangle -A $CHAIN -d $it -j RETURN") }
+ if (allowMode) {
+ // Proxy ONLY the explicitly selected apps. If nothing resolved (e.g. the
+ // selected packages failed to resolve to uids at early boot), mark nothing
+ // instead of falling through to the catch-all below: a fail-open here would
+ // tunnel every unselected app — both a privacy leak and the "per-app proxies
+ // everything after a reboot" bug.
+ selectedUids.forEach { appendLine("$cmd -t mangle -A $CHAIN -m owner --uid-owner $it -j MARK --set-xmark $MARK") }
+ } else {
+ // all-apps mode (per-app off) or bypass mode: capture EVERY remaining uid
+ // (incl uid 0 + system uids)
+ appendLine("$cmd -t mangle -A $CHAIN -j MARK --set-xmark $MARK")
+ }
+ appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true")
+ appendLine("$cmd -t mangle -A OUTPUT -j $CHAIN")
+ }
+ }
+
+ /**
+ * Blackhole native IPv6 egress for the captured app population when IPv6 is NOT routed
+ * into the tun. A v4-only VpnService has no v6 route, so the kernel rejects apps' v6 and
+ * they fall back to IPv4; Root mode has to reproduce that explicitly, otherwise v6-capable
+ * apps reach destinations natively, bypassing the proxy / leaking. REJECT (not DROP) gives
+ * an instant failure so happy-eyeballs falls back to v4 without a timeout.
+ *
+ * Exemptions mirror the v4 chain: the tun2socks helper (fwmark), the app's own core (uid),
+ * loopback, link-local / multicast (NDP/RA/MLD) and ULA/LAN destinations. Per-app selection
+ * is honored: in bypass mode the bypassed apps keep native v6; in proxy mode only the
+ * selected apps lose v6 (everything else stays fully direct).
+ */
+ private fun buildV6Blackhole(
+ appUid: Int,
+ perAppEnabled: Boolean,
+ bypassApps: Boolean,
+ selectedUids: List,
+ ): String {
+ val chain = AppConfig.ROOT_V6_CHAIN
+ val allowMode = perAppEnabled && !bypassApps
+ val bypassSelected = perAppEnabled && bypassApps && selectedUids.isNotEmpty()
+ val reject = "-j REJECT --reject-with icmp6-adm-prohibited"
+ return buildString {
+ appendLine("ip6tables -t filter -N $chain 2>/dev/null || true")
+ appendLine("ip6tables -t filter -F $chain")
+ // never touch the helper, the core, loopback, NDP/link-local/multicast or LAN
+ appendLine("ip6tables -t filter -A $chain -m mark --mark $FWMARK -j RETURN")
+ appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $appUid -j RETURN")
+ appendLine("ip6tables -t filter -A $chain -o lo -j RETURN")
+ bypassCidrsV6.forEach { appendLine("ip6tables -t filter -A $chain -d $it -j RETURN") }
+ // bypass mode: bypassed apps keep their native v6
+ if (bypassSelected) {
+ selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it -j RETURN") }
+ }
+ if (allowMode) {
+ // proxy mode: only the selected apps lose v6 (so they fall back to v4-via-proxy).
+ // None resolved -> reject nothing, mirroring the v4 chain's fail-closed handling.
+ selectedUids.forEach { appendLine("ip6tables -t filter -A $chain -m owner --uid-owner $it $reject") }
+ } else {
+ // all-apps / bypass: reject everyone left
+ appendLine("ip6tables -t filter -A $chain $reject")
+ }
+ appendLine("ip6tables -t filter -D OUTPUT -j $chain 2>/dev/null || true")
+ appendLine("ip6tables -t filter -A OUTPUT -j $chain")
+ }
+ }
+
+ // -------------------------------------------------- LAN / tethering sharing
+
+ /**
+ * Route Wi-Fi-hotspot / USB-tethered clients through the tun as well (ipv4).
+ * Best-effort: wrapped in `set +e` so a failure here never breaks the working proxy.
+ * Mirrors Magic_V2Ray's hotspot rules (FORWARD accept, DNS DNAT, source-based policy
+ * routing for private client ranges, MSS clamp).
+ */
+ private fun buildLanShareSetup(captureDeviceTraffic: Boolean, ipv6: Boolean): String {
+ val fwd = AppConfig.ROOT_FWD_CHAIN
+ val dnsChain = AppConfig.ROOT_DNS_CHAIN
+ val v6fwd = AppConfig.ROOT_V6_FWD_CHAIN
+ val v6pre = AppConfig.ROOT_V6_PRE_CHAIN
+ // Use the app's configured remote DNS (first plain IPv4) as the DNAT target for
+ // tethered clients; fall back to the default when it's a DoH/DoT/IPv6 value that
+ // can't be a DNAT target.
+ val dns = SettingsManager.getRemoteDnsServers()
+ .firstOrNull { Utils.isPureIpAddress(it) && !it.contains(":") }
+ ?: AppConfig.ROOT_LAN_DNS
+ val lanCidrs = listOf("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
+ return buildString {
+ appendLine("set +e")
+ appendLine("echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true")
+ // forward traffic to/from the tun
+ appendLine("iptables -N $fwd 2>/dev/null || true")
+ appendLine("iptables -F $fwd")
+ appendLine("iptables -A $fwd -i $TUN -j ACCEPT")
+ appendLine("iptables -A $fwd -o $TUN -j ACCEPT")
+ appendLine("iptables -D FORWARD -j $fwd 2>/dev/null || true")
+ appendLine("iptables -I FORWARD -j $fwd")
+ // clamp MSS to avoid TLS fragmentation overhead through the tunnel
+ appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true")
+ appendLine("iptables -t mangle -A FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350")
+ // hijack tethered clients' DNS into a dedicated chain so it resolves through the
+ // tunnel; the chain keeps teardown independent of the resolver IP
+ appendLine("iptables -t nat -N $dnsChain 2>/dev/null || true")
+ appendLine("iptables -t nat -F $dnsChain")
+ lanCidrs.forEach {
+ appendLine("iptables -t nat -A $dnsChain ! -i $TUN -d $it -p udp --dport 53 -j DNAT --to $dns")
+ }
+ appendLine("iptables -t nat -D PREROUTING -j $dnsChain 2>/dev/null || true")
+ appendLine("iptables -t nat -A PREROUTING -j $dnsChain")
+ // policy routing: return-path via main, LAN direct, the rest via the tun table
+ appendLine("ip rule add iif lo goto 6000 pref 5000 2>/dev/null || true")
+ appendLine("ip rule add iif $TUN lookup main suppress_prefixlength 0 pref 5010 2>/dev/null || true")
+ appendLine("ip rule add iif $TUN goto 6000 pref 5020 2>/dev/null || true")
+ appendLine("ip rule add to 10.0.0.0/8 lookup main pref 5025 2>/dev/null || true")
+ appendLine("ip rule add to 172.16.0.0/12 lookup main pref 5026 2>/dev/null || true")
+ appendLine("ip rule add to 192.168.0.0/16 lookup main pref 5027 2>/dev/null || true")
+ appendLine("ip rule add from 10.0.0.0/8 lookup $TABLE pref 5030 2>/dev/null || true")
+ appendLine("ip rule add from 172.16.0.0/12 lookup $TABLE pref 5040 2>/dev/null || true")
+ appendLine("ip rule add from 192.168.0.0/16 lookup $TABLE pref 5050 2>/dev/null || true")
+ appendLine("ip rule add nop pref 6000 2>/dev/null || true")
+
+ // ---------------------------------------------------------- IPv6 clients
+ // Tethered/hotspot clients get a native (RA-assigned) global IPv6. The IPv4 rules
+ // above don't touch it, so it egresses the upstream interface directly, bypassing
+ // the proxy = IPv6 leak. Handle it explicitly (mirrors vincentng295/Magic_V2Ray
+ // cae4f7f): route it through the tun when v6 is enabled, reject it when it isn't.
+ appendLine("ip6tables -N $v6fwd 2>/dev/null || true")
+ appendLine("ip6tables -F $v6fwd")
+ appendLine("ip6tables -D FORWARD -j $v6fwd 2>/dev/null || true")
+ appendLine("ip6tables -I FORWARD -j $v6fwd")
+ if (ipv6) {
+ // When the device itself isn't capturing v6 (VPN-mode sharing) the tun table
+ // has no v6 default and the tun has no v6 address — add them so marked client
+ // v6 has somewhere to go. In Root mode the device-capture block already did.
+ if (!captureDeviceTraffic) {
+ appendLine("ip -6 addr add ${AppConfig.ROOT_TUN_ADDR_V6} dev $TUN 2>/dev/null || true")
+ appendLine("ip -6 route replace default dev $TUN table $TABLE 2>/dev/null || true")
+ appendLine("ip -6 rule add fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
+ }
+ // allow forwarding to/from the tun
+ appendLine("ip6tables -A $v6fwd -i $TUN -j ACCEPT")
+ appendLine("ip6tables -A $v6fwd -o $TUN -j ACCEPT")
+ // mark forwarded (non-locally-sourced) client v6 into the tun table. DNS first
+ // so a query to a LAN/router resolver is still tunneled (MARK survives RETURN);
+ // keep loopback, link-local (NDP/RA) and ULA/multicast direct.
+ appendLine("ip6tables -t mangle -N $v6pre 2>/dev/null || true")
+ appendLine("ip6tables -t mangle -F $v6pre")
+ appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p udp --dport 53 -j MARK --set-xmark $MARK")
+ appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -p tcp --dport 53 -j MARK --set-xmark $MARK")
+ bypassCidrsV6.forEach { appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -d $it -j RETURN") }
+ appendLine("ip6tables -t mangle -A $v6pre ! -i $TUN -m addrtype ! --src-type LOCAL -j MARK --set-xmark $MARK")
+ appendLine("ip6tables -t mangle -D PREROUTING -j $v6pre 2>/dev/null || true")
+ appendLine("ip6tables -t mangle -A PREROUTING -j $v6pre")
+ // fail closed: any forwarded v6 that wasn't marked into the tun (e.g. the
+ // addrtype match is unavailable, or marking failed) is rejected rather than
+ // leaked straight out the upstream interface.
+ appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route")
+ } else {
+ // v6 disabled: reject forwarded clients' native v6 so it can't leak past the
+ // proxy (the device's own v6 is blackholed separately in OUTPUT).
+ appendLine("ip6tables -A $v6fwd -j REJECT --reject-with icmp6-no-route")
+ }
+ }
+ }
+
+ // ---------------------------------------------------------------- teardown
+
+ private fun buildTeardown(context: Context): String {
+ val runDir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR)
+ val pidFile = File(runDir, "tun2socks.pid").absolutePath
+ val oomGuardPid = File(runDir, "oomguard.pid").absolutePath
+ val corePid = Process.myPid()
+ return buildString {
+ // mangle (TUN2SOCKS), both families
+ for (cmd in listOf("iptables", "ip6tables")) {
+ appendLine("$cmd -t mangle -D OUTPUT -j $CHAIN 2>/dev/null || true")
+ appendLine("$cmd -t mangle -F $CHAIN 2>/dev/null || true")
+ appendLine("$cmd -t mangle -X $CHAIN 2>/dev/null || true")
+ }
+ // IPv6 blackhole chain (only set up when v6 is disabled; harmless if absent)
+ appendLine("ip6tables -t filter -D OUTPUT -j ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -t filter -F ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -t filter -X ${AppConfig.ROOT_V6_CHAIN} 2>/dev/null || true")
+ // routing rule + table
+ appendLine("ip rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
+ appendLine("ip -6 rule del fwmark $MARK table $TABLE priority $PRIORITY 2>/dev/null || true")
+ appendLine("ip route flush table $TABLE 2>/dev/null || true")
+ appendLine("ip -6 route flush table $TABLE 2>/dev/null || true")
+ // LAN / tethering sharing (always cleaned, harmless if it was never set up)
+ appendLine("iptables -D FORWARD -j ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("iptables -F ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("iptables -X ${AppConfig.ROOT_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("iptables -t mangle -D FORWARD -o $TUN -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1350 2>/dev/null || true")
+ appendLine("iptables -t nat -D PREROUTING -j ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
+ appendLine("iptables -t nat -F ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
+ appendLine("iptables -t nat -X ${AppConfig.ROOT_DNS_CHAIN} 2>/dev/null || true")
+ // IPv6 LAN-sharing chains (forward accept/reject + forwarded-client marking)
+ appendLine("ip6tables -D FORWARD -j ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -F ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -X ${AppConfig.ROOT_V6_FWD_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -t mangle -D PREROUTING -j ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -t mangle -F ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
+ appendLine("ip6tables -t mangle -X ${AppConfig.ROOT_V6_PRE_CHAIN} 2>/dev/null || true")
+ for (pref in listOf(5000, 5010, 5020, 5025, 5026, 5027, 5030, 5040, 5050, 6000)) {
+ appendLine("ip rule del pref $pref 2>/dev/null || true")
+ }
+ // tun device down + helper process
+ appendLine("ip link set dev $TUN down 2>/dev/null || true")
+ appendLine("[ -f '$pidFile' ] && kill \$(cat '$pidFile') 2>/dev/null || true")
+ appendLine("rm -f '$pidFile'")
+ // stop the OOM re-pin loop and restore the core process's LMK priority
+ appendLine("[ -f '$oomGuardPid' ] && kill \$(cat '$oomGuardPid') 2>/dev/null || true")
+ appendLine("rm -f '$oomGuardPid'")
+ appendLine("echo 0 > /proc/$corePid/oom_score_adj 2>/dev/null || true")
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt
new file mode 100644
index 000000000..c07719e59
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/root/RootShell.kt
@@ -0,0 +1,54 @@
+package com.v2ray.ang.root
+
+import android.content.Context
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.util.LogUtil
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+/**
+ * Minimal root command runner backed by the `su` binary.
+ *
+ * Scripts are written to the app's private root runtime dir and executed with
+ * `su -c sh ` so shell quoting stays simple. stderr is merged into stdout to
+ * avoid pipe-buffer deadlocks.
+ */
+object RootShell {
+
+ data class Result(val code: Int, val output: String) {
+ val success: Boolean get() = code == 0
+ }
+
+ /** Write [script] to `/root/` and run it as root. */
+ fun runScript(context: Context, name: String, script: String): Result {
+ val dir = File(context.filesDir, AppConfig.ROOT_RUNTIME_DIR).apply { mkdirs() }
+ val file = File(dir, name).apply {
+ writeText(script)
+ setExecutable(true, false)
+ }
+ return exec("sh ${file.absolutePath}")
+ }
+
+ fun exec(command: String, timeoutSeconds: Long = 30): Result {
+ return try {
+ val process = ProcessBuilder("su", "-c", command)
+ .redirectErrorStream(true)
+ .start()
+ val output = process.inputStream.bufferedReader().use { it.readText() }
+ val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
+ if (!finished) {
+ process.destroy()
+ LogUtil.e(AppConfig.TAG, "RootShell: timed out: $command")
+ return Result(-1, output)
+ }
+ val result = Result(process.exitValue(), output)
+ if (!result.success) {
+ LogUtil.w(AppConfig.TAG, "RootShell: '$command' exited ${result.code}: ${output.trim()}")
+ }
+ result
+ } catch (e: Exception) {
+ LogUtil.e(AppConfig.TAG, "RootShell: failed to run '$command'", e)
+ Result(-1, e.message ?: e.javaClass.simpleName)
+ }
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt
new file mode 100644
index 000000000..a00d55ef0
--- /dev/null
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreRootService.kt
@@ -0,0 +1,96 @@
+package com.v2ray.ang.service
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import com.v2ray.ang.AppConfig
+import com.v2ray.ang.contracts.ServiceControl
+import com.v2ray.ang.core.CoreServiceManager
+import com.v2ray.ang.root.RootProxyManager
+import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.util.LogUtil
+import com.v2ray.ang.util.MyContextWrapper
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import java.lang.ref.SoftReference
+
+/**
+ * Foreground service for the root (system-wide) run modes. Unlike [CoreVpnService] it
+ * does not use Android VpnService — traffic is routed by iptables instead
+ * (see [RootProxyManager]).
+ *
+ * The in-process core is started first (so its listener is up and the foreground
+ * notification is posted promptly), then the root routing rules are installed off the
+ * main thread. On teardown the rules are removed before the core stops.
+ */
+class CoreRootService : Service(), ServiceControl {
+
+ private var setupJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ LogUtil.i(AppConfig.TAG, "StartCore-Root: Service created")
+ CoreServiceManager.serviceControl = SoftReference(this)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ LogUtil.i(AppConfig.TAG, "StartCore-Root: command received")
+
+ // Start the in-process core first (this also posts the foreground notification),
+ // then install the root routing off the main thread.
+ if (!CoreServiceManager.startCoreLoop(null)) {
+ LogUtil.e(AppConfig.TAG, "StartCore-Root: core failed to start")
+ stopService()
+ return START_NOT_STICKY
+ }
+
+ setupJob = CoroutineScope(Dispatchers.IO).launch {
+ if (!RootProxyManager.start(this@CoreRootService)) {
+ LogUtil.e(AppConfig.TAG, "StartCore-Root: failed to start root mode, stopping")
+ stopService()
+ }
+ }
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Wait for any in-flight async setup to finish before tearing down. The rules are
+ // installed off the main thread and can take seconds (the setup script waits for the
+ // tun to appear); if a stop arrives during that window, teardown would run first and
+ // the setup would then re-install the rules + tun pointing at a now-dead core,
+ // blackholing all traffic until the next start/stop cycle clears it.
+ runBlocking { setupJob?.cancelAndJoin() }
+ // Remove routing rules BEFORE stopping the core so traffic is never redirected
+ // to a dead listener. Synchronous on purpose — leaving rules behind breaks the net.
+ RootProxyManager.stop(this)
+ CoreServiceManager.stopCoreLoop()
+ }
+
+ override fun getService(): Service = this
+
+ override fun startService() {
+ // do nothing
+ }
+
+ override fun stopService() {
+ stopSelf()
+ }
+
+ override fun vpnProtect(socket: Int): Boolean = true
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun attachBaseContext(newBase: Context?) {
+ val context = newBase?.let {
+ MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
+ }
+ super.attachBaseContext(context)
+ }
+}
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt
index 7d14ac222..d00cbdc6e 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreVpnService.kt
@@ -24,6 +24,7 @@ import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
+import com.v2ray.ang.root.RootLanSharing
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
@@ -134,6 +135,9 @@ class CoreVpnService : VpnService(), ServiceControl {
stopAllService()
return
}
+
+ // Start LAN sharing if enabled in settings
+ RootLanSharing.startClientSharing(this)
}
override fun stopService() {
@@ -362,6 +366,8 @@ class CoreVpnService : VpnService(), ServiceControl {
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
+ RootLanSharing.stopClientSharing(this)
+
CoreServiceManager.stopCoreLoop()
if (isForced) {
diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt
index 40dba1f74..bc13fce89 100644
--- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt
+++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt
@@ -2,6 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.view.View
+import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@@ -9,9 +10,12 @@ import androidx.preference.PreferenceFragmentCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
+import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MmkvPreferenceDataStore
+import com.v2ray.ang.root.RootManager
import com.v2ray.ang.util.Utils
+import kotlinx.coroutines.launch
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,6 +47,8 @@ class SettingsActivity : BaseActivity() {
private val fragmentMaxSplit by lazy { findPreference(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
private val mode by lazy { findPreference(AppConfig.PREF_MODE) }
+ private val enableRootMode by lazy { findPreference(AppConfig.PREF_ROOT_MODE_ENABLE) }
+ private val lanSharing by lazy { findPreference(AppConfig.PREF_ROOT_LAN_SHARING) }
private val hevTunLogLevel by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
@@ -97,6 +103,7 @@ class SettingsActivity : BaseActivity() {
updateMode(valueStr)
true
}
+
mode?.dialogLayoutResource = R.layout.preference_with_help_link
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
@@ -113,6 +120,33 @@ class SettingsActivity : BaseActivity() {
updateDynamicSocksPort(newValue as Boolean)
true
}
+
+ enableRootMode?.setOnPreferenceChangeListener { _, newValue ->
+ if (newValue == true && !RootManager.cachedRoot()) {
+ lifecycleScope.launch {
+ if (checkAndRequestRoot()) {
+ enableRootMode?.isChecked = true
+ }
+ }
+ false
+ } else {
+ true
+ }
+ }
+
+ lanSharing?.setOnPreferenceChangeListener { _, newValue ->
+ if (newValue == true && !RootManager.cachedRoot()) {
+ lifecycleScope.launch {
+ if (checkAndRequestRoot()) {
+ lanSharing?.isChecked = true
+ }
+ }
+ false
+ } else {
+ true
+ }
+ }
+
}
private fun initPreferenceSummaries() {
@@ -161,6 +195,15 @@ class SettingsActivity : BaseActivity() {
preferenceScreen?.let { traverse(it) }
}
+ private suspend fun checkAndRequestRoot(): Boolean {
+ val hasRoot = RootManager.refresh()
+ if (!isAdded) return false
+ if (!hasRoot) {
+ context?.toastError(R.string.toast_root_required)
+ }
+ return hasRoot
+ }
+
override fun onStart() {
super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
index a6c9c27ae..e49cd3fb5 100644
--- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
+++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml
@@ -284,6 +284,13 @@
选择启用后 TUN 将使用 hev-socks5-tunnel 否则使用 xray-core
HevTun 日志级别
HevTun 读写超时(秒) (tcp,udp 默认 300,60)
+ 模式设置
+ 启用 Root 模式 (仅限 Root 用户)
+ 通过 Root 权限启用底层透明代理。此模式将不再通过 Android 常规系统 VPN 建立连接,而是直接接管系统底层所有网络流量。开启后,原有的常规模式、应用分流及相关 VPN 设置将直接失效。
+ 此功能需要 Root 权限
+ 局域网 / 热点网络共享 (仅限 Root 用户)
+ 允许通过 Wi-Fi 热点和 USB 共享网络连接的设备走 VPN 代理流量
+
Logcat
复制
diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
index 4c70cef54..7768fd16c 100644
--- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
+++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml
@@ -283,6 +283,13 @@
選擇啟用後 TUN 將使用 hev-socks5-tunnel 否則使用 xray-core
HevTun 日誌級別
HevTun 讀寫逾時(秒) (tcp,udp 預設 300,60)
+ 模式設定
+ 啟用 Root 模式 (僅限 Root 使用者)
+ 透過 Root 權限啟用底層透明代理。此模式將不再透過 Android 常規系統 VPN 建立連線,而是直接接管系統底層所有網路流量。開啟後,原有的常规模式、應用分流及相關 VPN 設定將直接失效。
+ 此功能需要 Root 權限
+ 區域網路 / 熱點網路共享 (僅限 Root 使用者)
+ 允許透過 Wi-Fi 熱點和 USB 共享網路連線的裝置走 VPN 代理流量
+
Logcat
複製
diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml
index 4624ffb1d..419e5b6a7 100644
--- a/V2rayNG/app/src/main/res/values/strings.xml
+++ b/V2rayNG/app/src/main/res/values/strings.xml
@@ -291,6 +291,12 @@
When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.
Hev Tun Log Level
Hev Tun read/write timeout (seconds) (tcp,udp default 300,60)
+ Mode Settings
+ Root mode enabled (Root Users)
+ Enable low-level transparent proxying via root privileges. This mode bypasses the standard Android system VPN to directly take over all underlying network traffic. Once enabled, the original regular mode, per-app routing, and related VPN settings will become completely invalid.
+ Root access is required for this feature
+ LAN / tethering sharing (Root Users)
+ Route Wi-Fi hotspot and USB-tethered devices through the VPN
Logcat
Copy
diff --git a/V2rayNG/app/src/main/res/xml/pref_settings.xml b/V2rayNG/app/src/main/res/xml/pref_settings.xml
index 729c57550..78114e3c6 100644
--- a/V2rayNG/app/src/main/res/xml/pref_settings.xml
+++ b/V2rayNG/app/src/main/res/xml/pref_settings.xml
@@ -314,6 +314,10 @@
android:summary="@string/summary_pref_ip_api_url"
android:title="@string/title_pref_ip_api_url" />
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/compile-hevtun.sh b/compile-hevtun.sh
index 6aa01c650..a6793ea65 100644
--- a/compile-hevtun.sh
+++ b/compile-hevtun.sh
@@ -16,26 +16,88 @@ clear_tmp () {
}
trap 'echo -e "Aborted, error $? in command: $BASH_COMMAND"; trap ERR; clear_tmp; exit 1' ERR INT
-#build hev-socks5-tunnel
+ABIS="armeabi-v7a arm64-v8a x86 x86_64"
+
mkdir -p "$TMPDIR/jni"
pushd "$TMPDIR"
-echo 'include $(call all-subdir-makefiles)' > jni/Android.mk
-
ln -s "$__dir/hev-socks5-tunnel" jni/hev-socks5-tunnel
+# 1) JNI shared library (libhev-socks5-tunnel.so) — loaded in-process by
+# com.v2ray.ang.service.TProxyService for the VpnService hev tun mode.
+echo 'include $(call all-subdir-makefiles)' > jni/Android.mk
+
"$NDK_HOME/ndk-build" \
NDK_PROJECT_PATH=. \
APP_BUILD_SCRIPT=jni/Android.mk \
- "APP_ABI=armeabi-v7a arm64-v8a x86 x86_64" \
+ "APP_ABI=$ABIS" \
APP_PLATFORM=android-24 \
NDK_LIBS_OUT="$TMPDIR/libs" \
NDK_OUT="$TMPDIR/obj" \
"APP_CFLAGS=-O3 -DPKGNAME=com/v2ray/ang/service" \
"APP_LDFLAGS=-Wl,--build-id=none -Wl,--hash-style=gnu" \
+# 2) Standalone executable (libhevsockstun.so) — run as a separate root
+# process by com.v2ray.ang.core.root for the Root run mode. Same hev source,
+# no -DENABLE_LIBRARY so hev-main.c's main() is built, and BUILD_EXECUTABLE
+# instead of a shared library. It creates its own tun and reads a YAML config.
+cat > jni/exec.mk <<'EXECMK'
+TOP_PATH := $(call my-dir)/hev-socks5-tunnel
+
+ifeq ($(filter $(modules-get-list),yaml),)
+ include $(TOP_PATH)/third-part/yaml/Android.mk
+endif
+ifeq ($(filter $(modules-get-list),lwip),)
+ include $(TOP_PATH)/third-part/lwip/Android.mk
+endif
+ifeq ($(filter $(modules-get-list),hev-task-system),)
+ include $(TOP_PATH)/third-part/hev-task-system/Android.mk
+endif
+
+LOCAL_PATH := $(TOP_PATH)
+SRCDIR := $(LOCAL_PATH)/src
+
+include $(CLEAR_VARS)
+include $(LOCAL_PATH)/build.mk
+LOCAL_MODULE := hevsockstun
+LOCAL_SRC_FILES := $(patsubst $(SRCDIR)/%,src/%,$(SRCFILES))
+LOCAL_C_INCLUDES := \
+ $(LOCAL_PATH)/src \
+ $(LOCAL_PATH)/src/misc \
+ $(LOCAL_PATH)/src/core/include \
+ $(LOCAL_PATH)/third-part/yaml/include \
+ $(LOCAL_PATH)/third-part/lwip/src/include \
+ $(LOCAL_PATH)/third-part/lwip/src/ports/include \
+ $(LOCAL_PATH)/third-part/hev-task-system/include
+LOCAL_CFLAGS += -DFD_SET_DEFINED -DSOCKLEN_T_DEFINED
+LOCAL_CFLAGS += $(VERSION_CFLAGS)
+ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
+LOCAL_CFLAGS += -mfpu=neon
+endif
+LOCAL_STATIC_LIBRARIES := yaml lwip hev-task-system
+LOCAL_LDFLAGS += -Wl,-z,max-page-size=16384
+LOCAL_LDFLAGS += -Wl,-z,common-page-size=16384
+include $(BUILD_EXECUTABLE)
+EXECMK
+
+"$NDK_HOME/ndk-build" \
+ NDK_PROJECT_PATH=. \
+ APP_BUILD_SCRIPT=jni/exec.mk \
+ "APP_ABI=$ABIS" \
+ APP_PLATFORM=android-24 \
+ NDK_LIBS_OUT="$TMPDIR/libs-exec" \
+ NDK_OUT="$TMPDIR/obj-exec" \
+ "APP_CFLAGS=-O3" \
+ "APP_LDFLAGS=-Wl,--build-id=none -Wl,--hash-style=gnu" \
+
+# Stage both artifacts under libs//. The executable is renamed to
+# lib*.so so the APK installer extracts it into nativeLibraryDir as an
+# executable file (filename distinct from the JNI library above).
mkdir -p "$__dir/libs"
cp -r "$TMPDIR/libs/"* "$__dir/libs/"
+for abi in $ABIS; do
+ cp "$TMPDIR/libs-exec/$abi/hevsockstun" "$__dir/libs/$abi/libhevsockstun.so"
+done
popd
rm -rf $TMPDIR