Preskoči na sadržaj

Inženjering6 min čitanja

Tajmer za isključenje VPN-a na Androidu: čuvar živi u VpnService-u

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.

SessionLimit.ktkotlin
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.

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: politika, skladište i pretplata zajedno

Sa ostatkom aplikacije razgovara SessionGuard. Glavne finese su u dve njegove metode.

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

    /** 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.

SessionWatchdog.ktkotlin
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:

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

    /** Č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.

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

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:

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

Testovi na virtuelnom vremenu

delay čuvara ide po virtuelnom vremenu test planera, a sesija po našem Clock-u. Trik je pomerati ih zajedno:

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

/** 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 canConnect pre otvaranja prozora, a novi prozor otvarajte tek posle kraja stare sesije.
  • Proveravajte kratkim koracima po kalendarskom satu: jedan dugi delay se 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.

PodeliXLinkedIn

ONLYAPPS Team

Novi Sad, Srbija · onlyapps.org

Kontakt

Kolačiće za analitiku koristimo samo uz vašu saglasnost. Neophodni kolačići održavaju rad sajta. Politika privatnosti