Введение
В России последние годы VPN это не опция для гиков, а по факту обязательный компонент повседневного пользования интернетом: значительная часть сервисов периодически или постоянно недоступна без него. И вместе с этим в обиход плотно вошла ещё одна привычка, регулярно гадать, почему именно не открывается сайт или не работает приложение. Это плохое соединение? Лёг сам сайт? Или VPN в очередной раз попал под блокировку и просто не пропускает трафик, хотя формально числится подключённым?
Ответ на этот вопрос обычно ищут одним и тем же способом: смотрят на значок VPN в шторке Android. Горит - значит, дело не в VPN, ищи проблему в другом месте. Не горит - сразу понятно, откуда растут ноги.
Проблема в том, что этот значок сообщает не совсем то, что мы привыкли из него читать. Он говорит “существует VPN интерфейс”, а не “через этот интерфейс прямо сейчас реально идёт трафик”. Разница звучит как техническая придирка, пока не столкнёшься с ней на практике: VPN клиент завис, сервер на другом конце попал под блокировку или недоступен, соединение фактически мертво, а иконка как горела, так и горит, потому что с точки зрения системы интерфейс никуда не делся.
Это не гипотетическая ситуация, а типичный сценарий именно в условиях, где VPN-серверы регулярно блокируются: соединение может “подвиснуть” ровно в момент, когда провайдер блокировки нашёл и прижал конкретный IP или протокол, и клиент ещё не успел (или не смог) на это отреагировать разрывом туннеля. Пользователь в этот момент смотрит на значок, который продолжает гореть, и делает вывод, прямо противоположный реальности.
Дальше о том, как Android на самом деле определяет “есть VPN или нет”, почему этого недостаточно, какие есть способы проверить состояние точнее, и что из этого выросло в отдельный pet проект - виджет для Android, показывающий состояние соединения без необходимости открывать VPN приложение каждый раз, чтобы понять, в нём ли дело.
Как Android определяет наличие VPN
Базовый механизм, которым пользуется подавляющее большинство приложений, показывающих статус VPN (включая системную шторку), это ConnectivityManager и NetworkCapabilities:
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false val isVpnActive = caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
Логика простая: у активной сети есть транспорт TRANSPORT_VPN значит, трафик идёт через VPN интерфейс. Это официальный, задокументированный способ, и он абсолютно корректно отвечает на вопрос “существует ли сейчас VPN интерфейс в системе”.
Beда в том, что вопрос “существует ли интерфейс” и вопрос “работает ли VPN” это два разных вопроса, и TRANSPORT_VPN отвечает только на первый.
Почему этого недостаточно
Интерфейс - это виртуальное сетевое устройство. Система создаёт его в момент, когда VPN клиент устанавливает соединение, и убирает, когда клиент явно его закрывает. Всё, что происходит внутри этого интерфейса - доходят ли пакеты до сервера, отвечает ли сервер, не завис ли туннель, уже не входит в зону ответственности TRANSPORT_VPN. Флаг остаётся true даже если:
сервер VPN недоступен (в том числе попал под блокировку) и все запросы уходят в таймаут;
сам процесс VPN клиента завис, но не успел (или не смог) закрыть интерфейс;
соединение “просело” при переключении Wi-Fi - мобильная сеть, и туннель фактически неработоспособен, пока клиент не переустановит его заново.
Казалось бы, для этого в Android есть более подходящий флаг - NET_CAPABILITY_VALIDATED, который по документации должен означать “система подтвердила, что через эту сеть реально проходит интернет”. На бумаге это ровно то, что нужно. На практике на части устройств и особенно на эмуляторах этот флаг ведёт себя нестабильно, не обновляется вовремя или не выставляется вовсе, хотя соединение рабочее. Полагаться на него в продакшене оказалось рискованно: он либо давал ложные срабатывания, либо просто не менялся тогда, когда должен был.
Вывод, к которому неизбежно приходишь: единственный надёжный способ понять, работает ли VPN - это не спрашивать систему о состоянии интерфейса, а самому попробовать что-то через него передать и посмотреть, дойдёт ли ответ.
Возможные способы проверки VPN
Раз системных флагов недостаточно, единственный вариант - активная проверка. У неё тоже есть несколько подходов, и ни один не идеален сам по себе.
Проверка интерфейса (TRANSPORT_VPN). Самый дешёвый способ - не требует сети, не тратит трафик. Но, как показано выше, отвечает только на вопрос “существует ли интерфейс”, а не “работает ли он”.
Запрос к внешнему сервису поверх активного соединения. Если запросить, например, свой публичный IP через HTTP запрос, и получить осмысленный ответ - значит, трафик реально доходит и возвращается. Это самая честная проверка из всех: она тестирует именно то, что нужно пользователю “мой трафик сейчас реально куда-то идёт”. Минусы тоже понятны: зависимость от стороннего сервиса (если он сам недоступен можно ошибочно решить, что виноват VPN), дополнительный сетевой запрос, расход батареи при частой проверке.
TCP пинг вместо ICMP. Настоящий ICMP echo request (классический ping) на Android недоступен обычным приложениям без root - сырые сокеты закрыты для непривилегированных процессов. Стандартный обходной путь, которым пользуется большинство “ping” приложений в Play Store - замерять не ICMP эхо, а время установки TCP-соединения до заведомо доступного порта (например, 443 у публичного DNS сервера). Это не совсем то же самое, что классический пинг, но даёт сопоставимую по смыслу метрику задержки. У подхода есть свои ложные срабатывания в обе стороны: ложноположительное - порт открыт и отвечает быстро, но это никак не говорит о качестве конкретно VPN туннеля, если проверка идёт мимо него; ложноотрицательное - файрвол на пути может резать именно проверяемый порт, хотя сам VPN в полном порядке.
Комбинированный подход. На практике ни один сигнал в одиночку не даёт надёжного ответа, поэтому рабочая схема - комбинация: интерфейс (TRANSPORT_VPN) есть, и внешний запрос через него прошёл - состояние “OK”. Интерфейс есть, но запрос не прошёл - “DEAD” (соединение подвисло). Интерфейса нет вообще - “OFF”. Тройное состояние вместо привычного бинарного “подключено / не подключено” честнее отражает реальность, хоть и чуть сложнее для восприятия с первого взгляда.
От исследования к собственному решению
Отсюда и родилась идея pet проекта: не переизобретать VPN клиент, а сделать инструмент, который постоянно и без лишних действий показывает, в каком из трёх состояний сейчас находится соединение, не заходя каждый раз в VPN приложение, чтобы это проверить, и не гадая, в чём причина, когда что-то не грузится.
Обычная иконка VPN в шторке для этого не годится по причине, разобранной выше: она показывает факт существования интерфейса, а не факт его работоспособности. Нужен был отдельный, постоянно видимый индикатор, опирающийся именно на комбинированную проверку и желательно в нескольких местах одновременно: на рабочем столе (виджет), в шторке (постоянное уведомление) и поверх других приложений (плавающий оверлей), чтобы не нужно было выбирать, где именно смотреть статус.
Неожиданные сложности разработки
Ограничения Jetpack Glance
Виджет реализован на Jetpack Glance - современном Compose подобном API для виджетов рабочего стола. У Glance есть не самые очевидные ограничения: например, ограничение на число прямых дочерних элементов в Column. При попытке впихнуть весь список полей (статус VPN, IP, страна, тип сети, скорость, качество соединения, DNS и т.д.) в один плоский Column упираешься в этот лимит, а помимо лимита, просто в клиппинг: виджет на маленьком размере превращается в обрезанную кашу текста.
Решение состоит из двух частей. Во-первых, SizeMode.Responsive - Glance позволяет задать несколько опорных размеров виджета и рендерить для каждого свой набор контента, ориентируясь на LocalSize.current. Во-вторых - разбить сам виджет на переиспользуемые секции (Status / Network / Speed / Quality / Footer), которые собираются в разной комбинации в зависимости от того, к какой конфигурации (small / medium / large) ближе всего текущий размер виджета. Маленький виджет показывает только статус VPN, IP и скорость; большой разворачивает полный набор вплоть до DNS-серверов и информации о хосте.
Жёсткий потолок в 15 минут для фонового обновления
Это оказалось самым архитектурно значимым ограничением всего проекта. WorkManager - стандартный и рекомендуемый Google механизм для периодических фоновых задач на Android, не позволяет планировать периодическую работу чаще, чем раз в 15 минут. Это не настройка, которую можно подкрутить, а жёстко зашитый в платформу минимум.
Для приложения, весь смысл которого - вовремя заметить, что VPN отвалился (например, под блокировку попал конкретный сервер), 15 минут это очень грубое ограничение. Пользователь может провести четверть часа с полностью незащищённым или вовсе не работающим трафиком, даже не подозревая об этом, просто потому что виджет ещё не успел обновиться.
Рабочее решение оказалось не в том, чтобы обмануть WorkManager, а в том, чтобы для части сценариев вообще не зависеть от него. У Android есть принципиально другой класс процессов - foreground сервисы, которые система не убивает произвольно (в отличие от фонового не foreground сервиса или обычного таймера, которые могут быть прибиты в любой момент под предлогом экономии батареи). Если у пользователя включено постоянное уведомление в шторке, оно уже обязано быть foreground сервисом по требованиям Android к такого рода уведомлениям. А раз процесс и так вынужден жить постоянно, почему бы не разрешить именно ему крутить собственный цикл обновления с произвольным интервалом, не оглядываясь на ограничение WorkManager, оно существует для периодических фоновых задач, а не для активного foreground процесса.
Так появилось правило: интервалы обновления короче 15 минут разблокируются только тогда, когда включено постоянное уведомление, не потому что это искусственное ограничение фичи, а потому что именно foreground статус физически даёт то, что нужно для частого обновления, а без него подобная частота на Android попросту недостижима легальными способами. У этого foreground цикла при этом есть и собственный нижний защитный порог: 30 секунд, не позволяющий интервалу уйти в ноль, отдельная страховка от посадки батареи, независимая от 15-минутного лимита WorkManager.
Отдельным и, возможно, более важным механизмом внутри этого же foreground сервиса стал ConnectivityManager.NetworkCallback - подписка на смену сети. Мгновенная реакция на “VPN только что отвалился” доступна ровно в тех же условиях, что и ускоренное обновление, только пока постоянное уведомление включено, поскольку живёт этот сторож внутри того же сервиса. Наивная версия такого механизма быстро столкнулась бы с проблемой: не любое изменение сети достойно триггерить обновление. Сила сигнала Wi-Fi меняется постоянно, и реагировать на неё как на смену VPN статуса означало бы обновляться по несколько раз в секунду. Пришлось сузить фильтр до одного конкретного перехода - подтверждения NET_CAPABILITY_VALIDATED и добавить дебаунс в полторы секунды, потому что один сетевой хэндовер (скажем, переустановка VPN-туннеля после сброса блокировкой) реально способен выстрелить несколько callback событий подряд за доли секунды.
В сумме получилось три независимых триггера обновления: периодическая задача WorkManager (постоянный фон, минимум раз в 15 минут), ручное нажатие пользователя, и при включённом постоянном уведомлении foreground цикл с собственным интервалом и network watchdog поверх него. Все три сходятся в одной точке - координаторе с mutex, вместо того, чтобы каждый писал состояние по отдельности и рисковал гонкой.
Здесь же всплыло ещё одно ограничение платформы: из фонового контекста (тот самый WorkManager или цикл сервиса) Android 12+ запрещает поднимать foreground сервис заново, можно только останавливать. Поэтому координатор для постоянного уведомления в шторке вызывает не полный “перезапусти при необходимости”, а условный “останови, если выключено в настройках”, разрешить себе только половину операции оказалось не примером избыточной осторожности, а прямым следствием платформенного ограничения, которое до этого уже один раз приводило к падению приложения при холодном старте.
Самовосстановление фоновых сервисов
Оверлей, рисующий плавающую полоску поверх других приложений, сознательно сделан не foreground сервисом (в отличие от постоянного уведомления). Это значит, что система в любой момент может тихо его убить в фоне без единого предупреждения пользователю, просто в какой-то момент полоска пропадёт с экрана, и никакого события об этом никуда не прилетит.
Попытка сделать его неубиваемым, тупиковый путь: система целенаправленно проектирует эти ограничения именно для того, чтобы такие процессы можно было останавливать. Рабочий подход - не бороться с этим, а закладывать в архитектуру постоянную самопроверку: при каждом обновлении данных и при каждом возврате пользователя в приложение (onResume) состояние оверлея перепроверяется и, если нужно, поднимается заново (сам он, в отличие от постоянного уведомления, не foreground сервис, поэтому запрет на перезапуск из фона на него не распространяется). Не “сервис работает, потому что я его запустил один раз”, а “сервис работает, потому что кто-то регулярно проверяет и чинит его состояние”.
Различия между устройствами
Отдельная категория сложностей - расхождения в поведении между производителями. Один конкретный пример: модификатор cornerRadius() в Jetpack Glance, отвечающий за скруглённые углы виджета, корректно работает на большинстве устройств, но ломается на прошивках Huawei/EMUI. Обходной путь - не полагаться на программный модификатор Glance, а вернуться к старому доброму XML drawable ресурсу для скругления, который у системы рендеринга виджетов уже давно и стабильно поддерживается на всех прошивках.
Публикация в Google Play
Формальное требование Google для новых приложений на аккаунтах разработчика - закрытое тестирование: минимум 12 тестировщиков, активных 14 дней подряд, прежде чем открыть доступ к продакшен-релизу.
Для одиночного разработчика без готовой аудитории это на бумаге выглядит мелкой бюрократической формальностью, а на практике превращается в самое узкое место всего процесса публикации: попросту негде взять 12 живых людей, готовых две недели подряд держать твоё приложение установленным.
Практическое решение, которым пользуется сообщество инди-разработчиков - группы взаимного тестирования: ты ставишь чужие приложения, тебе - твои. Схема рабочая, но с предсказуемым изъяном. Чтобы гарантированно набрать нужные 12 тестировщиков (а с учётом того, что часть участников отвалится или не досидит все 14 дней, на практике надёжнее ориентироваться штук на 20), нужно и самому поставить и “протестировать” примерно столько же чужих приложений. При таком объёме 15-20 чужих приложений одновременно - ни времени, ни реальной мотивации разбираться в каждом из них уже не остаётся. Цикл выходит на автомат: открыл приложение, сделал скриншот, отправил его нужному разработчику в чат, закрыл. Формальное требование Google при этом полностью выполняется, а вот тестирования по содержанию, ради которого это требование вроде бы существует, не происходит ни с одной из сторон.
Порог в 5 тестировщиков вместо 12 сделал бы то же самое требование выполнимым без превращения его в конвейер по обмену скриншотами. Пятерых человек одиночный разработчик реально способен найти среди знакомых или в небольшом профильном сообществе, и в этом случае у каждого из них сохраняется и время, и мотивация действительно попробовать приложение, а не просто отчитаться о факте установки.
Что в итоге получилось
Как итог - у меня теперь есть удобный виджет-приложение для проверки статуса VPN соединения, которым пользуюсь я сам и мои знакомые.
Ссылка на Google Play: [ссылка]. Буду рад любым замечаниям, отзывам и советам.
Koluchy
Хоть бы пробный период сделали...а то сразу деньги подавай, ещё и в гуглоплее, который при этом ещё и деньги не хочет принимать... Хочу попробовать, но заморачиваться ради этого с оплатой в гуглоплее совсем вот никакого желания...