1. Введение: Зачем, если их сотни?

Каждый раз, нажимая кнопку «Connect» в очередном VPN-приложении, я ловил себя на мысли: а что там, под капотом? Как эта магия на самом деле работает на уровне Android? Вокруг сотни готовых решений, но простое пользовательское любопытство переросло в профессиональное: мне захотелось не просто использовать, а понять. Понять, какие подводные камни скрывает VpnService, как реализовать весь цикл от авторизации до поднятия защищённого туннеля, и можно ли сделать это, не утонув в устаревших технологиях.

Важный момент: Эта статья и сам проект — это мой личный "бортовой журнал". Я не претендую на создание самого безопасного или анонимного решения. Это скорее история о пути, граблях и открытиях, которая, надеюсь, будет полезна тем, кто тоже решит заглянуть под капот VPN-технологий на Android.

Так родилась идея этого проекта — создать свой, пусть и простой, VPN-клиент. Это не попытка сделать коммерческий продукт, а скорее исследовательское погружение, мой личный "челлендж" и способ разложить всё по полочкам.

Три ключевых экрана Android-приложения "Vectra VPN": экран авторизации, главный экран для подключения и системный запрос разрешения на VPN.
Три ключевых экрана Android-приложения "Vectra VPN": экран авторизации, главный экран для подключения и системный запрос разрешения на VPN.

Да, я прекрасно понимаю, что на сегодняшний день "голый" WireGuard имеет проблемы с доступностью. В некоторых регионах его трафик научились определять с помощью систем глубокого анализа пакетов (DPI) и успешно блокировать.

Однако, поскольку цель моего проекта — это исследование архитектуры и интеграции современного протокола в Android-приложение, а не создание инструмента для обхода продвинутых блокировок, техническая элегантность и простота WireGuard перевесили этот операционный недостаток.

Сразу встал вопрос о выборе протокола. Можно было взять проверенный временем, но громоздкий OpenVPN, но я искал что-то более современное. Выбор пал на WireGuard, и вот почему:

  • Простота и элегантность. В отличие от монстров вроде IPSec, у WireGuard предельно компактный код и минимум настроек. Часто для запуска туннеля достаточно одного конфигурационного файла. Это подкупало.

  • Производительность и современная криптография. Он известен своей скоростью и использует только проверенные, быстрые криптографические алгоритмы. Для учебного проекта, где хочется сосредоточиться на логике, а не на борьбе с протоколом, — идеальный кандидат.

  • Готовые инструменты. Для Android уже существуют готовые библиотеки и реализации, что обещало (как мне тогда казалось) простую интеграцию.

  • Личный интерес. Честно говоря, мне просто хотелось поработать с модной и перспективной технологией.

Сравнительная схема двух протоколов перед которыми у меня был выбор.
Сравнительная схема двух протоколов перед которыми у меня был выбор.

2. Архитектура: собираем пазл из MVVM, сервиса и корутин

Прежде чем написать первую строчку кода, я сел за чертёж. VPN-клиент — это не просто "одна кнопка и один экран". Здесь есть асинхронные сетевые запросы, долгоживущий фоновый процесс (VpnService), который должен пережить закрытие UI, и реактивный интерфейс, который на всё это реагирует. Собирать такое "на коленке" — прямой путь к головной боли.

С архитектурой я решил не изобретать велосипед и взял за основу классический MVVM (Model-View-ViewModel). Поначалу казалось, что это оверкилл. Но я жестоко ошибался. Когда на одном экране нужно одновременно показывать статус подключения (Connecting, Connected, Disconnected, Error), в фоне пинговать десяток серверов и обновлять их статусы, а еще следить за текущим IP — без ViewModel и LiveData я бы быстро утонул в лапше из коллбэков.

Вот общая схема, по которой построено приложение:

Схема, по которой построено приложение.
Схема, по которой построено приложение.

Давайте разберем каждый слой подробнее.

1. UI-слой (Activity)

Это то, что видит пользователь. MainActivity — наш командный центр. Его задача проста: показывать данные из ViewModel и передавать ей команды от пользователя. Никакой бизнес-логики.

Ключевой момент — подписка на LiveData из ViewModel. Это позволяет UI обновляться автоматически при любом изменении состояния.

// MainViewModel.kt

// LiveData для статуса и конфига
private val _connectionStatus = MutableLiveData<String>()
val connectionStatus: LiveData<String> = _connectionStatus

private val _config = MutableLiveData<String>() // Сюда придет конфиг от сервера
val config: LiveData<String> = _config

fun connectToServer() {
    // Не блокируем UI, запускаем всё в корутине
    viewModelScope.launch {
        _connectionStatus.postValue("Connecting...")
        val server = _selectedServer.value ?: return@launch
        val token = getToken() // Получаем токен из SharedPreferences

        try {
            val request = ConnectRequest(server_id = server.id)
            val response = ApiClient.apiService.connect("Bearer $token", request)

            if (response.isSuccessful && response.body()?.config != null) {
                // Успех! Передаем конфиг дальше и обновляем статус
                _config.postValue(response.body()!!.config)
                _connectionStatus.postValue("Connected")
            } else {
                _connectionStatus.postValue("Error: ${response.message()}")
            }
        } catch (e: Exception) {
            _connectionStatus.postValue("Error: ${e.message}")
        }
    }
}
    

2. ViewModel-слой

Это мозг приложения. MainViewModel хранит все состояния (список серверов, статус подключения, текущий IP) и содержит всю логику для их получения и обработки: запросы к API, запуск пинга и т.д.

LiveData здесь — наш главный инструмент для связи с UI.

// MainViewModel.kt

// LiveData для статуса и конфига
private val _connectionStatus = MutableLiveData<String>()
val connectionStatus: LiveData<String> = _connectionStatus

