Kako VPN Russia ograničava besplatnu sesiju na 15 minuta na Androidu: politika u čistom Kotlinu sa zamenljivim satom, prozor sesije u DataStore-u, čuvar na korutinama unutar VpnService-a i testovi na virtuelnom vremenu.
Besplatni režim aplikacije VPN Russia donosi 15 minuta veze, zatim 45 minuta pauze; pretplata uklanja ograničenje. Na iOS-u tajmer živi u ekstenziji tunela — o tome smo pisali u tekstu o iOS-u. Android klijent je prenos iste logike, a platforma postavlja isto pitanje u drugom obliku: ko upravlja tajmerom kada ekrana aplikacije nema?
Zašto interfejs nije pravo mesto
Provera na dugmetu „Poveži” je potrebna, ali nije dovoljna. Na Androidu ekran i tunel žive odvojenim životima:
- Korisnik zatvori aplikaciju ili je prevuče, a tunel nastavlja da radi u foreground servisu — na ekranu više nema ko da broji vreme.
- Tunel ne mora da krene sa našeg dugmeta: sistem može sam da pokrene
VpnService, na primer kada je u podešavanjima uključen stalni VPN (always-on). - Tajmeri u ViewModel-u umiru zajedno sa ekranom, a ekran je prvo što Android oduzima.
Zato je pravilo isto kao na iOS-u: komponenta koja drži tunel — naš VpnService — prati sesiju i sama gasi tunel. Interfejs samo pita da li je povezivanje dozvoljeno i pokazuje koliko treba čekati.
Politika u čistom Kotlinu
Sva aritmetika vremena je u modulu bez zavisnosti od Androida. Trenutno vreme dolazi preko Clock-a sa jednom metodom, pa ga testovi pomeraju kako žele.
fun interface Clock { fun nowMillis(): Long }
/** Do kada korisnik sme da ostane povezan i kada sme ponovo da se poveže. */
data class FreeSessionWindow(
val allowedUntilMillis: Long,
val nextConnectionAtMillis: Long,
)
class SessionLimitPolicy(
val sessionDuration: Duration,
private val pauseDuration: Duration,
private val clock: Clock,
) {
/** Povezivanje je zabranjeno samo između kraja sesije i kraja pauze. */
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)
}
}Kao i na iOS-u, čuvamo dva trenutka, a ne ostatak. Datumi preživljavaju ponovna pokretanja i sami daju ključno pravilo: ponovno povezivanje unutar prozora ne poklanja korisniku petnaest novih minuta.
Prozor na disku
Kada bi prozor bio u memoriji, ograničenje bi se resetovalo ponovnim pokretanjem aplikacije. Zato je u DataStore-u; ključevi čak ponavljaju iOS verziju, što olakšava otklanjanje grešaka na obe platforme.
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: politika, skladište i pretplata zajedno
Sa ostatkom aplikacije razgovara SessionGuard. Glavne finese su u dve njegove metode.
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()
}
/** Pretplatniku se prozor ne otvara: posle otkazivanja naleteo bi na tuđe staro ograničenje. */
suspend fun onConnected() {
if (isPremium()) return
val window = windows.current()
// Ponovno povezivanje unutar aktivne sesije ne produžava prozor
if (window != null && window.allowedUntilMillis > policy.nowMillis()) return
windows.save(policy.startSession())
}
}Pogledajte proveru u onConnected. Primamljivo je napisati if (canConnect()) return, ali canConnect važi i za prozor čija je pauza već prošla. Sa takvom verzijom stari prozor bi ostao zauvek, odbrojavanje više ne bi počinjalo i ograničenje bi prestalo da radi od drugog povezivanja. Da li treba novi prozor, odlučuje samo kraj sesije.
Čuvar unutar VpnService-a
Čuvar je mala klasa sa jednom suspend funkcijom. Šta da uradi kada rok istekne, dobija kao lambdu, pa ne zna ništa o servisu i lako se testira.
class SessionWatchdog(
private val guard: SessionGuard,
private val interval: Duration = 5.seconds,
) {
suspend fun watch(stopTunnel: suspend () -> Unit) {
// Prvo zabrana, pa prozor. Obrnuto ne sme: povezivanje tokom pauze
// otvorilo bi sebi novi prozor i tako skinulo sopstvenu zabranu
if (!guard.canConnect()) {
stopTunnel()
return
}
// Prozor otvara sam tunel: aplikacija možda nije učestvovala u pokretanju
guard.onConnected()
while (currentCoroutineContext().isActive) {
delay(interval)
if (guard.sessionExpired()) {
stopTunnel()
return
}
}
}
}Zašto provera na svakih pet sekundi, a ne jedan delay(remaining)? Kašnjenja korutina idu po monotonom satu koji stoji u dubokom snu. Zaključajte telefon na sat vremena i jedno kašnjenje od petnaest minuta završiće se posle petnaest minuta budnog rada — sesija će porasti za celo vreme spavanja. Kratki koraci uz poređenje sa kalendarskim satom stižu rok za pet sekundi posle buđenja.
Servis pokreće čuvara kada se tunel poveže i otkazuje ga kada se tunel zaustavlja:
@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,
)
/** Čuvar živi u samom servisu: zatvorena aplikacija ga ne zaobilazi. */
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 odlazi u analitiku, pa se isključenje zbog ograničenja ne meša sa pritiskom na „Isključi” niti sa opozivom VPN-a od strane sistema (onRevoke).
Povezivanje zavisnosti i kratki intervali za debug
Trajanja zadaje DI modul. U debug verziji to su dva minuta i jedan minut: tajmer od četvrt sata niko ne proverava čekanjem.
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 },
)Ekran: odbiti i objasniti
Pre povezivanja ViewModel pita čuvara. Tokom pauze ne podiže tunel koji bi servis odmah ugasio, već pokazuje koliko još treba čekati:
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)
}Testovi na virtuelnom vremenu
delay čuvara ide po virtuelnom vremenu test planera, a sesija po našem Clock-u. Trik je pomerati ih zajedno:
private var now = 1_000_000L
private fun guard() = SessionGuard(
policy = SessionLimitPolicy(2.minutes, 1.minutes, clock = Clock { now }),
windows = MemoryWindows(),
isPremium = { false },
)
/** Sat sesije je kalendarski, pa se virtuelno vreme pomera zajedno sa njim. */
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() // prozor se otvara već u prvom koraku čuvara
advance(1.minutes) { advanceTimeBy(it) }
assertThat(stopped).isFalse()
advance(1.minutes + 10.seconds) { advanceTimeBy(it) }
assertThat(stopped).isTrue()
job.cancel()
}Kontrolna lista
- Čuvar pripada
VpnService-u, a ne ekranu: tunel nadživljava interfejs i može da krene bez njega. - Držite politiku u čistom Kotlinu sa zamenljivim satom.
- Čuvajte prozor u DataStore-u kao dva trenutka, a ne kao odbrojavanje u memoriji.
- Proverite
canConnectpre otvaranja prozora, a novi prozor otvarajte tek posle kraja stare sesije. - Proveravajte kratkim koracima po kalendarskom satu: jedan dugi
delayse rasteže za vreme dubokog sna. - Otkažite posao čuvara kada se tunel zaustavlja i prosledite analitici poseban razlog isključenja.
- Pretplatnicima se prozor ne otvara.
Isti dizajn na iOS-u — sa App Group-om, NEPacketTunnelProvider-om i DispatchSourceTimer-om — nalazi se u tekstu o iOS-u.
ONLYAPPS Team
Novi Sad, Srbija · onlyapps.org