Как 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 с одним методом, так что тесты двигают его как угодно.
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-версию — так проще отлаживать обе платформы.
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. Основные тонкости — в двух его методах.
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-функцией. Что делать по сроку, он получает лямбдой, поэтому ничего не знает о сервисе и легко тестируется.
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)? Задержки корутин идут по монотонным часам, которые стоят в глубоком сне. Заблокируйте телефон на час — и одна пятнадцатиминутная задержка закончится через пятнадцать минут бодрствования: сессия вырастет на всё время сна. Короткие шаги со сверкой по календарным часам догоняют срок за пять секунд после пробуждения.
Сервис запускает сторожа, когда туннель подключился, и отменяет, когда туннель останавливается:
@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. В отладочной сборке — две минуты и одна минута: таймер на четверть часа ожиданием никто не проверяет.
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 спрашивает сторожа. Во время паузы она не поднимает туннель, который сервис тут же снимет, а показывает, сколько осталось ждать:
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. Хитрость в том, чтобы двигать их вместе:
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.
ONLYAPPS Team
Нови-Сад, Сербия · onlyapps.org