private val _config = MutableLiveData<String>() // Сюда придет конфиг от сервера
val config: LiveData<String> = _config

fun connectToServer() {
    // Не блокируем UI, запускаем всё в корутине
    viewModelScope.launch {
        _connectionStatus.postValue("Connecting...")
        val server = _selectedServer.value ?: return@launch
        val token = getToken() // Получаем токен из SharedPreferences

        try {
            val request = ConnectRequest(server_id = server.id)
            val response = ApiClient.apiService.connect("Bearer $token", request)

            if (response.isSuccessful && response.body()?.config != null) {
                // Успех! Передаем конфиг дальше и обновляем статус
                _config.postValue(response.body()!!.config)
                _connectionStatus.postValue("Connected")
            } else {
                _connectionStatus.postValue("Error: ${response.message()}")
            }
        } catch (e: Exception) {
            _connectionStatus.postValue("Error: ${e.message}")
        }
    }
}
    

Обратите внимание: ViewModel ничего не знает о VpnService. Она лишь готовит данные (config) и сообщает о статусе. Запуск самого сервиса — ответственность UI-слоя.

3. Сетевой слой (Retrofit + OkHttp)

Здесь всё стандартно для современного Android-приложения. Retrofit декларативно описывает API, а OkHttp выполняет "грязную" работу. Важный момент, который сэкономил мне часы отладки — HttpLoggingInterceptor.

Лайфхак: Когда работаешь с самописным бэкендом, первое, что нужно сделать — включить логирование тела запросов и ответов. Без этого отладка превращается в гадание на кофейной гуще.

ApiClient.kt
ApiClient.kt

Интерфейс ApiService — это просто контракт с нашим бэкендом, реализованный с помощью корутин (suspend функций).

// ApiService.kt
interface ApiService {
    @GET("servers")
    suspend fun getServers(@Header("Authorization") token: String): Response<List<Server>>

    @POST("connect")
    suspend fun connect(@Header("Authorization") token: String, @Body request: ConnectRequest): Response<ConnectResponse>
}
    

4. VPN Service

Это сердце нашего VPN-клиента. MyVpnService — это наследник android.net.VpnService, который работает в фоне и управляет непосредственно WireGuard-туннелем через GoBackend. Это самый сложный и ответственный компонент. Мы разберем его работу подробнее в следующей главе, а пока — общая структура:

// MyVpnService.kt
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    val config = intent?.getStringExtra(EXTRA_CONFIG) ?: return START_NOT_STICKY

    // ... Запускаем Foreground-уведомление, чтобы система нас не убила

    // Инициализируем WireGuard Backend и поднимаем туннель
    backend = GoBackend(this)
    tunnel = //... реализация интерфейса Tunnel
    val parsedConfig = Config.parse(config.byteInputStream())
    backend.setState(tunnel, State.UP, parsedConfig)

    return START_STICKY
}

override fun onDestroy() {
    super.onDestroy()
    // Важно! Корректно "опускаем" туннель при остановке сервиса
    if (::backend.isInitialized) {
        backend.setState(tunnel, State.DOWN, null)
    }
}

5. Модели данных и сценарий взаимодействия

Чтобы всё это заработало вместе, нам нужны простые data-классы для общения с API.

// Модель сервера
@Parcelize
data class Server(...) : Parcelable

// Запрос на подключение
data class ConnectRequest(val server_id: Int)

// Ответ с конфигом
data class ConnectResponse(val config: String)

А вот как всё это складывается в единый сценарий:

Блок-схема сценария подключения VPN, где каждый компонент передаёт "эстафетную палочку"
Блок-схема сценария подключения VPN, где каждый компонент передаёт "эстафетную палочку"

Такая архитектура позволила четко разделить зоны ответственности и сделать код предсказуемым и тестируемым. В следующей главе мы погрузимся в самое интересное — в магию и боль работы с VpnService и WireGuard.

3. Погружение в WireGuard: между элегантностью и суровой реальностью

Вот мы и добрались до ядра нашего приложения — VpnService и его интеграции с WireGuard. Я выбрал для этого wireguard-android — официальную библиотеку, которая предоставляет GoBackend. Это реализация протокола на Go, завёрнутая для использования в Android. На бумаге всё выглядело идеально: высокая производительность, не нужен root, простая интеграция.

Как я упоминал в самом начале, я ожидал, что интеграция будет простой... Что ж, реальность оказалась немного сложнее.

3.1. Первый шок: молчаливые крэши GoBackend

Первое, с чем я столкнулся — это "молчаливые" падения. GoBackend — это нативный код. И если ему что-то не нравится, он не будет утруждать себя красивыми Kotlin-исключениями. Он просто падает. Молча. Унося с собой ваш сервис.

Примерно так я себя чувствовал, когда сервис падал без единой ошибки в логах.
Примерно так я себя чувствовал, когда сервис падал без единой ошибки в логах.

После нескольких часов отладки я наткнулся на свои первые грабли: я передавал в бэкенд конфиг, который прилетал с сервера "как есть". Любой неверный символ, лишний пробел или опечатка в ключе (например, Adress вместо Address) — и всё, приложение отправляется в небытие.

Вывод №1 (и самый главный): Любое взаимодействие с GoBackend, особенно вызов backend.setState(), обязательно нужно оборачивать в жирный try-catch(e: Exception). Это ваш единственный спасательный круг.

3.2. Конфиг: эстафетная палочка от сервера к сервису

Сам по себе конфиг WireGuard (.conf) — вещь простая. Он генерируется на нашем бэкенде для каждой сессии и содержит приватный ключ клиента, публичный ключ сервера, IP-адреса и т.д.

