Перейти к содержимому

Разработка5 мин чтения

Таймер отключения VPN на Android: сторож живёт в VpnService

Как VPN Russia ограничивает бесплатную сессию 15 минутами на Android: политика на чистом Kotlin с подменяемыми часами, окно сессии в DataStore, сторож на корутинах внутри VpnService и тесты на виртуальном времени.

Бесплатный режим VPN Russia — это 15 минут подключения, затем 45 минут паузы; подписка снимает ограничение. На iOS таймер живёт в расширении туннеля — мы рассказали об этом в статье про iOS. Android-клиент — перенос той же логики, и платформа задаёт тот же вопрос в другой форме: кто владеет таймером, когда экрана приложения нет?

Почему интерфейс — неправильное место

Проверка на кнопке «Подключить» нужна, но её недостаточно. На Android экран и туннель живут отдельными жизнями:

  • Пользователь закрывает приложение или смахивает его, а туннель продолжает работать в foreground-сервисе — считать время на экране уже некому.
  • Туннель не обязан стартовать с нашей кнопки: система может запустить VpnService сама, например при включённом в настройках постоянном VPN (always-on).
  • Таймеры во ViewModel умирают вместе с экраном, а экран — первое, что Android забирает.

Поэтому правило то же, что на iOS: компонент, который держит туннель, — наш VpnService — следит за сессией и сам снимает туннель. Интерфейс только спрашивает, можно ли подключиться, и показывает, сколько ждать.

Политика на чистом Kotlin

Вся арифметика времени живёт в модуле без зависимостей от Android. Текущее время приходит через Clock с одним методом, так что тесты двигают его как угодно.

SessionLimit.ktkotlin
fun interface Clock { fun nowMillis(): Long }

/** До какого момента можно оставаться на связи и когда можно подключиться снова. */
data class FreeSessionWindow(
    val allowedUntilMillis: Long,
    val nextConnectionAtMillis: Long,
)

class SessionLimitPolicy(
    val sessionDuration: Duration,
    private val pauseDuration: Duration,
    private val clock: Clock,
) {
    /** Подключение запрещено только между концом сессии и концом паузы. */
    fun canConnect(window: FreeSessionWindow?): Boolean {
        if (window == null) return true
        val now = clock.nowMillis()
        val sessionOver = window.allowedUntilMillis < now
        val pauseStillRunning = window.nextConnectionAtMillis > now
        return !(sessionOver && pauseStillRunning)
    }

    fun remainingPause(window: FreeSessionWindow?): Duration? {
        if (window == null || canConnect(window)) return null
        return (window.nextConnectionAtMillis - clock.nowMillis()).milliseconds
    }

    fun nowMillis(): Long = clock.nowMillis()

    fun startSession(): FreeSessionWindow {
        val allowedUntil = clock.nowMillis() + sessionDuration.inWholeMilliseconds
        return FreeSessionWindow(allowedUntil, allowedUntil + pauseDuration.inWholeMilliseconds)
    }
}

Как и на iOS, храним два момента, а не остаток. Даты переживают перезапуски и сами дают ключевое правило: переподключение внутри окна не дарит пользователю пятнадцать свежих минут.

Окно на диске

Если держать окно в памяти, лимит сбрасывался бы перезапуском приложения. Оно лежит в DataStore; ключи даже повторяют iOS-версию — так проще отлаживать обе платформы.

DataStoreSessionWindow.ktkotlin
interface SessionWindowStore {
    suspend fun current(): FreeSessionWindow?
    suspend fun save(window: FreeSessionWindow)
    suspend fun clear()
}

