Android に VPN が接続されているかを尋ねることはできません。VpnService には接続状態を返す getter がなく、ステータスバーの鍵アイコンはシステムが描画するもので、アプリが読めるものではありません。できるのは、接続スタックにもっと狭い問いを投げかけることです。このアプリの通信はどのネットワークを使っているのか、そのネットワークは VPN トランスポートを備えているのか。本稿では、誤検知を避けられる順序でチェックをたどります。端末でのシェルコマンドから、アプリ内で動かし続けるコールバックまでです。

アプリのコードに触れる前にシェルで端末を確認する

まず基準となる状態を取ります。adb shell dumpsys connectivity はシステムが把握しているすべてのネットワークを出力し、各ブロックにはトランスポートが列挙されるため、VPN ネットワークはその一覧に VPN を伴って現れます。何より先にデフォルトネットワークのブロックを読み、そのうえで同じトランスポートを持つ他のネットワークを探します。

二つの出力は別の問いに答えます。VPN ネットワークが存在するかどうかと、アプリがそれを使っているかどうかは同じではありません。仕事用プロファイルや第二のユーザーがある端末では、あなたのプロセスが直接インターネットと通信していながら、両方が同時に真になり得ます。ソケットの挙動を決めるのはアクティブネットワークの行です。

アプリが使っているネットワークを問い合わせる

コードでは機能テストは四行です。マニフェストで ACCESS_NETWORK_STATE を要求し、アクティブネットワークの機能を VPN トランスポートと比較します。

ConnectivityManager cm = (ConnectivityManager)
    context.getSystemService(Context.CONNECTIVITY_SERVICE);
Network active = cm.getActiveNetwork();
NetworkCapabilities caps = cm.getNetworkCapabilities(active);
boolean vpnInUse = caps != null
    && caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN);

答えは意図的に狭いものです。アプリのデフォルトネットワークが今 VPN かどうか。どの VPN か、それが自分のものか、トンネルが終端まで届いているかは何も語りません。true は「通信が何らかのトンネルを通って出ていく」と解釈し、自前の記録は別に保ちます。三つの意味を黙って同時に持つ真偽値は、ユーザーが VPN 経由で閲覧している最中に設定画面が VPN をオフと表示してしまう原因になります。

逆に、VPN ネットワークが存在するかどうかを知りたい場合は、cm.getAllNetworks() を反復して各機能セットを検査します。二つの答えは、他のアプリを対象にするが自分のアプリは対象にしないアプリ別 VPN が動いている端末で食い違います。その食い違いが、明らかにオンの VPN をアプリが見られないという不具合報告のほとんど源です。

ポーリングではなく変化を監視する

タイマーによるポーリングはこの問いには適した形ではありません。状態の変化はイベントとして届くからです。VPN トランスポートに一致するリクエストを登録し、一致するネットワークが現れたり消えたり変化したりしたときにフレームワークに知らせてもらいます。

NetworkRequest request = new NetworkRequest.Builder()
    .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
    .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
    .build();
cm.registerNetworkCallback(request, callback);

VPN ネットワークが NET_CAPABILITY_NOT_VPN を持つことは決してないため、明示的な削除は意図を文書化します。それがなくてもリクエストは一致しますが、次にコードを読む人はそれを自分で突き止めなければなりません。

登録は観測を必要とするスコープに属し、通常は画面かフォアグラウンドサービスです。onStop や onDestroy で解除し、すでに消えたアクティビティにコールバックを付けたままにしないでください。可視のコンポーネントを持たないアプリは常駐オブザーバの宿主としては不適切であり、忘れたコールバックは構成変更の後にクラッシュするものです。

三つの問いを切り離しておく

この領域の混乱のほとんどは、三つの異なるチェックを一つとして扱うことから生じます。それぞれ別の API を使い、一つが真でも他は偽であり得ます。

QuestionHow you answer itWhat a yes means
Did the user grant VPN consent to my app?VpnService.prepare() returns nullConsent exists; the tunnel may still be down
Is the device sending default traffic through a VPN?TRANSPORT_VPN on the active networkSomething is tunneling, not necessarily your app
Did this request arrive through the VPN?Source address seen by the serverProof for that connection, nothing wider

三行目は人々がクライアント側で答えようとするものですが、そこでは答えられません。VPN クライアントに専用のアドレスプールを与え、サーバー側で送信元アドレスを読みます。セッション途中でユーザーがトンネルを切り替えても残る唯一の証拠です。

誤検知を排除する検証順序

まだ出回っている古いサンプルコードは getNetworkInfo(ConnectivityManager.TYPE_VPN) を呼びます。この呼び出しは非推奨で、いくつかのリリースでは VPN が接続中でも null を返しました。コピーされた断片が動作する端末で壊れて見えるのはそのためです。機能チェックはその罠を避け、Android 5.0 以降で同じように振る舞います。今も呼び出しているコードベースを保守しているなら、フォールバックで包むのではなく呼び出し自体を置き換えてください。誤った答えを生んだのはそのフォールバックだからです。

一つの近道には警告が必要です。外部サービスが報告する公開アドレスと VPN の出口アドレスを比較することは、経路について何かを証明しますが、チェックのたびに第三者へのリクエストが増え、そのサービスが稼働し続けることに依存し、キャッシュされたり遮断されたりし得ます。手動の診断にとどめ、アプリ内の状態チェックには決して使わないでください。

この順序でチェックを実行し、ユーザーの報告と食い違う最初のもので止めます。シェルのトランスポート一覧、アクティブネットワークの機能、トンネルを切り替えながらのコールバックイベント、そしてサーバー側の送信元アドレスです。四つすべてが一致してもアプリが誤動作するなら、不具合は Android のネットワーク認識ではなく、あなた自身の状態機械にあります。

関連記事