Процесс передачи выглядит как эстафета:

  1. ViewModel запрашивает конфиг у API.

  2. API возвращает его в виде обычной строки.

  3. ViewModel через LiveData передает эту строку в Activity.

  4. Activity, поймав конфиг, запускает наш MyVpnService, передавая строку через Intent.putExtra.

// MainViewModel.kt - запрашивает и получает конфиг
fun connectToServer() {
    // ...
    if (response.isSuccessful && response.body()?.config != null) {
        // Конфиг получен, передаем его UI
        _config.postValue(response.body()!!.config)
    }
    // ...
}

// MainActivity.kt - ловит конфиг и стартует сервис
private fun startVpnService(config: String) {
    val intent = Intent(this, MyVpnService::class.java).apply {
        putExtra(MyVpnService.EXTRA_CONFIG, config)
    }
    startService(intent)
}

3.3. Запуск туннеля и борьба с "гонкой состояний"

Основная логика живёт в MyVpnService. При запуске он получает конфиг из Intent, инициализирует GoBackend и поднимает туннель командой backend.setState(tunnel, State.UP, parsedConfig).

Здесь меня ждали вторые грабли: гонка состояний (race conditions). Что, если пользователь очень быстро нажмет "Connect", потом "Disconnect", а потом снова "Connect"? Мои первые реализации приводили к тому, что могли запуститься несколько конкурирующих корутин, пытающихся одновременно поднять или опустить туннель.

Решение: корутины и атомарные операции.
Я вынес всю логику запуска в отдельную корутину (serviceScope.launch) и использовал AtomicBoolean для контроля состояния.

Защита от двойного запуска и молчаливых крэшей
Защита от двойного запуска и молчаливых крэшей

3.4. Foreground-сервис: как не дать Android "убить" ваш VPN

Это не просто рекомендация, а жёсткое требование системы. Любой VpnService должен работать в режиме Foreground. Иначе при первой же нехватке памяти Android убьёт ваш сервис в фоне, и соединение оборвётся самым бесцеремонным образом.

Для этого при запуске сервиса нужно создать постоянное уведомление и вызвать startForeground().

// В MyVpnService.kt

private fun startForegroundWithNotification() {
    val notification = buildNotification() // Метод, который создает Notification
    
    // На Android 14+ нужно указывать тип сервиса
    if (Build.VERSION.SDK_INT >= 34) {
        startForeground(
            NOTIFICATION_ID,
            notification,
            // Указываем, что наш сервис работает с подключенными устройствами/сетями
            android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE 
        )
    } else {
        startForeground(NOTIFICATION_ID, notification)
    }
}

private fun buildNotification(): Notification {
    // Создаем канал для уведомлений на Android 8+
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        val channel = NotificationChannel(CHANNEL_ID, "VPN Status", NotificationManager.IMPORTANCE_LOW)
        val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        nm.createNotificationChannel(channel)
    }

    // Intent, чтобы по клику на уведомление открывалось приложение
    val pendingIntent = PendingIntent.getActivity(
        this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE
    )

    return NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("MyVPN Active")
        .setContentText("Your connection is secure")
        .setSmallIcon(R.drawable.ic_vpn_notification) // Важно: иконка должна быть монохромной!
        .setContentIntent(pendingIntent)
        .setOngoing(true) // Делаем уведомление "неубираемым"
        .build()
}

Лайфхак: Иконка для уведомления (setSmallIcon) должна быть простой и монохромной (белый силуэт на прозрачном фоне). Если вы используете цветную иконку, на современных версиях Android она превратится в уродливый белый квадрат. Я потратил на это полчаса, не повторяйте моих ошибок.

Итог: Интеграция WireGuard через GoBackend — это мощный инструмент. Но он требует внимания к деталям: аккуратной обработки ошибок, правильного управления жизненным циклом сервиса и обязательного использования Foreground режима. Преодолев эти начальные трудности, вы получаете быстрый и надежный VPN-туннель без необходимости в root-правах.

4. Сетевое взаимодействие: строим мост к серверу с помощью Retrofit и API

Ни один VPN-клиент не может существовать в вакууме. Ему нужен бэкенд для авторизации, получения списка серверов и, самое главное, — для генерации уникальных WireGuard-конфигов. Для этого я спроектировал простой REST API.

4.1. Контракт с сервером: структура API

Чтобы приложение работало, нам нужно всего четыре эндпоинта, которые покрывают весь пользовательский путь:

  • POST /register — регистрация нового пользователя.

  • POST /token — авторизация (логин) и получение JWT-токена.

  • GET /servers — получение списка доступных VPN-серверов (требует токен).

  • POST /connect — главный эндпоинт, который генерирует и возвращает WireGuard-конфиг для выбранного сервера (требует токен).

Эта структура позволяет реализовать полный цикл: зарегистрировался -> залогинился -> увидел серверы -> подключился. Просто и понятно.

4.2. Инструменты: проверенный временем стек Retrofit + OkHttp

Для работы с API я взял стандартный для Android-мира и горячо любимый мной стек: Retrofit для декларативного описания эндпоинтов и OkHttp в качестве "двигателя" для HTTP-запросов. Корутины (suspend функции) позволяют работать с сетью без коллбэков и головной боли.

Вот как выглядит интерфейс ApiService, который является нашим "контрактом" на стороне клиента:

// ApiService.kt
interface ApiService {
    // Регистрация
    @POST("register")
    suspend fun register(@Body credentials: JsonObject): Response<Void>

    // Логин. Обратите внимание на @FormUrlEncoded
    @FormUrlEncoded
    @POST("token")
    suspend fun login(
        @Field("username") username: String,
        @Field("password") password: String
    ): Response<JsonObject> // Ответ с токеном

    // Получение серверов с токеном в заголовке
    @GET("servers")
    suspend fun getServers(@Header("Authorization") token: String): Response<List<Server>>