@Singleton
class DataStoreSessionWindow @Inject constructor(
    private val store: DataStore<Preferences>,
) : SessionWindowStore {

    override suspend fun current(): FreeSessionWindow? {
        val prefs = store.data.first()
        val allowedUntil = prefs[ALLOWED_UNTIL] ?: return null
        val nextConnection = prefs[NEXT_CONNECTION] ?: return null
        return FreeSessionWindow(allowedUntil, nextConnection)
    }

    override suspend fun save(window: FreeSessionWindow) {
        store.edit { prefs ->
            prefs[ALLOWED_UNTIL] = window.allowedUntilMillis
            prefs[NEXT_CONNECTION] = window.nextConnectionAtMillis
        }
    }

    override suspend fun clear() {
        store.edit { it.remove(ALLOWED_UNTIL); it.remove(NEXT_CONNECTION) }
    }

    private companion object {
        val ALLOWED_UNTIL = longPreferencesKey("allowedDate")
        val NEXT_CONNECTION = longPreferencesKey("nextReconnectDate")
    }
}

SessionGuard: политика, хранилище и подписка вместе

С остальным приложением разговаривает SessionGuard. Основные тонкости — в двух его методах.

SessionLimit.ktkotlin
class SessionGuard(
    private val policy: SessionLimitPolicy,
    private val windows: SessionWindowStore,
    private val isPremium: () -> Boolean,
) {
    suspend fun canConnect(): Boolean =
        isPremium() || policy.canConnect(windows.current())

    suspend fun remainingPause(): Duration? =
        if (isPremium()) null else policy.remainingPause(windows.current())

    suspend fun sessionExpired(): Boolean {
        if (isPremium()) return false
        val window = windows.current() ?: return false
        return window.allowedUntilMillis <= policy.nowMillis()
    }

    /** Подписчику окно не заводится: после отмены подписки он упёрся бы в чужой старый лимит. */
    suspend fun onConnected() {
        if (isPremium()) return
        val window = windows.current()
        // Переподключение внутри действующей сессии окно не продлевает
        if (window != null && window.allowedUntilMillis > policy.nowMillis()) return
        windows.save(policy.startSession())
    }
}

Посмотрите на проверку в onConnected. Хочется написать if (canConnect()) return, но canConnect верен и для окна, у которого пауза уже прошла. С таким вариантом старое окно осталось бы навсегда, отсчёт больше не начинался бы, и лимит перестал бы работать со второго подключения. Нужно ли новое окно, решает только конец сессии.

Сторож внутри VpnService

Сторож — маленький класс с одной suspend-функцией. Что делать по сроку, он получает лямбдой, поэтому ничего не знает о сервисе и легко тестируется.

SessionWatchdog.ktkotlin
class SessionWatchdog(
    private val guard: SessionGuard,
    private val interval: Duration = 5.seconds,
) {
    suspend fun watch(stopTunnel: suspend () -> Unit) {
        // Сначала запрет, потом окно. Наоборот нельзя: подключение в паузе
        // завело бы себе новое окно и тем самым сняло бы собственный запрет
        if (!guard.canConnect()) {
            stopTunnel()
            return
        }

        // Окно заводит сам туннель: приложение могло не участвовать в запуске
        guard.onConnected()

        while (currentCoroutineContext().isActive) {
            delay(interval)
            if (guard.sessionExpired()) {
                stopTunnel()
                return
            }
        }
    }
}

Почему опрос каждые пять секунд, а не один delay(remaining)? Задержки корутин идут по монотонным часам, которые стоят в глубоком сне. Заблокируйте телефон на час — и одна пятнадцатиминутная задержка закончится через пятнадцать минут бодрствования: сессия вырастет на всё время сна. Короткие шаги со сверкой по календарным часам догоняют срок за пять секунд после пробуждения.

Сервис запускает сторожа, когда туннель подключился, и отменяет, когда туннель останавливается:

RuVpnService.ktkotlin
@AndroidEntryPoint
class RuVpnService : VpnService() {

    @Inject lateinit var sessionGuard: SessionGuard

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private var sessionJob: Job? = null

    private val sessions = TunnelSessions(
        // ...
        onConnected = ::watchFreeSession,
        onStopping = ::stopWatchingFreeSession,
    )

    /** Сторож живёт в самом сервисе: закрытое приложение его не обходит. */
    private fun watchFreeSession() {
        sessionJob?.cancel()
        sessionJob = scope.launch {
            SessionWatchdog(sessionGuard).watch {
                sessions.stopCurrent(DisconnectReason.FREE_LIMIT)
            }
        }
    }

