Android SDK Integration¶
leaf-sdk-android is a JNI-backed library that ships the Rust Leaf core as a standard Android VpnService, plus a high-level Java API (ServiceManagement) for binding, subscribing, and driving the VPN from your app process.
- Repository:
com.github.shiroedev2024:leaf-sdk-android(sample consumer) - Min SDK: 21 (Android 5.0)
- Target / Compile SDK: 36
- ABIs:
arm64-v8a,armeabi-v7a,x86_64,x86
Batteries-included features (zero extra code required on your side):
- Always-on VPN + kill switch —
SUPPORTS_ALWAYS_ON=truemeta-data merged from the library manifest. - Network-switch auto-reconnect — Wi-Fi ↔ cellular transitions trigger
setUnderlyingNetworks+ nativenotifyNetworkChanges()so outbounds rebind without a config reload. - Metered tagging (
setMetered(false)on API 29+), boot survival (START_STICKY),onRevoke()cleanup. - Foreground service with a customisable notification (
LeafConfig). - Secure inter-process event broadcasts (signature-permission protected).
1. Repository Setup¶
Add the private Maven repository to settings.gradle.kts (or settings.gradle):
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven {
url = uri("https://repo.surfshield.org/repository/maven-releases/")
}
}
}
Add the dependency in your app module:
Replace
1.26.3with the current library version. Every SDK bump must be paired with bumping the version you pass toupdateAssets()so the integrity check succeeds (see Architecture — Asset Versioning Rule).
2. Manifest Requirements¶
The library manifest already declares LeafVPNService with the correct BIND_VPN_SERVICE permission, foreground service type and :VPNService process. Your app still needs to request the matching permissions:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required to receive secure broadcasts from the VPN service process -->
<uses-permission android:name="com.github.shiroedev2024.leaf.android.VPN_EVENT_BROADCAST" />
If you want to override the notification metadata (title, content, stop-button, icons, click intent) instead of the defaults shipped by the library, build a LeafConfig and push it through ServiceManagement.setConfig(...) (see LeafConfig).
The service runs in its own
:VPNServiceprocess. YourApplication.onCreate()must distinguish between the main process and the VPN process and avoid binding the service from both. The sample project uses a helperisMainProcess(context)for this.
3. ServiceManagement — the public entry point¶
ServiceManagement is a thread-safe singleton that hides the AIDL ILeafServiceInterface boilerplate, posts all callbacks on the main thread, and watches the remote binder for death.
val sm = ServiceManagement.getInstance()
sm.addServiceListener(object : ServiceListener {
override fun onConnect() { /* binder ready, you can call sm.* */ }
override fun onDisconnect() { /* remote died / was unbound */ }
override fun onError(e: Throwable) { /* AIDL error */ }
})
sm.addLeafListener(object : LeafListener {
override fun onStarting() = updateUiLoading()
override fun onStartSuccess() = updateUiConnected()
override fun onStartFailed(msg: String?) = showError(msg)
override fun onReloadSuccess() = Unit
override fun onReloadFailed(msg: String?) = showError(msg)
override fun onStopSuccess() = updateUiDisconnected()
override fun onStopFailed(msg: String?) = showError(msg)
})
// Typically in BaseActivity.onStart() / onStop():
sm.bindService(context)
sm.unbindService(context)
API Summary¶
| Method | Thread | Purpose |
|---|---|---|
bindService(ctx) / unbindService(ctx) |
any | Bind/unbind to the :VPNService process. Ref-counted. |
isServiceDead() |
any | true when the remote binder is null or dead. |
getVersion() |
blocks | Leaf core version string. |
getPreferences() |
blocks, throws LeafException |
Read persistent LeafPreferences. |
setPreferences(UpdateLeafPreferences) |
blocks, throws | Persist prefs; reloads only happen when you explicitly call reloadLeaf(). |
setConfig(LeafConfig) |
blocks | Override session name / notification presentation. |
startLeaf() |
async | Start the VPN. Needs VpnService.prepare approval first. |
stopLeaf() |
async | Stop the VPN. |
reloadLeaf() |
async | Re-render the config and hot-swap it. |
isLeafRunning() |
blocks | true if the tunnel is up. |
tryAutoUpdate() |
async via autoUpdateSubscription() |
Refresh the subscription using last known flags. |
updateSubscription(clientId, cb) |
async | Refresh using automatic TLS / fragment / speedtest / try-all. |
updateSubscription(tls, fragment, clientId, enableSpeedtest, enableTryAll, cb) |
async | All five -1 / 0 / 1 flags. |
updateCustomSubscription(config, cb) |
async | Load a hand-crafted Leaf .conf. |
importOfflineSubscription(path, passphrase, keyIds, verifyingKeys, cb) |
async | Load an offline .leafsub bundle. |
updateAssets(major, minor, patch, cb) |
async | Pull geoip.dat / geosite.dat keyed on app version. |
verifyFileIntegrity() |
blocks, throws | SHA-256 check across the asset bundle; call before startLeaf(). |
Under the hood every async method runs on an Executors.newFixedThreadPool(4) and funnels exceptions through a LeafException shim.
4. End-to-end Flow¶
The following Kotlin snippet mirrors what the open-source leaf-android sample does:
class VpnController(private val activity: ComponentActivity) {
private val sm = ServiceManagement.getInstance()
private val vpnConsent = activity.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) startAfterConsent()
}
fun connect(clientId: String) {
// 1. Make sure the binder is alive
if (sm.isServiceDead) { sm.bindService(activity); return }
// 2. Pull config from the panel
sm.updateSubscription(clientId, object : SubscriptionCallback {
override fun onSubscriptionUpdating() = Unit
override fun onSubscriptionSuccess() {
// 3. Keep geo assets fresh
val (ma, mi, pa) = BuildConfig.VERSION_NAME.split(".")
.map { it.toInt() }.let { Triple(it[0], it[1], it[2]) }
sm.updateAssets(ma, mi, pa, object : AssetsCallback {
override fun onUpdateSuccess() = requestVpnConsent()
override fun onUpdateFailed(e: LeafException?) = showError(e?.message)
})
}
override fun onSubscriptionFailure(e: LeafException) = showError(e.message)
})
}
private fun requestVpnConsent() {
val intent = LeafVPNService.prepare(activity)
if (intent != null) vpnConsent.launch(intent) else startAfterConsent()
}
private fun startAfterConsent() {
try {
sm.verifyFileIntegrity() // blocks briefly
sm.startLeaf() // async — observe via LeafListener
} catch (e: LeafException) {
showError(e.message)
}
}
fun disconnect() = sm.stopLeaf()
}
5. LeafConfig¶
LeafConfig customizes how the :VPNService process presents itself. Pass it once after binding:
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val uri = intent.toUri(Intent.URI_INTENT_SCHEME)
val config = LeafConfig.Builder()
.setSessionName("Your VPN")
.setNotificationDetails(
getString(R.string.notification_title),
getString(R.string.notification_content),
getString(R.string.notification_stop_button),
)
.setSmallIcon(R.drawable.ic_notification)
.setStopIcon(R.drawable.ic_stop)
.setActivityIntentUri(uri) // tapping the notification returns to your Activity
.build()
ServiceManagement.getInstance().setConfig(config)
All setters except setSessionName are optional; omitted fields fall back to the library defaults.
6. Subscription Import Options¶
Online subscription (default)¶
Offline .leafsub bundle¶
sm.importOfflineSubscription(
path = "/storage/emulated/0/Download/user.leafsub",
passphrase = null, // or the bundle's passphrase
keyIds = listOf("k1"),
verifyingKeys = listOf("FjBD6zMrxVtHpWqzqsuFmT8uB7RZKmMuO94nT0N5LKo"),
subscriptionCallback = callback,
)
The sample project wires this to a content:// URI arriving via ACTION_VIEW with .leafsub mime type.
Custom hand-edited config¶
7. Preferences¶
All persistent knobs live in LeafPreferences. See the Preferences Reference for the full field list and defaults.
val prefs = sm.preferences
val update = UpdateLeafPreferences().apply {
isEnableIpv6 = true
logLevel = LogLevel.INFO
apiPort = 10001
isBypassLan = true
isFakeIp = true
bypassGeoipList = listOf("cn", "ir", "ru")
}
sm.setPreferences(update)
if (sm.isLeafRunning) sm.reloadLeaf()
8. Reading Live Stats¶
ApiClient is a thin OkHttp wrapper for the local Runtime HTTP API. Use it on a background thread:
lifecycleScope.launch(Dispatchers.IO) {
val api = ApiClient("http://127.0.0.1:${sm.preferences.apiPort}")
val usage: UsageReply = api.getUsage("default")
val list: OutboundListReply = api.outboundList
val health: OutboundHealthReply = api.getOutboundHealth("OUT")
withContext(Dispatchers.Main) {
// update UI
}
}
Full method list:
getUsage(tag)— total bytes sent/received since the core started.getStats()— per-connection stats.getSelectOutboundItems(tag)/getCurrentSelectOutboundItem(tag)/setSelectOutboundItem(tag, item)getOutboundList()— select/failover groups.getLastPeerActive(tag)/getSinceLastPeerActive(tag)getOutboundHealth(tag)— TCP / UDP ping in ms.getFailoverStatus(tag)/forceFailoverHealthCheck(tag)/forceFailoverHealthCheckAll()checkConnectivity()getLogs(limit, offset)/clearLogs()reloadRuntime()/shutdownRuntime()
9. Listening for Events in a Secondary Process¶
The SDK can broadcast lifecycle events to a BroadcastReceiver in your app so UI or widgets survive the :VPNService boundary. Declare it with the signature-protected permission:
<receiver
android:name=".VpnEventReceiver"
android:enabled="true"
android:exported="false"
android:permission="com.github.shiroedev2024.leaf.android.VPN_EVENT_BROADCAST">
<intent-filter>
<action android:name="com.github.shiroedev2024.leaf.VPN_EVENT" />
</intent-filter>
</receiver>
Implementation hint (see the sample):
class VpnEventReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val eventType = intent.getStringExtra("eventType") // see EventType enum
val data = intent.getStringExtra("data")
val timestamp = intent.getLongExtra("timestamp", 0L)
// update widget / tile here
}
}
Broadcast eventType values¶
Values come from the SDK's EventType enum. The receiver gets the lowercase string form; use EventType.fromValue(eventType) to recover the typed enum.
eventType |
When it fires |
|---|---|
starting |
User pressed connect; VpnService.establish not yet called. |
start_success |
TUN device is up and Leaf is running. |
start_failed |
Reason string is in data. |
reload_success |
Config was rebuilt and re-applied without dropping the TUN. |
reload_failed |
Reload was attempted but Leaf refused. |
stop_success |
Clean shutdown. |
stop_failed |
Forced shutdown encountered an error. |
The current SDK release keeps
shouldBroadcastEvent()returningfalseby default; the infrastructure is wired up so future releases (or patched builds) can re-enable outward broadcasts without a client-side change. Inside your app process the same events are delivered throughLeafListener— that is the supported API today.
10. Always-on VPN & Kill Switch¶
The SDK ships with Android's Always-on VPN and Block connections without VPN (kill switch) enabled out of the box — you do not need any extra Kotlin code or permissions. The library's AndroidManifest.xml declares:
<service
android:name=".LeafVPNService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse"
android:process=":VPNService">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true" />
</service>
The meta-data SUPPORTS_ALWAYS_ON=true advertises the service to the system VPN picker. The flag is merged into your final APK automatically via manifest merger — nothing to add on your side.
How the SDK handles the Always-on start signal¶
When the user enables Always-on VPN for your app in Settings → Network & Internet → VPN → Your VPN → Always-on, Android sends Intent { action = "android.net.VpnService" } to LeafVPNService at:
- Boot (if user allowed auto-connect on boot).
- When network connectivity comes back after being lost.
- If the VPN process is killed (paired with
START_STICKY).
LeafVPNService.onStartCommand handles it by spawning a daemon thread that calls the internal start() (which mirrors what ServiceManagement.startLeaf() does from your app):
} else if ("android.net.VpnService".equals(action)) {
Thread alwaysOnVpnThread = new Thread(this::start);
alwaysOnVpnThread.setDaemon(true);
alwaysOnVpnThread.start();
return START_STICKY;
}
How "Block connections without VPN" (kill switch) works¶
The kill-switch is an OS-level feature; Android itself drops every non-VPN packet the moment the user flips Block connections without VPN on. Your app and the SDK do not need to do anything. The VpnService superclass exposes two helpers you can expose in UI if you want to show the current state:
val service: LeafVPNService = /* via your own binder or ServiceManagement */
val alwaysOn = service.isAlwaysOn // added in API 29 (Android 10)
val lockdown = service.isLockdownEnabled // added in API 29 (Android 10)
ServiceManagementitself does not surface these booleans — query them from inside the:VPNServiceprocess if you need to react to them (e.g. hide the "Stop VPN" button whenlockdown == trueso users understand why the VPN can't be disabled from your app).
UX recommendations for your app¶
- Onboarding — after the first successful connection, prompt the user to enable Always-on VPN + lockdown in system settings with a one-tap deep link:
- Stop-button behaviour — when
isLockdownEnabled() == true, stopping Leaf will still leave the OS kill-switch active; warn the user they will lose connectivity until they disable lockdown or re-start the VPN. - Boot auto-connect — subscribe to
BOOT_COMPLETEDis not needed. Android itself restarts the VPN when Always-on is on. - Lockdown + test VPN — during QA, toggle airplane mode ON then OFF to simulate the kill-switch triggering; verify that Leaf reconnects via Network-switch auto-reconnect below.
Verifying kill-switch in production¶
adb shell dumpsys vpn— look foralwaysOn=true lockdown=true.- Force-stop your app (
adb shell am force-stop com.your.package) with Always-on on; Android should relaunch the:VPNServiceprocess within ~10 s. - Disable Wi-Fi + cellular; on re-enable, the VPN should reconnect without user intervention.
11. Network-switch auto-reconnect¶
Switching Wi-Fi ↔ cellular, or losing and regaining connectivity, is a common failure mode for VPN apps. The SDK registers a ConnectivityManager.NetworkCallback on API 28+ and drives three effects so the tunnel survives network changes without user interaction:
- Underlying-network pinning —
setUnderlyingNetworks(new Network[] { current })keeps your metered-vs-unmetered bookkeeping accurate and ensures Android routes system-level checks through the right physical link. - Metered tagging —
VpnService.Builder.setMetered(false)(API 29+) so apps using your VPN aren't treated as roaming. - Core notification — the SDK calls the native
notifyNetworkChanges()method, which triggers the Leaf core to re-fetch the default interface, re-bind outbounds to it, reset DNS servers, and trigger aforce_health_check_all_failoversacross every failover group. No config reload, no TUN re-creation.
You get all of this by default — no manifest changes or extra Kotlin required. The relevant code lives inside LeafVPNService:
connectivity.registerNetworkCallback(
new NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build(),
new ConnectivityManager.NetworkCallback() {
public void onAvailable(Network network) {
if (!network.equals(currentNetwork)) {
currentNetwork = network;
setUnderlyingNetworks(new Network[] { network });
}
}
public void onLost(Network network) {
if (network.equals(currentNetwork)) {
currentNetwork = null;
setUnderlyingNetworks(null);
notifyNetworkChanges();
}
}
});
TUN settings shipped by the SDK¶
For reference (you cannot change these from client code; they are hard-coded inside LeafVPNService):
| Setting | Value | Notes |
|---|---|---|
| MTU | 1450 | Fits inside a 1500-byte Ethernet frame even after Trojan/TLS overhead. |
| IPv4 address | 172.31.0.2/16 |
Private range; LAN apps remain on their own subnets. |
| IPv4 gateway | 172.31.0.1 |
Leaf sinks TUN packets to itself. |
| IPv6 address | fd00:1234:5678::2/48 |
ULA, only used when enable_ipv6 = true. |
| IPv6 gateway | fd00:1234:5678::1 |
ULA gateway. |
| DNS servers added by the VPN builder | 1.1.1.1, 8.8.8.8 |
On top of this, Leaf's own internal DNS server runs if internal_dns_server = true. |
| Fake-IP route | 198.18.0.0/16 |
Always routed into the TUN so the fake-IP DNS pool works. |
| Bypass-LAN | Public-range routes only | Toggle via LeafPreferences.bypass_lan; bypass_lan_in_core moves the split from TUN to the router inside Leaf. |
When to reload manually¶
You only need to call reloadLeaf() after changing LeafPreferences fields that affect routing (bypass_lan, fake_ip, internal_dns_server, GeoIP/Geosite lists). Physical-network changes are handled transparently by the SDK.
12. Quick Settings Tile (Optional)¶
For a one-tap toggle from the Quick Settings panel declare a TileService:
<service
android:name=".LeafVPNTileService"
android:label="Your VPN"
android:icon="@drawable/round_vpn_lock_24"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:exported="true">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVITY"
android:resource="@xml/leaf_vpn_tile" />
</service>
Reference implementation: LeafVPNTileService.kt.
13. Deep Links / Auto-Install¶
To let third-party portals push a clientId straight into your app, register the leafvpn://install scheme:
<activity
android:name=".activity.AutoProfileActivity"
android:exported="true">
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="install" android:scheme="leafvpn" />
</intent-filter>
</activity>
The sample's AutoProfileActivity extracts the query parameter, calls sm.updateSubscription(clientId, ...), and returns to the main screen.
14. Error Handling¶
- All SDK calls that can fail synchronously throw
LeafException. - Async operations deliver errors through their typed callback (
LeafListener.onStartFailed,SubscriptionCallback.onSubscriptionFailure,AssetsCallback.onUpdateFailed). ServiceManagement.isServiceDead()is the canonical way to know whether you may invoke remote methods.
15. Putting it all together¶
See the open-source reference implementation: github.com/shiroedev2024/leaf-android — in particular MainApplication.kt, BaseActivity.kt, and LeafViewModel.kt. The Sample Projects page walks through it.