    // Получение конфига для конкретного сервера
    @POST("connect")
    suspend fun connect(
        @Header("Authorization") token: String, 
        @Body request: ConnectRequest
    ): Response<ConnectResponse>
}

А вот настройка самого клиента. Здесь есть один критически важный элемент, который я добавляю во все свои проекты в первый же день.

Сэкономит вам часы отладки. Включайте сразу!
Сэкономит вам часы отладки. Включайте сразу!

Почему я делаю такой акцент на логгере? Потому что когда ваш бэкенд возвращает ошибку 400 (Bad Request), без логов вы будете часами гадать, что не так: не тот формат JSON? Забыли поле? Неверный заголовок? HttpLoggingInterceptor сразу покажет вам в Logcat, что именно вы отправили и что именно получили в ответ.

4.3. Вызовы API из ViewModel

Вся логика вызовов, как мы уже обсуждали в главе про архитектуру, живет в ViewModel и выполняется внутри viewModelScope. Это гарантирует, что запросы не блокируют UI и автоматически отменяются, если ViewModel уничтожается.

Вот как выглядит типичный вызов для получения списка серверов:

      // MainViewModel.kt
fun loadServers() {
    viewModelScope.launch {
        val token = getToken() // Получаем сохраненный токен
        if (token == null) {
            // Обработка случая, когда пользователь не авторизован
            _error.postValue("Auth token not found!")
            return@launch
        }

        try {
            val response = ApiClient.apiService.getServers("Bearer $token")
            if (response.isSuccessful) {
                // Успех! Обновляем LiveData, UI перерисуется сам
                _servers.postValue(response.body() ?: emptyList())
            } else {
                // Обрабатываем ошибки сервера, например, 401 Unauthorized
                _error.postValue("Error: ${response.code()} ${response.message()}")
            }
        } catch (e: Exception) {
            // Обрабатываем ошибки сети (нет интернета и т.д.)
            _error.postValue("Network error: ${e.message}")
        }
    }
}

4.4. Простые, но важные вопросы безопасности

  • HTTPS: Мой пример использует http:// для простоты отладки с локальным сервером. В любом приложении, которое выходит за рамки "Hello World", использование HTTPS является обязательным. Без него все данные, включая пароли и токены, передаются в открытом виде.

  • Хранение токена: После успешного логина я сохраняю access_token в SharedPreferences. Это самый простой способ.

    // Вспомогательная функция для сохранения токена
    private fun saveToken(token: String) {
        val sharedPref = getApplication<Application>().getSharedPreferences(
            "vpn_prefs", Context.MODE_PRIVATE)
        with(sharedPref.edit()) {
            putString("auth_token", token)
            apply() // apply() асинхронен, commit() синхронен
        }
    }

    Для настоящего продакшена стоило бы посмотреть в сторону EncryptedSharedPreferences из библиотеки Jetpack Security, чтобы защитить токен от извлечения на рутованных устройствах. Но для нашего исследовательского проекта этого достаточно.

Собранный воедино, этот сетевой слой получился надежным, легко расширяемым и, что самое главное, — простым в отладке благодаря правильным инструментам.

5. Интерфейс и UX: делаем сложное простым и понятным

Какой бы мощной ни была начинка, без удобного и понятного интерфейса приложением никто не будет пользоваться. Моей целью было создать простой, но функциональный UI, который бы четко отражал состояние VPN и не перегружал пользователя лишней информацией. Основой для этого стали компоненты Material Design, архитектура MVVM и реактивность LiveData.

5.1. Основные экраны: от входа до подключения

Весь путь пользователя укладывается в три основных экрана:

  • Экран авторизации (LoginActivity)
    Это входные ворота в приложение. Здесь все стандартно: поля для email и пароля, валидация на пустоту и минимальную длину, а также кнопки "Войти" и "Зарегистрироваться". Важный элемент UX — обратная связь. Когда пользователь нажимает на кнопку, я сразу показываю ProgressBar и блокирую кнопку, чтобы он понимал, что процесс пошел, и не мог отправить запрос повторно.

    Короткая анимация экрана логина: ввод данных -> нажатие "Войти" -> появляется ProgressBar -> переход на главный экран.
    Короткая анимация экрана логина: ввод данных -> нажатие "Войти" -> появляется ProgressBar -> переход на главный экран.

    Главный экран (MainActivity)
    Это наш командный центр. Здесь сосредоточена вся важная информация:

    1. Текущий публичный IP-адрес.

    2. Выбранный сервер (страна и пинг).

    3. Большая, заметная кнопка подключения/отключения.

    4. Четкий статус соединения.

    Главная "фишка" этого экрана — его реактивность. Благодаря LiveData, все элементы обновляются автоматически. Например, когда ViewModel сообщает о статусе "Connected", текст на кнопке мгновенно меняется на "Disconnect", а цвет фона кнопки — с зеленого на красный. Это дает пользователю мгновенную и понятную обратную связь.

  • Экран выбора сервера (ServerListActivity)
    Здесь я хотел дать пользователю возможность осознанного выбора. Поэтому для каждого сервера в списке, помимо названия и страны, отображается его пинг (задержка). Этот пинг измеряется асинхронно, и как только значение готово, оно тут же появляется в списке. Для отображения флагов я использовал простую утилиту, которая по ISO-коду страны генерирует URL для загрузки картинки через Glide.

    // Пример загрузки флага
    fun loadFlag(imageView: ImageView, countryCode: String) {
        // Утилита FlagUtils формирует URL к API флагов, например, "https://flagcdn.com/w40/de.png"
        val url = FlagUtils.getFlagUrl(countryCode)
        Glide.with(imageView.context)
            .load(url)
            .placeholder(R.drawable.ic_placeholder_flag) // Важно иметь плейсхолдер
            .into(imageView)
    }

