Skip to content

Engineering6 min read

A VPN disconnect timer on Android: the watchdog lives in VpnService

How VPN Russia limits a free session to 15 minutes on Android: a pure Kotlin policy with an injectable clock, a session window in DataStore, a coroutine watchdog inside VpnService and tests on virtual time.

The free plan of VPN Russia gives 15 minutes of connection, then a 45-minute pause; a subscription removes the limit. On iOS the timer lives in the tunnel extension — we described it in the iOS post. The Android client is a port of the same logic, and the platform raises the same question in a different form: who owns the timer when the app's screen isn't there?

Why the UI is the wrong place

A check on the Connect button is necessary but not sufficient. On Android the screen and the tunnel live separate lives:

  • The user closes the app or swipes it away, and the tunnel keeps running in its foreground service — there is no screen left to count anything.
  • The tunnel doesn't have to start from our button: the system can start a VpnService by itself, for example with always-on VPN enabled in settings.
  • Timers in a ViewModel die with the screen, and the screen is the first thing Android takes away.

So the rule is the same as on iOS: the component that holds the tunnel — our VpnService — watches the session and drops the tunnel itself. The UI only asks whether a connection is allowed and shows how long to wait.

The policy in plain Kotlin

All the time arithmetic sits in a module with no Android dependencies. The current time comes through a one-method Clock, so tests can move it freely.

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

/** Until when the user may stay connected, and when they may connect again. */
data class FreeSessionWindow(
    val allowedUntilMillis: Long,
    val nextConnectionAtMillis: Long,
)

class SessionLimitPolicy(
    val sessionDuration: Duration,
    private val pauseDuration: Duration,
    private val clock: Clock,
) {
    /** Connecting is forbidden only between the end of the session and the end of the pause. */
    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)
    }
}

Like on iOS, we store two moments, not a remaining duration. Dates survive restarts and make the key rule automatic: reconnecting inside the window does not give the user fifteen fresh minutes.

The window on disk

If the window lived in memory, restarting the app would reset the limit. It goes to DataStore; the keys even repeat the iOS ones, which makes cross-platform debugging easier.

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: policy, storage and subscription together

SessionGuard is what the rest of the app talks to. Two of its methods carry most of the subtlety.

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()
    }

    /** No window for subscribers: after cancelling they would hit someone else's old limit. */
    suspend fun onConnected() {
        if (isPremium()) return
        val window = windows.current()
        // Reconnecting inside a running session does not extend it
        if (window != null && window.allowedUntilMillis > policy.nowMillis()) return
        windows.save(policy.startSession())
    }
}

Look at the check in onConnected. It is tempting to write if (canConnect()) return, but canConnect is also true for a window whose pause is already over. With that version the old window would stay forever, the countdown would never start again, and the limit would stop working from the second connection. Only the end of the session decides whether a new window is needed.

The watchdog inside VpnService

The watchdog is a small class with one suspend function. It takes the action to perform as a lambda, so it knows nothing about the service and is easy to test.

SessionWatchdog.ktkotlin
class SessionWatchdog(
    private val guard: SessionGuard,
    private val interval: Duration = 5.seconds,
) {
    suspend fun watch(stopTunnel: suspend () -> Unit) {
        // Check the ban first, then open the window. The other order would let
        // a connection during the pause open a new window and lift its own ban
        if (!guard.canConnect()) {
            stopTunnel()
            return
        }

        // The tunnel opens the window itself: the app may not have taken part in the start
        guard.onConnected()

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

Why poll every five seconds instead of a single delay(remaining)? Coroutine delays run on a monotonic clock that stops in deep sleep. Lock the phone for an hour, and a single fifteen-minute delay ends fifteen minutes of awake time later — the session grows by the whole sleep. Short steps against the wall clock catch up within five seconds after the device wakes.

The service starts the watchdog when the tunnel is connected and cancels it when the tunnel is stopping:

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,
    )

    /** The watchdog lives in the service itself: closing the app does not bypass it. */
    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 travels into analytics, so a limit disconnect is not confused with the user tapping Disconnect or the system revoking the VPN (onRevoke).

Wiring and debug durations

Durations come from the DI module. Debug builds use two minutes and one minute — nobody tests a quarter-hour timer by waiting for it.

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 },
)

The screen: refuse and explain

Before connecting, the ViewModel asks the guard. During the pause, instead of starting a tunnel that the service would drop at once, it shows how long to wait:

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)
}

Testing on virtual time

The watchdog's delay runs on the test scheduler's virtual time, while the session uses our Clock. The trick is to move both together:

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 },
)

/** The session clock is a calendar clock, so virtual time moves with it. */
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() // the window opens on the watchdog's first step

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

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

    job.cancel()
}

Checklist

  • The watchdog belongs to VpnService, not to the screen: the tunnel outlives the UI and may start without it.
  • Keep the policy in pure Kotlin with an injectable clock.
  • Store the window in DataStore as two moments, not as a countdown in memory.
  • Check canConnect before opening a window, and open a new window only when the old session has ended.
  • Poll with short steps against the wall clock; one long delay stretches by the time spent in deep sleep.
  • Cancel the watchdog's job when the tunnel stops, and pass a distinct disconnect reason to analytics.
  • No window for subscribers.

The same design on iOS — with an App Group, NEPacketTunnelProvider and DispatchSourceTimer — is in the iOS post.

ShareXLinkedIn

ONLYAPPS Team

Novi Sad, Serbia · onlyapps.org

Contact

We use analytics cookies only with your consent. Essential cookies keep the site working. Privacy policy