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
VpnServiceby 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.
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.
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.
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.
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:
@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.
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:
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:
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
canConnectbefore 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
delaystretches 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.
ONLYAPPS Team
Novi Sad, Serbia · onlyapps.org