5.2. Самый важный диалог: разрешение на VPN

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

Этот диалог — ключ ко всей функциональности. Без согласия пользователя ничего не произойдет
Этот диалог — ключ ко всей функциональности. Без согласия пользователя ничего не произойдет

1. Когда пользователь нажимает "Connect" в первый раз, я вызываю VpnService.prepare(this).

2. Если разрешение еще не дано, этот метод возвращает Intent для запуска системного диалога.

3. Я запускаю этот Intent через мой vpnPermissionLauncher.

4. Если пользователь дает разрешение, я получаю RESULT_OK и только тогда запускаю VpnService. Если отказывает — показываю вежливое сообщение, что без этого разрешения ничего не заработает.

Моя задача — обработать этот запрос корректно. Я использую современный подход с ActivityResultLauncher.

// В MainActivity

private val vpnPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result ->
    if (result.resultCode == RESULT_OK) {
        // Пользователь дал разрешение, теперь можно запускать сервис
        val config = viewModel.config.value
        if (config != null) {
            startVpnService(config)
        }
    } else {
        // Пользователь отказал. Показываем сообщение.
        Toast.makeText(this, "VPN permission is required to connect", Toast.LENGTH_LONG).show()
    }
}

private fun prepareAndStartVpn() {
    val intent = VpnService.prepare(this)
    if (intent != null) {
        // Разрешения нет, запрашиваем
        vpnPermissionLauncher.launch(intent)
    } else {
        // Разрешение уже есть, просто запускаем
        val config = viewModel.config.value
        if (config != null) {
            startVpnService(config)
        }
    }
}
    

5.3. Реактивность — наше всё

Я не могу не подчеркнуть еще раз, насколько LiveData упрощает жизнь в таком приложении. Вся логика обновления UI сводится к подписке на изменения в ViewModel. Это делает код в Activity предельно чистым и декларативным.

// В MainActivity.onCreate()

private fun setupObservers() {
    // Наблюдаем за статусом подключения
    viewModel.connectionStatus.observe(this) { status ->
        updateConnectionButton(status)
        binding.statusText.text = status
    }

    // Наблюдаем за выбранным сервером
    viewModel.selectedServer.observe(this) { server ->
        updateServerInfo(server)
    }

    // Наблюдаем за ошибками
    viewModel.error.observe(this) { error ->
        Toast.makeText(this, error, Toast.LENGTH_SHORT).show()
    }
}

Такой подход не только упрощает код, но и делает UX плавным и отзывчивым. Пользователь видит изменения на экране ровно в тот момент, когда они происходят в бизнес-логике.

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

6. Серверы и пинг: помогаем пользователю выбрать лучшее

Просто дать пользователю список из 20 серверов с названиями "DE-1", "US-5", "JP-3" — плохой UX. Как ему понять, какой из них быстрее? Какой обеспечит лучшее соединение для игр или стриминга? Я решил, что приложение должно помогать с этим выбором, предоставляя ключевой показатель — задержку (пинг).

6.1. Получение и модель сервера

Все начинается с простого API-запроса GET /servers после авторизации. Сервер возвращает список доступных локаций. Важно было сразу спроектировать правильную модель данных, которая бы содержала всю необходимую для UI информацию и поле для будущих вычислений.

@Parcelize
data class Server(
    val id: Int, // Для запроса на подключение
    val name: String, // "Germany #1"
    val country_code: String, // "de", для флага
    val ip: String, // IP для пинга и отладки
    var latency: Int? = null // Сюда мы запишем измеренный пинг
) : Parcelable

Поле latency с типом Int? и начальным значением null — это ключевой элемент. Оно позволяет нам отображать серверы сразу после загрузки, а пинг показывать по мере его вычисления.

6.2. Третьи грабли: параллельный пинг и обновление UI

Идея была простой: получить список серверов и для каждого измерить пинг. Но как это сделать эффективно? Запускать проверку последовательно — значит заставить пользователя ждать вечность. Очевидное решение — запускать проверки параллельно. И здесь меня ждали очередные грабли.

Проблема: Моя первая реализация была наивной. Я запускал корутину для каждого сервера. Когда серверов стало больше 20, это создавало всплеск сетевой активности и лишнюю нагрузку. Но главная проблема была в обновлении UI. Я обновлял весь список в RecyclerView после каждого успешного пинга. Это приводило к неприятному "морганию" списка.

Решение:

  1. Ограничение параллелизма: Я решил запускать не более 10 проверок одновременно. Этого достаточно для быстрого получения первых результатов без перегрузки сети.

  2. Точечное обновление: Вместо _servers.postValue(updatedList) после каждого пинга, который заставлял RecyclerView полностью перерисовываться, я перешел на более умное обновление. В идеале здесь использовать DiffUtil в адаптере, который сам найдет изменившиеся элементы и обновит только их. Но даже простое обновление всего списка после завершения всей пачки проверок уже лучше, чем дерганье после каждой.

Вот как выглядит улучшенная логика в ViewModel:

// ViewModel.kt

private fun measureAllLatencies(servers: List<Server>) {
    // Создаем копию списка, чтобы работать с ней
    val serversWithLatency = servers.map { it.copy() }

    viewModelScope.launch(Dispatchers.IO) {
        // Делим все серверы на "пачки" по 10 штук
        serversWithLatency.chunked(10).forEach { serverChunk ->
            // Создаем Deferred для каждой корутины в пачке
            val latencyJobs = serverChunk.map { server ->
                async {
                    // LatencyChecker - наша утилита для измерения пинга
                    val latency = LatencyChecker.ping(server.ip)
                    // Обновляем пинг в нашей копии объекта
                    server.latency = latency
                }
            }
            // Ждем завершения всех проверок в текущей пачке
            latencyJobs.awaitAll()

            // И только теперь обновляем LiveData один раз для всей пачки
            // Это значительно уменьшает количество перерисовок UI
            _servers.postValue(serversWithLatency)
        }
    }
}

