Connecting
VPN Consent
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).
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.
| Mode | Behaviour |
|---|---|
Auto | The 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 */ }
}
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:
- Saves the selected location to shared state (a
DataStoreshared with the VPN service) - Starts the
KapeVpnServiceforeground service - The service reads the location, the selected VPN protocol, and authenticates endpoints via the SDK
- 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
KapeVpnTunnelError | Cause | Fix |
|---|---|---|
VpnPermissionMissing | The 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 |
VpnPermissionRevoked | The user revoked the VPN in system settings, or another VPN app replaced this one | same as above |
DipExpired | The Dedicated IP address expired, or the subscription was reset | re-run Dedicated IP setup |
DipUnderMaintenance | The Dedicated IP server is offline | retry later |
DipUnavailable | Dedicated IP health unknown, no cached access token, no IPv4 configuration, or a Simple Dedicated IP no longer reserved | retry; re-run setup if it persists |
DipUnsupportedProtocol | The 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.
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.