Ваш застосунок викликає 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-застосунку.

Схожі статті