6.3. Зачем всё это нужно?

Лишние сложности? Вовсе нет. Измерение пинга дает сразу несколько преимуществ:

  • Осознанный выбор: Пользователь видит, какой сервер физически "ближе" и быстрее.

  • Автоматическая рекомендация: Ничто не мешает нам после измерения пинга автоматически выбрать и подставить в главный экран сервер с наименьшей задержкой.

  • Качество соединения: Для игр, видеозвонков и стриминга низкий пинг — критически важный параметр. Мы даем пользователю инструмент для улучшения своего опыта.

6.4. Выбор сервера

Когда пользователь нажимает на сервер в списке, Activity просто уведомляет об этом ViewModel:

      // ServerListActivity, в адаптере RecyclerView
holder.itemView.setOnClickListener {
    val selectedServer = getItem(position)
    // Возвращаем результат в MainActivity
    val resultIntent = Intent().apply {
        putExtra("selected_server", selectedServer)
    }
    setResult(Activity.RESULT_OK, resultIntent)
    finish()
}

// MainViewModel
fun setSelectedServer(server: Server) {
    _selectedServer.value = server
}
    

В итоге, такой подход к работе с серверами превращает простой список в полезный инструмент, который помогает пользователю получить максимум от VPN-соединения.

7. Безопасность и обработка ошибок: готовимся к худшему

Разработка — это не только про то, как всё работает, когда всё хорошо, но и про то, как система ведет себя, когда всё идет не по плану. В приложении, работающем с авторизацией и сетевыми туннелями, таких "нештатных" ситуаций масса.

7.1. Хранение токена: компромисс между простотой и безопасностью

Главный "секрет" нашего приложения после авторизации — это JWT-токен. Его нужно где-то хранить между сессиями. Для этого учебного проекта я выбрал самый простой и стандартный механизм — SharedPreferences.

Почему?

  • Простота: Не требует подключения дополнительных библиотек.

  • Изоляция: Данные хранятся в "песочнице" приложения и недоступны другим программам на нерутованном устройстве.

// Утилиты для работы с токеном в SharedPreferences
private fun saveToken(context: Context, token: String) {
    val sharedPref = context.getSharedPreferences("vpn_prefs", Context.MODE_PRIVATE)
    sharedPref.edit {
        putString("auth_token", token)
        // используем edit { ... } из ktx для более лаконичного кода
    }
}

private fun getToken(context: Context): String? {
    val sharedPref = context.getSharedPreferences("vpn_prefs", Context.MODE_PRIVATE)
    return sharedPref.getString("auth_token", null)
}
    

Честный дисклеймер: Является ли это самым безопасным способом? Нет. На устройстве с root-доступом злоумышленник сможет прочитать файл с вашими SharedPreferences и украсть токен.

Что делать в реальном проекте? Использовать EncryptedSharedPreferences из библиотеки Jetpack Security. Это так же просто, но данные будут зашифрованы с использованием Android Keystore, что на порядок безопаснее. Для моего исследовательского проекта я осознанно пошел на компромисс в пользу простоты.

Уровни защиты токена.
Уровни защиты токена.

7.2. Обработка ошибок: что, если...?

  • ...токен "протух" или невалиден?
    Если API возвращает нам ошибку 401 Unauthorized, это сигнал, что с токеном что-то не так. В этом случае приложение должно:

    1. Очистить невалидный токен из SharedPreferences.

    2. Показать пользователю сообщение (например, "Сессия истекла, пожалуйста, войдите снова").

    3. Автоматически перенаправить его на экран логина.

  • ...нет сети?
    Все сетевые запросы в ViewModel обернуты в try-catch (e: Exception). Это позволяет ловить UnknownHostException и другие сетевые ошибки, чтобы показать пользователю понятное сообщение "Проверьте интернет-соединение", а не просто уронить приложение.

  • ...VPN-сервис упал?
    Как мы уже обсуждали, GoBackend может упасть из-за неверного конфига. Мой try-catch в VpnService ловит это падение, вызывает stopSelf() и предотвращает бесконечные попытки перезапуска.

7.3. Принципы работы с чувствительными данными

Помимо хранения токена, я следовал нескольким простым, но важным правилам:

  • Пароли не храним: Пароль пользователя используется только в момент логина/регистрации и нигде на устройстве не сохраняется.

  • Токен — в заголовке: Токен авторизации передается только в заголовке Authorization: Bearer ..., а не в теле запроса или URL. Это стандартная практика.

  • HTTPS — по умолчанию: Я уже говорил об этом, но повторюсь: в проде — только HTTPS.

  • Минимум прав: Приложение запрашивает только то, что ему жизненно необходимо: INTERNET и разрешение на VPN.

  • Стабильность через Foreground: VpnService работает как Foreground Service, что защищает его от "убийства" системой и обеспечивает стабильность соединения, что тоже является частью безопасности (предотвращение внезапных обрывов).

Соблюдение этих базовых правил гигиены позволяет создать достаточно надежное приложение, даже если оно не претендует на звание "банка в кармане". Главное — понимать риски и компромиссы на каждом шагу.

8. Шпаргалка выжившего: главные грабли и лайфхаки

В процессе этого путешествия я наступил на немало граблей и нашел несколько полезных приемов, которые сэкономили мне кучу времени. Я решил собрать их все в одном месте — возможно, эта шпаргалка убережет кого-то от моих ошибок.

