Skip to main content

Connecting

Before connecting, the user must grant Android's system VPN consent. The system shows a one-time consent dialog the first time any app prepares a VPN. KapeVpnManager wraps the platform VpnService.prepare(...) call:

val vpn = KapeVpnManager(
handle,
protocols = listOf(KapeWireGuardSupport, KapeLightwaySupport, KapeOpenVpnSupport),
)

// Returns an Intent if consent is required, or null if already granted.
// Must be called from an Activity context (see the note below).
val consentIntent = vpn.prepare(activity)
if (consentIntent != null) {
consentLauncher.launch(consentIntent)
}

Launch the returned Intent from an ActivityResultLauncher and proceed to connect(...) once the result is RESULT_OK:

val consentLauncher = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
viewModelScope.launch { vpn.connect(KapeConnectionMode.Auto) }
}
}

Consent persists across app launches. The user only needs to grant it once (until they revoke it in system settings).

info

prepare(...) must be called from an Activity context so Android can present the consent dialog. It returns null when consent was already granted, in which case you can call connect(...) directly.

Connection Modes​

KapeVpnManager supports two connection modes via KapeConnectionMode:

// Automatic — SDK picks the best available server
vpn.connect(KapeConnectionMode.Auto)

// Manual — connect to a specific server
vpn.connect(KapeConnectionMode.Manual(locationId = "us-east-1"))

// Direct shorthand for manual
vpn.connect("us-east-1")

connect(mode: KapeConnectionMode) is a suspend function that returns a ConnectResult. The direct connect(locationId: String) shorthand remains fire-and-forget and does not return a result.

ModeBehaviour
AutoThe SDK selects the best available location from cached SmartLocation data (highest-rated entry). If no SmartLocation data is cached yet, the first location for the currently selected protocol is used. No network request is made for the cache lookup itself.
Manual(locationId)Connects to the specified location. The locationId comes from KapeServerLocation.id.
import com.kape.platformsdk.vpn.ConnectResult

when (val result = vpn.connect(KapeConnectionMode.Auto)) {
is ConnectResult.Success -> { /* result.locationId is the location that was connected to */ }
ConnectResult.NoLocationsAvailable -> { /* no cached SmartLocation and no location for the selected protocol */ }
is ConnectResult.SdkError -> { /* handle result.error */ }
}
info

Auto reads from the SmartLocation cache. It never triggers a new measurement. Call fetchSmartLocations() beforehand to ensure the cache is populated. If the cache is empty, the first server in the unordered server list is used as a fallback.

What happens on connect​

All modes follow the same internal flow:

  1. Saves the selected location to shared state (a DataStore shared with the VPN service)
  2. Starts the KapeVpnService foreground service
  3. The service reads the location, the selected VPN protocol, and authenticates endpoints via the SDK
  4. Establishes the tunnel (WireGuard, Lightway, or OpenVPN, depending on the selected protocol)

To change the protocol, see VPN Protocol.

If already connected, calling any connect variant with a different location will switch servers without disconnecting first.

Disconnecting​

vpn.disconnect()

Signing Out​

vpn.signOut()

Goes further than disconnect(): it also stops the VPN service and resets every persisted VPN setting — last location, OpenVPN credentials, proxies, Connect on Demand and connect-on-startup, plus protocol, transport, kill switch, split tunnel, custom DNS, Advanced Protection, cipher and SNI. The next account starts from SDK defaults.

Call it alongside KapeAuthClient.logout(), which cannot reach the tunnel itself — see Authentication. It is idempotent and safe to call with nothing connected.

Connection Status​

Observe the connection status via the connectionStatus StateFlow:

viewModelScope.launch {
vpn.connectionStatus.collect { status ->
when (status) {
KapeVPNConnectionStatus.Disconnected -> { /* Not connected */ }
KapeVPNConnectionStatus.Connecting -> { /* Establishing tunnel */ }
KapeVPNConnectionStatus.Connected -> { /* Tunnel active */ }
KapeVPNConnectionStatus.Reconnecting -> { /* Re-establishing after a drop */ }
KapeVPNConnectionStatus.Disconnecting -> { /* Tearing down */ }
KapeVPNConnectionStatus.Paused -> { /* Tunnel down, auto-resumes */ }
}
}
}

Traffic Stats​

Observe cumulative bytes sent/received for the current connection via the trafficStats StateFlow:

