另一个 VPN 已占用时 VpnService 保护会失败
Android 每个用户只允许一个 VPN 接口。请按顺序执行 prepare、establish、protect 与归属检查,再选择撤销或按应用排除。
你的应用调用 VpnService.prepare,拿到 null,建立隧道,描述符看起来有效,但数据却到不了服务器。或者 prepare 返回一个 Intent,用户批准了它,establish 仍然失败。这两种结果通常意味着同一件事:另一个应用已经占用了该用户的 VPN 接口,而你的 protect 调用标记的是隧道用不了的 socket。请按顺序完成下面的检查,并且在第一个失败的检查处停下,因为后面的步骤修不好前面的步骤。
在动 protect 之前先确认 VPN 槽位
Android 给每个用户 只有一个活动 VPN 接口。这是平台规则,不是你的应用能申请的权限,所以两个都想成为隧道的客户端无法共存。
为另一个应用设置的始终开启 VPN 是最难的一种情况,因为系统会让那个应用一直保持连接,并拒绝被接管。像 AdGuard 这样的流量过滤器、企业客户端或消费级 VPN,都可能占着槽位,而用户早已忘了它装着。请用户打开设置,读一下 VPN 下面列出的那一项;那里的名字就是正在和你竞争的占用者。在这一项被断开或始终开启设置被清除之前,你的 VpnService 无法成为接口。
第 1 步:读懂 prepare 返回什么
VpnService.prepare 只回答一个问题:这个应用能不能成为 VPN?已经获得同意时它返回 null,还需要用户批准时返回 Intent,你必须用 startActivityForResult 启动它。把返回 null 当作可以继续的许可,而不是 establish 一定成功的证据。
Intent intent = VpnService.prepare(context);
if (intent != null) {
startActivityForResult(intent, REQUEST_VPN);
}如果 Intent 这条路返回、而用户拒绝了,下次尝试时 prepare 仍会返回 Intent。请记录结果码,而不是在循环里反复重试,因为用户拒绝同意是人的决定,不是暂时性错误。
第 2 步:确认接口属于你的应用
establish 之后拿到的 ParcelFileDescriptor 就是你拥有它的凭证,值得在任何 socket 工作之前先核对。描述符为 null 或已经关闭,说明接口从未创建,即便没有任何异常冒出来。把 builder 里的接口名与系统为当前活动 VPN 报告的名字作比较,在测试设备上用 dumpsys 查看。如果两者不同,你建起来的隧道并不是承载流量的那条隧道。
adb shell dumpsys connectivity同样的不一致也解释了图标显示已连接、流量却是死的情况。系统显示的是另一个应用的隧道,而你的代码握着一个自己并不拥有的接口描述符。
第 3 步:在 connect 之前调用 protect
接口一旦存在,你的应用打开的 socket 默认就会进入隧道。外层传输不能遵守这条规则,否则封装后的报文会绕回接口,所以 VpnService protect 会标记某个 socket 绕过 VPN、改用底层网络。这个调用返回 boolean,忽略它就是让 socket 看似健康却什么都传不出去的原因。
在 connect 或 bind 之前调用 protect,绝不要放在之后。保护必须在 socket 连接或绑定之前就位,因为已连接的 socket 已经选好了路由,后来的 protect 并不能可靠地把它挪走。如果 protect 返回 false,请停下来重新核对归属:当接口不属于你的应用时它就会失败,而这正是另一个 VPN 留下的状态。返回 false 是回到第 1 步的信号,不是重试同一个 socket 的理由。
DatagramSocket socket = new DatagramSocket();
if (!vpnService.protect(socket)) {
return;
}
socket.connect(serverAddress, serverPort);第 4 步:socket 不好处理时绑定进程
有些传输并不是单个 socket:一个库可能开出好几个,或者你希望辅助进程里的每条连接都走同一个出口。ConnectivityManager.bindProcessToNetwork 能把进程绑定到选定的网络,更早的 setProcessDefaultNetwork 在被弃用前做的也是这件事。用 NET_CAPABILITY_NOT_VPN 请求底层连接,让绑定选中隧道下面的网络,而不是隧道本身。
NetworkRequest request = new NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build();绑定进程不是第二个 VPN。它是给那个进程选出口,对你的传输有用,对同时跑两条隧道没用。
修用户的设备,而不是改代码路径
没有任何 API 能让两个应用共享 VPN 槽位,所以修法落在设备状态上。给用户两条具体的路径:断开或清除另一个应用的始终开启,让你的 prepare 能接手;或者打开那个应用的按应用设置,把你的应用从它的隧道里排除。排除之后过滤器对其它所有流量继续工作,而你的应用走普通连接,这往往正是用户真正想要的结果。
把这两条路径写进你的支持文档,因为用户很少知道是哪个应用占着 VPN。请他们把设置里活动 VPN 的名字、以及日志里 prepare、establish 和 protect 的结果发过来,你一分钟内就能判断卡在哪一步。然后用真实流量验证:一次名字解析、一次 TCP 连接、一次穿过隧道的请求,而不只是看图标变绿。有效的描述符和绿色指示灯最多只能证明归属,永远不能证明传输熬过了路由规则。
如果四项检查全都通过、流量却仍然死掉,归属就不再是你的问题了。转去查路由和服务器可达性,并在没装任何 VPN 应用的设备上试同一套凭据。