Грабли №1: Молчаливый GoBackend

  • Проблема: Нативный GoBackend WireGuard не бросает привычных Kotlin-исключений. Если ему что-то не нравится (особенно в конфиге), он просто падает и уносит с собой весь VpnService, не оставляя следов в Logcat.

  • Решение: Всегда оборачивайте вызов backend.setState(...) в try-catch (e: Exception). Это единственный способ поймать ошибку, залогировать ее и безопасно остановить сервис, а не уронить приложение.

Грабли №2: Гонка состояний (Race Conditions)

  • Проблема: Пользователь может очень быстро нажимать кнопки "Connect/Disconnect", запуская несколько конкурирующих корутин, которые пытаются одновременно управлять туннелем.

  • Решение: Используйте AtomicBoolean для контроля состояния "занят/свободен". Перед запуском любой длительной операции (поднятие/опускание туннеля) атомарно проверяйте и устанавливайте флаг с помощью compareAndSet(false, true).

Грабли №3: Моргающий RecyclerView

  • Проблема: При параллельном пинге серверов наивное обновление всего списка в RecyclerView после получения каждого нового значения пинга вызывает раздражающее моргание.

  • Решение: Не обновляйте LiveData в цикле. Собирайте результаты (например, в пачках по 10 штук с помощью chunked), и обновляйте LiveData один раз после завершения всей пачки. В идеале — используйте DiffUtil, чтобы адаптер обновлял только изменившиеся ячейки.

Грабли №4: Белый квадрат вместо иконки уведомления

  • Проблема: Вы ставите красивую цветную иконку в уведомление Foreground-сервиса, а на современных версиях Android видите уродливый белый квадрат или круг.

  • Решение: Иконка для NotificationCompat.Builder.setSmallIcon() должна быть монохромной (белый цвет на прозрачном фоне). Это требование Material Design, о котором легко забыть и потратить время на отладку.

Ключевые лайфхаки

  • Логируйте сеть, как будто от этого зависит ваша жизнь (потому что так и есть). HttpLoggingInterceptor из OkHttp с уровнем BODY — ваш лучший друг. Включайте его с самого начала проекта. Это сэкономит вам 90% времени при отладке проблем с API.

  • Используйте ViewBinding. Забудьте про findViewById и kotlinx.android.synthetic. ViewBinding типобезопасен, null-безопасен и избавляет от целого класса потенциальных ошибок во время выполнения.

  • MVVM и LiveData — не оверкилл, а необходимость. Даже в простом VPN-приложении столько асинхронных состояний (статус подключения, пинг, IP-адрес), что без четкого разделения логики и UI вы быстро запутаетесь.

  • Тестируйте на реальном устройстве. Эмулятор — это хорошо, но специфику работы VpnService и сетевых туннелей лучше всего проверять на настоящем девайсе. Некоторые проблемы (особенно связанные с "засыпанием" устройства и работой в фоне) на эмуляторе могут просто не проявиться.

Этот проект научил меня главному: дьявол кроется в деталях. И именно умение предвидеть и обрабатывать эти детали отличает работающий прототип от надежного приложения.

9. Заключение: стоило ли оно того?

Путь от вопроса "а как это работает?" до реально работающего VPN-клиента на моем телефоне оказался и сложнее, и увлекательнее, чем я предполагал. Это было настоящее исследовательское погружение, полное технических вызовов и маленьких побед.

Так стоило ли оно того, когда вокруг сотни готовых приложений? Однозначно — да.

Я не просто реализовал набор фич. Я на своей шкуре прочувствовал, почему VpnService требует особого обращения. Я понял, что GoBackend — это мощный, но капризный инструмент, который не прощает ошибок. Я на практике убедился, что MVVM — это не просто модная аббревиатура, а спасательный круг в море асинхронных состояний.

Этот проект выполнил свою главную задачу: он утолил мое любопытство и превратил "черный ящик" VPN-технологий в понятную и управляемую систему.

Конечно, это лишь начало пути. В планах еще много идей: от поддержки OpenVPN до внедрения push-уведомлений.
Но, возможно, самый интересный вопрос, который остался за кадром: а как все это выглядит с другой стороны? Как устроен API, который раздает конфиги? Как настроены сами VPN-серверы, чтобы работать со всем этим?

Если эта статья вызовет интерес, то в следующей части я готов провести экскурсию по серверной части нашего проекта.

Надеюсь, мой "бортовой журнал" был для вас полезен и поможет кому-то избежать тех граблей, на которые я с таким удовольствием наступал.

А теперь самый интересный вопрос я хочу задать вам, читателям:
С какими неочевидными проблемами или забавными багами при работе с VpnService, сетевыми туннелями или нативным кодом на Android сталкивались вы? Делитесь своими историями в комментариях, давайте обсудим!