viewModelScope.launch {
vpn.trafficStats.collect { stats ->
println("Received: ${stats.bytesReceived} bytes, Sent: ${stats.bytesSent} bytes")
}
}

trafficStats is of type StateFlow<KapeVpnTrafficStats>, polled roughly once per second while a connection is active, and resets to zero (KapeVpnTrafficStats.ZERO) when the session ends.

Connection Errors​

Failures arrive on one of two channels, depending on when they happen.

Synchronously, from connect(...) — see Connection Modes. ConnectResult covers what can be determined before the tunnel service starts: NoLocationsAvailable, DipError(error) (see Dedicated IP) and SdkError(error).

Asynchronously, on lastTunnelError — once the service is running, the tunnel lives in its own lifecycle and nothing propagates back to the connect(...) call site. Transient failures (endpoint fetch failed, handshake timed out, no endpoints in a batch) are retried automatically with exponential backoff and are deliberately not surfaced — reporting them would be noise. What does surface is the subset the app has to act on:

viewModelScope.launch {
vpn.lastTunnelError.filterNotNull().collect { error ->
when (error) {
KapeVpnTunnelError.VpnPermissionMissing,
KapeVpnTunnelError.VpnPermissionRevoked -> promptForConsent()
KapeVpnTunnelError.DipExpired -> promptDipReassignment()
else -> showMessage(error)
}
}
}

Values​

KapeVpnTunnelErrorCauseFix
VpnPermissionMissingThe OS refused to build the tunnel interface — this app is not the prepared VPN app (consent never granted, or another VPN app took it)prepare(activity) and launch the returned Intent
VpnPermissionRevokedThe user revoked the VPN in system settings, or another VPN app replaced this onesame as above
DipExpiredThe Dedicated IP address expired, or the subscription was resetre-run Dedicated IP setup
DipUnderMaintenanceThe Dedicated IP server is offlineretry later
DipUnavailableDedicated IP health unknown, no cached access token, no IPv4 configuration, or a Simple Dedicated IP no longer reservedretry; re-run setup if it persists
DipUnsupportedProtocolThe Dedicated IP does not serve the selected protocol (Anonymous: anything but Lightway)switch to KapeVPNProtocol.Lightway or Automatic

lastTunnelError is StateFlow<KapeVpnTunnelError?>. It is cleared on the next connect(...) call, on disconnect(), and as soon as a connection succeeds. It deliberately survives the service unbinding, so a revoked VPN — which stops the service itself — still reports why.

info

A VpnPermissionMissing failure ends the session immediately instead of retrying: without OS consent, every attempt would fail the same way. Everything else keeps retrying in the background, so seeing a value here does not necessarily mean the session is over.

Pausing​

Temporarily bypass the VPN for a fixed duration without tearing down the whole session — the tunnel process stays alive, and the VPN auto-reconnects once the duration elapses:

import com.kape.platformsdk.vpn.PauseResult
import com.kape.platformsdk.vpn.ResumeResult
import kotlin.time.Duration.Companion.minutes

when (vpn.pause(5.minutes)) {
PauseResult.Success -> { /* traffic now bypasses the tunnel */ }
PauseResult.InvalidDuration -> { /* duration was zero or negative */ }
PauseResult.NotConnected -> { /* not connected or already paused */ }
PauseResult.NotLicensed -> { /* the license does not grant VpnSessionPauseResume */ }
}

// Reconnect immediately, before the duration elapses. resume() is never gated by the license, so a
// tunnel paused by a previously-licensed build can always be released:
when (vpn.resume()) {
ResumeResult.Success -> { /* reconnecting */ }
ResumeResult.NotPaused -> { /* nothing to resume */ }
}

pause(duration) is valid only when connectionStatus is Connected or already Paused — calling it again while paused re-arms the duration rather than starting a second pause. Calling resume() or reconnect() while paused both reconnect immediately; disconnect() and switching to a new location via connect(...) both tear down the pause cleanly as part of their normal teardown.

Observe the countdown via pausedUntilFlow:

viewModelScope.launch {
vpn.pausedUntilFlow.collect { pausedUntilMillis ->
// Epoch millis the pause ends, or null when not paused.
}
}

connectionStatus reports KapeVPNConnectionStatus.Paused for the duration of the pause — the foreground service notification also switches to reflect this ("VPN Paused") and reverts once the tunnel reconnects.

The Kill Switch is intentionally bypassed for the duration of a pause (regardless of killSwitchMode) — see Network Protection.