    private fun stopWatchingFreeSession() {
        sessionJob?.cancel()
        sessionJob = null
    }

    override fun onDestroy() {
        scope.cancel()
        super.onDestroy()
    }
}

DisconnectReason.FREE_LIMIT уходит в аналитику, поэтому отключение по лимиту не путается с нажатием «Отключить» или с отзывом VPN системой (onRevoke).

Сборка зависимостей и отладочные интервалы

Длительности задаёт модуль DI. В отладочной сборке — две минуты и одна минута: таймер на четверть часа ожиданием никто не проверяет.

AppModule.ktkotlin
val freeSessionDuration: Duration = if (BuildConfig.DEBUG) 120.seconds else 15.minutes
val freeSessionPause: Duration = if (BuildConfig.DEBUG) 1.minutes else 45.minutes

@Provides
@Singleton
fun provideSessionGuard(
    clock: Clock,
    windows: SessionWindowStore,
    purchases: PurchaseRepository,
): SessionGuard = SessionGuard(
    policy = SessionLimitPolicy(freeSessionDuration, freeSessionPause, clock),
    windows = windows,
    isPremium = { purchases.isPremium.value },
)

Экран: отказать и объяснить

Перед подключением ViewModel спрашивает сторожа. Во время паузы она не поднимает туннель, который сервис тут же снимет, а показывает, сколько осталось ждать:

ConnectViewModel.ktkotlin
private fun toggleConnection() = viewModelScope.launch {
    if (!sessionGuard.canConnect()) {
        sessionGuard.remainingPause()?.let { left ->
            alert.value = LimitAlert(
                minutes = left.inWholeMinutes.toInt(),
                seconds = (left.inWholeSeconds % 60).toInt(),
            )
        }
        return@launch
    }
    val server = repository.serverBy(repository.selectedEndpoint.value) ?: return@launch
    sessionGuard.onConnected()
    vpnController.connect(server)
}

Тесты на виртуальном времени

delay сторожа идёт по виртуальному времени тестового планировщика, а сессия — по нашим Clock. Хитрость в том, чтобы двигать их вместе:

SessionWatchdogTest.ktkotlin
private var now = 1_000_000L

private fun guard() = SessionGuard(
    policy = SessionLimitPolicy(2.minutes, 1.minutes, clock = Clock { now }),
    windows = MemoryWindows(),
    isPremium = { false },
)

/** Часы сессии календарные, поэтому виртуальное время двигается вместе с ними. */
private fun advance(duration: Duration, virtual: (Long) -> Unit) {
    now += duration.inWholeMilliseconds
    virtual(duration.inWholeMilliseconds)
}

@Test
fun the_tunnel_is_dropped_when_the_free_session_runs_out() = runTest {
    var stopped = false
    val job = launch { SessionWatchdog(guard(), interval = 5.seconds).watch { stopped = true } }
    runCurrent() // окно заводится в первом же шаге сторожа

    advance(1.minutes) { advanceTimeBy(it) }
    assertThat(stopped).isFalse()

    advance(1.minutes + 10.seconds) { advanceTimeBy(it) }
    assertThat(stopped).isTrue()

    job.cancel()
}

Чек-лист

  • Сторож принадлежит VpnService, а не экрану: туннель переживает интерфейс и может стартовать без него.
  • Держите политику на чистом Kotlin с подменяемыми часами.
  • Храните окно в DataStore двумя моментами, а не обратным отсчётом в памяти.
  • Проверяйте canConnect до открытия окна, а новое окно открывайте только после конца старой сессии.
  • Опрашивайте короткими шагами по календарным часам: один длинный delay растягивается на время глубокого сна.
  • Отменяйте задачу сторожа при остановке туннеля и передавайте в аналитику отдельную причину отключения.
  • Подписчикам окно не заводится.

То же устройство на iOS — с App Group, NEPacketTunnelProvider и DispatchSourceTimer — в статье про iOS.

ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности