アプリが VpnService.prepare を呼び、null を受け取り、トンネルを構築し、ディスクリプタも有効に見えるのに、サーバーには何も届きません。あるいは prepare が Intent を返し、ユーザーが承認しても establish が失敗します。どちらの結果も通常は同じ意味で、別のアプリがこのユーザーの VPN インターフェースをすでに所有しており、あなたの protect 呼び出しはトンネルが使えないソケットをマークしています。後の手順は前の手順を修復できないため、以下の確認を順に進め、最初に失敗したところで止めてください。

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 が所有権の証拠であり、ソケット作業の前に確認する価値があります。null またはすでに閉じたディスクリプタは、例外が出ていなくてもインターフェースが作られなかったことを意味します。builder のインターフェース名は、テスト端末で dumpsys を使って、システムが有効な VPN について報告する名前と比較してください。両者が異なるなら、あなたが構築したトンネルは通信を運んでいるトンネルではありません。

adb shell dumpsys connectivity

同じ不一致が、接続アイコンはあるのに通信が死んでいる状態を説明します。システムは別のアプリのトンネルを表示し、あなたのコードは所有していないインターフェースのディスクリプタを保持しています。

ステップ 3: connect の前に protect を呼ぶ

インターフェースが存在すれば、アプリが開くソケットは既定でトンネルに流されます。外側のトランスポートはその規則に従えないためカプセル化されたパケットがインターフェースに戻ってしまうので、VpnService protect はソケットをマークし、VPN を迂回して基盤のネットワークを使わせます。呼び出しは boolean を返し、それを無視することが、健全に見えて何も運ばないソケットに行き着く道です。

protect は connect や bind の前に呼び、決して後にしないでください。保護はソケットが接続またはバインドされる前に有効でなければならず、接続済みのソケットはすでに経路を選んでいるため後からの protect では確実に移せません。protect が false を返したら止まり、自分のアプリに属さないインターフェースでは失敗するので、所有権を再確認してください。false の返却はステップ 1 に戻る合図であり、同じソケットを再試行する合図ではありません。

DatagramSocket socket = new DatagramSocket();
if (!vpnService.protect(socket)) {
    return;
}
socket.connect(serverAddress, serverPort);

ステップ 4: ソケットが扱いにくいときはプロセスをバインドする

トランスポートによっては単一のソケットではなく、ライブラリが複数開くことも、ヘルパープロセス内のすべての接続に同じ出口を使わせたいこともあります。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 ではありません。そのプロセスの出口を選ぶだけで、トランスポートには有用ですが、二つのトンネルを同時に動かすには役立ちません。

コードの経路ではなく、ユーザーの端末を直す

二つのアプリが VPN スロットを共有できる API はないため、修正は端末の状態に属します。ユーザーに、別のアプリを切断するか常時接続を消去して prepare が引き継げるようにする道と、そのアプリのアプリ別設定を開いて自分のアプリをそのトンネルから除外する道を示してください。除外はほかのすべてに対してフィルターを動かしたままにし、あなたのアプリは通常の接続を使えるので、多くの場合それがユーザーが本当に望む結果になります。

ユーザーはどのアプリが VPN を所有しているかほとんど知らないため、両方の道をサポート文書に書いてください。設定の有効な VPN 名と、ログからの prepare、establish、protect の結果を送ってもらえば、どの手順で失敗したか一分で分かります。その後、実際の通信で、名前解決、TCP 接続、トンネル経由の要求一回まで確認し、接続アイコンだけでは不十分です。有効なディスクリプタと緑の表示が証明できるのはせいぜい所有権であり、トランスポートがルーティング規則を生き延びたことではありません。

四つの確認がすべて通り、それでも通信が死ぬなら、所有権はもうあなたの問題ではありません。ルーティングとサーバーへの到達性に進み、VPN アプリが入っていない端末から同じ資格情報を試してください。

関連記事