Ваше приложение вызывает VpnService.prepare, получает null, строит туннель, и дескриптор выглядит допустимым, но на сервер ничего не приходит. Или prepare возвращает Intent, пользователь его одобряет, а establish всё равно не работает. Оба результата обычно означают одно: другое приложение уже владеет интерфейсом VPN для этого пользователя, а ваши вызовы protect помечают сокеты, которые ваш туннель не может использовать. Пройдите проверки ниже по порядку и остановитесь на первой неудачной, потому что более поздний шаг не может исправить более ранний.

Проверьте место VPN, прежде чем трогать protect

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 с тем, которое система сообщает для активного VPN, используя dumpsys на тестовом устройстве. Если они различаются, построенный вами туннель — не тот туннель, который несёт трафик.

adb shell dumpsys connectivity

То же несоответствие объясняет подключённый значок с мёртвым трафиком. Система показывает туннель другого приложения, а ваш код держит дескриптор интерфейса, которым не владеет.

Шаг 3: вызовите protect перед connect

Как только интерфейс существует, сокеты, которые открывает ваше приложение, по умолчанию направляются в туннель. Внешний транспорт не может следовать этому правилу, иначе инкапсулированный пакет вернулся бы в интерфейс, поэтому VpnService protect помечает сокет, чтобы обойти VPN и использовать нижележащую сеть. Вызов возвращает boolean, и его игнорирование — это путь к сокету, который выглядит здоровым и ничего не несёт.

Вызывайте protect до connect или bind, никогда после. Защита должна быть на месте до того, как сокет подключён или привязан, потому что подключённый сокет уже выбрал маршрут, и более поздний protect не перемещает его надёжно. Если protect возвращает false, остановитесь и перепроверьте владение: он не работает, когда интерфейс не принадлежит вашему приложению, а это именно то состояние, которое оставляет другой VPN. Возврат 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. Она выбирает выход для этого процесса, что полезно для вашего транспорта и бесполезно для одновременной работы двух туннелей.

Исправляйте устройство пользователя, а не путь кода

Нет API, который позволяет двум приложениям делить место VPN, поэтому исправление относится к состоянию устройства. Предложите пользователю два конкретных пути: отключите или удалите настройку всегда включено для другого приложения, чтобы ваш prepare мог перехватить, или откройте настройки этого приложения по приложениям и исключите своё приложение из его туннеля. Исключение сохраняет фильтр работающим для всего остального и позволяет вашему приложению использовать обычное соединение, что часто и есть результат, которого пользователь действительно хочет.

Запишите оба пути в документацию поддержки, потому что пользователи редко знают, какое приложение владеет VPN. Попросите их прислать имя активного VPN из Настроек и результаты prepare, establish и protect из ваших журналов, и вы сможете за минуту сказать, какой шаг не удался. Затем проверьте реальным трафиком: запрос имени, TCP-соединение и один запрос через туннель, а не только подключённый значок. Допустимый дескриптор и зелёный индикатор доказывают в лучшем случае владение, но никогда — что транспорт пережил правила маршрутизации.

Если все четыре проверки проходят, а трафик всё равно умирает, владение больше не ваша проблема. Переходите к маршрутизации и доступности сервера и проверьте те же учётные данные с устройства без установленного VPN-приложения.

Похожие статьи