Network Protection
Kill Switch
The Kill Switch controls what happens to network traffic when the VPN connection drops unexpectedly.
Configure it on the KapeVpnManager before connecting:
vpn.setKillSwitchMode(KapeKillSwitchMode.Standard) // Off / Standard / Advanced
Modes
| Mode | Behaviour | When to use |
|---|---|---|
Off | No traffic is blocked while the tunnel is down or being (re)established — packets are free to leak outside the VPN. | SDK consumers whose own network calls (e.g. plain HTTP requests made outside the SDK) must keep working even when no protected route is available, and which cannot call protect() themselves. |
Standard | The SDK continuously attempts to reconnect. Internet traffic is blocked until the tunnel is restored, while local network communication remains functional. | Recommended default for consumer VPN apps. |
Advanced | All traffic without exception must be routed through the tunnel. Any traffic that cannot be tunnelled — including local network communication — is blocked until the tunnel is restored. | High-security scenarios where any unprotected traffic is unacceptable. |
On Android, the strongest guarantee — blocking all traffic when the tunnel is down — is enforced by
the system Always-on VPN feature with "Block connections without VPN" (lockdown) enabled.
This is a system setting the user must turn on; an app cannot enable it programmatically.
KapeKillSwitchMode.Advanced configures the SDK and tunnel for this mode and you can deep-link the
user to the relevant settings screen, but the OS-level lockdown toggle remains user-controlled.
To send the user to the system VPN settings where Always-on / lockdown is configured:
context.startActivity(Intent("android.net.vpn.SETTINGS"))
Pausing and the Kill Switch
Pausing the VPN (vpn.pause(duration)) intentionally bypasses the Kill
Switch for the duration of the pause, regardless of killSwitchMode — pausing is a deliberate,
user-initiated bypass, not an unexpected drop, so none of the three modes above block traffic while
paused. The Kill Switch's normal behavior resumes automatically as soon as the tunnel reconnects
(either via resume() or once the pause duration elapses).
Always-on VPN
This feature is provided by the system Always-on VPN setting, which the user enables for your app in system settings.
Connect on Startup
when (vpn.setConnectOnStartup(true)) {
is KapeVpnSettingResult.Success -> Unit
is KapeVpnSettingResult.NotLicensed -> showError("Not licensed")
}
Connects once the device has finished booting. Read the current value from
vpn.connectOnStartupFlow. Defaults to false.
Suppressed when the system Always-on VPN is enabled for your app — the OS starts the tunnel itself in that configuration — and when VPN consent has not been granted.
The SDK registers no boot receiver of its own, because starting a tunnel needs a
KapeVpnManager and only you can build one. Register your own and forward the event:
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<receiver android:name=".MyBootReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
class MyBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
myVpnManager.onBootCompleted()
} finally {
pendingResult.finish()
}
}
}
}
The subscription is resolved for you. The tunnel cannot fetch an endpoint without one: it would
raise its network lock, then retry an endpoint fetch that can never succeed, leaving the device with
traffic blocked and no connection. KapeSdkHandle therefore activates a subscription itself — after
a reboot from the persisted authentication state, since nothing has opened your UI — and both
automatic connects wait for that pick to settle before starting. If it finds no subscription they
refuse to start and log it, so the failure is visible rather than silent.
Without a receiver, Connect on Startup never fires, and Connect on Demand only resumes watching
once something else starts your process. Constructing a KapeVpnManager is what restarts the
watcher, so the same receiver covers both.
BOOT_COMPLETED is delivered once the user unlocks the device, not at LOCKED_BOOT_COMPLETED —
the SDK's settings are credential-encrypted, so there is nothing readable before the first unlock.
A device that is rebooted and never unlocked never connects.
Connect on Demand
Connect on Demand starts and stops the VPN automatically as the device joins and leaves networks,
without user interaction. Both it and the rules below require the VpnAutoConnectRules feature on
your SDK license — see Licensed VPN features.
vpn.setConnectOnDemand(true)
Read the current value from vpn.connectOnDemandFlow. Defaults to false.
Because Android does not expose SSID-triggered VPN rules to apps, the SDK evaluates the rules itself by observing connectivity changes. Rules are evaluated in order, and the first one that matches wins.
The SDK can only react while its own process is alive. It keeps a foreground service running to stay alive while no tunnel is up (see The standby notification), but a process the system kills outright stops applying rules until something starts it again.
Rules are only applied when the network changes. A connection you end yourself therefore stands until the device actually moves to another network — enabling Connect on Demand never overrides a manual disconnect, and disabling it never changes the current connection.
Connect on Demand Rules
vpn.setOnDemandRules(
listOf(
// Disconnect on a trusted home network
KapeOnDemandRule(KapeOnDemandCondition.Ssid("Home WiFi"), KapeOnDemandAction.Disconnect),
// Connect on any other WiFi
KapeOnDemandRule(KapeOnDemandCondition.Wifi, KapeOnDemandAction.Connect),
// Connect on cellular
KapeOnDemandRule(KapeOnDemandCondition.Cellular, KapeOnDemandAction.Connect),
),
)
setOnDemandRules stores the list exactly as given and never reorders it. Read it back from
vpn.onDemandRulesFlow.
Conditions
| Condition | Matches |
|---|---|
Ssid("Name") | A specific WiFi network by SSID |
Carrier("Name") | A specific mobile network, by carrier name |
Wifi | Any WiFi network |
SecuredWifi | A WiFi network using WPA or better |
UnsecuredWifi | A WiFi network with no encryption, or WEP |
Cellular | Any cellular (mobile data) connection |
Any | Any network at all — the catch-all |
Actions
| Action | Behaviour |
|---|---|
Connect | Start the tunnel, unless one is already up |
Disconnect | Stop the tunnel, if one is up — never while the system Always-on VPN is enabled for your app |
Retain | Match and stop, changing nothing — leaves the tunnel as the user left it |
Retain is not the same as no rule matching: it stops evaluation, so it shadows any broader rule
below it. That is how you exempt one network from a catch-all without disconnecting on it.
While the system Always-on VPN is enabled for your app, a matching Disconnect rule changes
nothing, exactly like Retain: the user has asked the OS to keep the tunnel up, and tearing it down
would either leak traffic or, with lockdown on, cut the network until the next network change.
onDemandStateFlow still reports Watching; the skipped disconnect is only logged.
A "trusted network" rule trusts the broadcast name only. Ssid matches the network's name and
nothing else — there is no BSSID pinning, certificate check or other binding to a physical access
point. SecuredWifi and UnsecuredWifi only check whether some encryption is in use.
Anyone in radio range can broadcast the same name. When the device joins that access point, a
Disconnect rule tears the tunnel down exactly as a user disconnect would — killSwitchMode does
not block traffic afterwards — and the device then sends all traffic, unprotected, through the
attacker's access point. Only a system Always-on VPN for your app prevents this: the SDK then skips
Disconnect rules entirely. A Retain rule on that name leaves the tunnel down if it was already down.
- An open network's name is spoofable by anyone. Never key
DisconnectorRetainon an open network or onUnsecuredWifi. - A WPA-secured network is only as strong as its password. A rule for a network whose password is weak or widely known is as spoofable as an open one.
SecuredWifi -> Disconnecttrusts every encrypted network, including one an attacker sets up.
If you let end users mark networks as trusted, tell them this: a trusted network rule is only as strong as that network's own authentication.
Adding rules one at a time
An app whose UI adds one network at a time should not have to work out where in the list the new rule belongs. These helpers place it for you, and edit the stored list atomically:
vpn.addOnDemandRule(KapeOnDemandRule(KapeOnDemandCondition.Ssid("Home"), KapeOnDemandAction.Disconnect))
vpn.removeOnDemandRule(KapeOnDemandCondition.Ssid("Home"))
vpn.resetOnDemandRules()
addOnDemandRule inserts by how narrowly the condition matches — a named network first, then
SecuredWifi/UnsecuredWifi, then Wifi/Cellular, then Any — and appends among equals, so an
order you chose within a group survives. Adding a rule for a condition that is already present
replaces its action in place rather than appending an unreachable duplicate.
Use setOnDemandRules when you want an order of your own; it overrides all of this.
Rules and both toggles are persisted automatically and restored on the next app launch.
The standby notification
While Connect on Demand is enabled and no tunnel is running, the SDK runs a foreground service so it keeps seeing network changes. Android requires a notification for that, and the SDK deliberately ships none of its own — it would carry the wrong brand and the wrong wording. Supply one:
// In Application.onCreate(), so it is set before the system can start the service after a reboot.
vpn.onDemandNotificationProvider = KapeOnDemandNotificationProvider { state ->
NotificationCompat.Builder(this, MY_CHANNEL_ID)
.setContentTitle("VPN standing by")
.setContentText(
when (state) {
KapeOnDemandState.NetworkIdentityUnavailable -> "Grant location access to apply network rules"
else -> "Watching for network changes"
},
)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.build()
}
Create the channel yourself first. Without a provider the service cannot go to the foreground and stops itself, so Connect on Demand will only work while something else keeps your process alive.
vpn.onDemandStateFlow publishes the same KapeOnDemandState if you would rather drive your own UI
from it.
Reading the network requires location access
Matching Ssid, SecuredWifi or UnsecuredWifi means reading the connected WiFi network's name,
which Android redacts on API 29+ unless all of the following hold:
ACCESS_FINE_LOCATIONis granted,ACCESS_BACKGROUND_LOCATIONis granted — Connect on Demand reads the network precisely when your app is not in front of the user,- location services are switched on.
These are declared and requested by your app, not by the SDK, which does not bundle a sensitive permission on integrations that use no SSID rules:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
When the current network cannot be identified and your rule set contains any condition that
names a network, the SDK applies no rule at all and reports
KapeOnDemandState.NetworkIdentityUnavailable.
It deliberately does not fall through to a broader rule: doing so would connect on exactly the
network a user marked as not needing the VPN. A rule set that names nothing — say only
Wifi -> Connect — is unaffected and keeps working without the permission.
Split Tunneling
Split Tunneling lets you control which traffic is routed through the VPN and which bypasses it. Android supports two complementary, independent styles — per-app and per-route — that can both be active at the same time.
Per-app split tunneling
Android can route traffic per app, backed by
VpnService.Builder.addAllowedApplication / addDisallowedApplication. Configure it on
KapeVpnManager with splitTunnelAppMode, which defaults to KapeSplitTunnelAppMode.Off (every
app is tunnelled):
// Allow-list — only these apps are routed through the VPN
vpn.setSplitTunnelAppMode(KapeSplitTunnelAppMode.Allow(listOf("com.example.browser", "com.example.mail")))
// Deny-list — these apps bypass the VPN
vpn.setSplitTunnelAppMode(KapeSplitTunnelAppMode.Disallow(listOf("com.example.banking")))
// Off — every app is tunnelled (default)
vpn.setSplitTunnelAppMode(KapeSplitTunnelAppMode.Off)
Instead of assigning the full package list at once, you can pick a mode and build the list up incrementally:
vpn.setSplitTunnelAppMode(KapeSplitTunnelAppMode.Allow(emptyList()))
vpn.addApplicationToCurrentSplitTunnelMode("com.example.browser")
vpn.addApplicationToCurrentSplitTunnelMode("com.example.mail")
vpn.removeApplicationFromCurrentSplitTunnelMode("com.example.mail")
vpn.resetCurrentSplitTunnelMode() // clears the list, keeps the current mode
addApplicationToCurrentSplitTunnelMode/removeApplicationFromCurrentSplitTunnelMode/resetCurrentSplitTunnelMode
return KapeSplitTunnelEditResult.ModeOff (instead of editing anything) if splitTunnelAppMode is
currently Off — pick Allow/Disallow first.
An Allow mode with an empty or never-populated package list has no effect: VpnService.Builder
tunnels every app unless addAllowedApplication is called at least once, so this behaves the same
as Off — every app is tunnelled — until packages are added.
Like killSwitchMode, splitTunnelAppMode applies on the next full connect() — call reconnect()
to pick up a mid-session change. KapeKillSwitchMode.Advanced and Disallow aren't contradictory:
an app you've explicitly excluded is intentionally bypassing the tunnel, not leaking.
Per-route split tunneling
Not yet implemented. You'll also be able to split by IP range in CIDR notation, either as a
whitelist (only matching traffic goes through the VPN) or a blacklist (matching traffic bypasses
it), as its own independent setting alongside splitTunnelAppMode:
// Whitelist — only these ranges are routed through the VPN
vpn.splitTunnelRouteMode = KapeSplitTunnelRouteMode.Whitelist(listOf(
KapeRoute.Cidr("10.0.0.0/8"),
KapeRoute.Cidr("192.168.1.0/24"),
))
// Blacklist — these ranges bypass the VPN
vpn.splitTunnelRouteMode = KapeSplitTunnelRouteMode.Blacklist(listOf(
KapeRoute.Cidr("203.0.113.0/24"),
))
Instead of hardcoding IP ranges, you'll be able to provide a domain name, with the SDK resolving its IP addresses automatically before the tunnel is established and applying the resolved ranges to the rule:
vpn.splitTunnelRouteMode = KapeSplitTunnelRouteMode.Whitelist(listOf(
KapeRoute.Cidr("10.0.0.0/8"),
KapeRoute.Domain("internal.example.com"),
))
Per-route exclusion (Blacklist) will rely on VpnService.Builder.excludeRoute, which requires
API 33+; on older versions only inclusion routes will be applied.
Custom DNS
By default the tunnel uses the per-protocol default resolvers. You can override these with your own,
in priority order, via KapeVpnManager. The setting applies to all three protocols.
// Dotted-quad IPv4 only, all-or-nothing.
when (val result = vpn.setCustomDnsServers(listOf("1.1.1.1", "9.9.9.9"))) {
is KapeCustomDnsResult.Success -> Unit
is KapeCustomDnsResult.InvalidAddress -> showError("Not a valid IPv4 address: ${result.value}")
is KapeCustomDnsResult.NotLicensed -> showError("The license does not grant VpnCustomDns")
}
vpn.customDnsServers // ["1.1.1.1", "9.9.9.9"]
vpn.customDnsServersFlow // StateFlow<List<String>> for UI binding
vpn.setCustomDnsServers(emptyList()) // restore the per-protocol default
Entries are trimmed and de-duplicated, and the order is preserved — it is the resolver priority
order. The first invalid entry rejects the whole list and nothing is persisted. Validation is
strict because VpnService.Builder.addDnsServer throws on a non-literal address, which fails
establish() and the whole connect: "10.9.8" is rejected, as are leading zeros and IPv6
addresses.
| Protocol | Empty / unset behaviour |
|---|---|
| Lightway | The synthetic in-tunnel resolver (100.64.100.3) |
| WireGuard | The server-returned resolvers, then the internal-IP gateway heuristic |
| OpenVPN | The server-pushed peer gateway |
The resolvers replace the default rather than joining it. Android treats configured DNS servers as an interchangeable set, so keeping the default alongside yours would send roughly half of all queries to it.
Precedence over Advanced Protection
Custom DNS wins over the Advanced Protection filtering resolver
(KapeVpnManager.advancedProtectionCategories). When both are set:
- the custom resolvers are installed on every protocol;
- no filtering resolver is fetched at all — the LL SDK call is skipped, which also means the selected categories are not pushed onto the SDK configuration;
- on Lightway the legacy
ThreatManagerInfois forced inert, since a live one would make the Rust core rewrite DNS ports and intercept queries meant for your resolvers; - Advanced Protection filtering therefore does nothing while custom DNS is set. The tunnel logs a warning naming the bypass; clear the custom resolvers to re-enable filtering.
Reachability and caveats
Each resolver gets an explicit host route (/32, or /128 for IPv6), so it stays reachable even
when local ranges are excluded from the routes under kill switch Off/Standard — a more
specific route always wins.
Prefer public resolvers. Because the host route is unconditional, a resolver on the user's own
LAN (e.g. a Pi-hole on 192.168.1.10) is routed into the tunnel, where nothing answers. The route is
deliberately not skipped for private addresses: doing so would be an intentional DNS leak, and it is
impossible under KapeKillSwitchMode.Advanced anyway. The tunnel logs a warning when a resolver is
in a private range.
Local-network resolvers: known failure modes
A resolver on the user's own LAN is accepted — a private address is legitimate when the resolver lives VPN-side — but the unconditional host route means it behaves badly in two situations, neither of which the SDK detects.
| Situation | Result |
|---|---|
KapeKillSwitchMode.Advanced, on the LAN | Routes are 0.0.0.0/0 with no local exception, and the host route sends the query into the tunnel. The VPN server cannot reach a private address, so all name resolution fails. |
Off / Standard, on the LAN | Local ranges are excluded from the routes, but the per-resolver host route is more specific and wins — so the query still goes into the tunnel and still fails. This is why the tunnel logs a private-range warning. |
| User leaves that network | Nothing answers at that address. DNS fails until the setting is cleared. |
Two consequences worth designing around:
- It looks like a broken connection, not a broken setting. The tunnel stays connected; only resolution is dead.
- It does not self-heal. The resolvers are read once in
startVpn()and persist across reconnects, so changing networks does not re-evaluate them. There is no probing and no fallback.
If the resolver may be network-specific, clear it (setCustomDnsServers(emptyList())) once the user is off the
network that hosts it — the SDK will not do this for you.
A private address is not unique to one network: 192.168.1.10 elsewhere is some stranger's device. Treat a LAN
resolver as safe only on the LAN that owns it.
A user-configured Private DNS hostname (DNS-over-TLS) defeats this setting entirely, on every
protocol: the platform resolves over TLS to that provider and never queries the tunnel's resolvers.
No VpnService.Builder API can override it, so the SDK only logs a warning. Automatic Private DNS
mode is unaffected.
The settings are read once at tunnel start. Changing them while connected has no effect until the
next full disconnect()/connect() — reconnect() reuses the session's existing settings.
Verifying
While connected, the tunnel logs the resolvers it installed:
[tunnel] installing DNS resolvers=[1.1.1.1, 9.9.9.9] hostRoutes=2
With custom DNS set, the pre-tunnel network lock requests the same resolvers, so a Lightway connect
reuses the interface instead of re-establishing it (reusing current tunnel (settings match)).