Комментарии (19)


  1. init0
    09.07.2025 08:39

    Вы написали GUI для VPN клиента


    1. cyberscoper Автор
      09.07.2025 08:39

      Спасибо за комментарий! Вы правы, над GUI действительно пришлось поработать, чтобы сделать его понятным и отзывчивым. Однако, как я и старался показать в статье, самые интересные "грабли" и неожиданные сложности скрывались глубже: в интеграции нативного GoBackend, в управлении жизненным циклом VpnService и в борьбе с гонкой состояний. Именно об этих невидимых для пользователя вещах и была эта история.


      1. init0
        09.07.2025 08:39

        Я это собственно написал потому, что обманулся заголовком т.к. подумал, что вы действительно написали впн клиент. А GUI, тем более для wireguard который конечно вне конкуренции по скорости и нагрузки на клиентское устройство (при условии нативной поддержки, а это андроид 12+ если не ошибаюсь), но в российских реалиях далеко не лучший выбор т.к. работает нестабильно ввиду того, что блокируется.


        1. cyberscoper Автор
          09.07.2025 08:39

          Вы правы, заголовок можно трактовать по-разному. В контексте разработки мобильных приложений "написать клиент" обычно означает создание всего приложения: от UI и архитектуры до интеграции с API и системными сервисами, вроде VpnService. Я не писал сам протокол с нуля — это была бы задача для целой команды криптографов :) Целью статьи было как раз показать всю эту "обвязку" и подводные камни, которые возникают при интеграции готового ядра. Возможно, стоило сделать заголовок более конкретным, спасибо за эту мысль.

          Насчет выбора WireGuard в российских реалиях — вы абсолютно правы, и это очень важный момент. Я даже специально добавил об этом абзац во вступление. Для создания рабочего инструмента обхода блокировок "голый" WireGuard сейчас подходит слабо. Но для учебной задачи — исследования архитектуры и простоты интеграции — он, на мой взгляд, подошел идеально.

          Еще раз спасибо за развернутый фидбек!


          1. MessirB
            09.07.2025 08:39

            Я практически уверен что эти два ответа были написаны нейросеткой.


  1. mysherocker
    09.07.2025 08:39

    Есть незанятая (насколько я знаю) ниша в качестве идеи для развития. Проблема современного Андроида в том, что он не поддерживает цепочки сетевых фильтров и, соответственно, нельзя добавить несколько обработчиков трафика.

    Сценарий такой: я пользуюсь application firewall (no-root firewall). Я хочу гибко контролировать активность приложений в системе. В том числе, не пускать всех подряд в интернет, а тех, кому надо ходить, постараться лишить отправки аналитики.

    Проблема начинается тогда, когда надо работать через туннель. Приходится выбирать -- либо firewall, либо туннель. А хотелось бы и то и другое -- локально фильтровать трафик, и далее его направлять через туннель.

    Посему было бы здорово сделать VPN клиент с функциональностью web application firewall.


  1. melodictsk
    09.07.2025 08:39

    Можно было просто взять nekobox или любой другой опенсурсный клиент с поддержкой всевозможных протоколов. Ваиргард нынче почти не работает, работает даже хуже опенвпн. Ссш режут по скорости, влесс часто рвёт соединение, но вполне живёт. Прокси типа сокс5 тоже через раз работает. Тем и хорош некобокс, что там сразу пачка протоколов и переключение в 1 клик с тестированием.


  1. the_d_kid
    09.07.2025 08:39

    Я шиз или от самого поста и особенно ответов в комментариях воняет нейронкой?


    1. cyberscoper Автор
      09.07.2025 08:39

      В каких именно главах и предложениях вы это усмотрели, поделитесь пожалуйста.

      p.s Что касаемо комментариев - я не вступаю в полемику если в этом нет нужды.


    1. Dandy_the_crocodile
      09.07.2025 08:39

      Шизеем вместе!

      Тоже возникло такое ощущение.

      В целом, текст может и авторский, но редактура GPT весьма заметна. Как и структура.


  1. Uporoty
    09.07.2025 08:39

    Выбор пал на WireGuard, и вот почему

    Мыши плакали, кололись, но продолжали использовать Wireguard, который уж пять лет как блокируется в России по одному щелчку пальцами


    1. cyberscoper Автор
      09.07.2025 08:39

      Ну ведь я в самом начале описал причину, он прост и я не обхожу блокировки.

      Да, я прекрасно понимаю, что на сегодняшний день "голый" WireGuard имеет проблемы с доступностью. В некоторых регионах его трафик научились определять с помощью систем глубокого анализа пакетов (DPI) и успешно блокировать.

      Эта статья и сам проект — это мой личный "бортовой журнал". Я не претендую на создание самого безопасного или анонимного решения. Это скорее история о пути, граблях и открытиях, которая, надеюсь, будет полезна тем, кто тоже решит заглянуть под капот VPN-технологий на Android.


  1. BareDreamer
    09.07.2025 08:39

    Я прочитал заголовок статьи, и возник вопрос: кто и зачем оплачивает сервера? Или используются бесплатные? Ответ не нашёл (статью не читал, поскольку интересно другое).
    Правильный VPN-клиент – это комбайн, работающий с разными протоколами. Примеры: NekoBox, Hiddify, V2Box, v2rayNG (сам ими почти не пользуюсь, поскольку VPN на Android мне не особо нужен)


    1. cyberscoper Автор
      09.07.2025 08:39

      Привожу строки из финала статьи,

      Конечно, это лишь начало пути. В планах еще много идей: от поддержки OpenVPN до внедрения push-уведомлений.

      Но, возможно, самый интересный вопрос, который остался за кадром: а как все это выглядит с другой стороны? Как устроен API, который раздает конфиги? Как настроены сами VPN-серверы, чтобы работать со всем этим?

      Будет ещё обзор того как устроена внутрянка самих серверов (образы докер и щепотка соли), но пока фидбек по статье сомнительный, не уверен что готов писать :)

      (Получив достаточно минусов в карму и пост, ну так себе.)


  1. vikulin
    09.07.2025 08:39

    Вы разрабатывали opensource решение? Для более доступного анализа лучше дать ссылку на репозиторий если такой имеется.
    Как Вы решили проблему когда VpnService эксклюзивно забирает на себя слот и никакой другой VPN уже не подключить? Нужно завершать такущий VpnService чтобы запустить его для другого приложения.


  1. vikulin
    09.07.2025 08:39

    По поводу проблем с нативным клиентом: падения - это баг библиотеки - не реализована валидация входящих данных. Скорее всего, код запускает panic без вызова recovery() и приложение завершается. Это, естественно, не освобождает разработчика от обработки исключений. Я бы рекомендовал вам оформить билет в их багтреккере.

    Хотел, также, посмотреть на саму библиотеку GoBackend, - ссылки не нашел.