👋 Hey all,
In this guide, we’ll learn how to integrate WireGuard into your Android app using Jetpack Compose and Kotlin. There is a wide range of VPN protocols available in the market to integrate with Android, and we selected WireGuard (a modern VPN protocol) known for its simplicity, speed, and strong security features. By the end of this tutorial, you’ll have a working WireGuard VPN implementation that you can integrate into your application.
If you are new to VPNs or unfamiliar with related terms, please read this blog before proceeding:

VPN Fundamentals for Mobile Developers: Everything You Need to Know Before Integrating WireGuard
Ankush Lokhande ・ Apr 2
If you're already familiar with VPNs or have already read the blog, let's get started!!!
Table Of Contents
- Introduction
- Essential Prerequisites for Setting up your project
- Test how VPN works on devices
- Setting up the project
- Let's Wrap
Before implementing the VPN, I explored various VPN protocols and found WireGuard the most suitable choice for mobile development. Its simplicity, speed, and strong security features stand out from traditional protocols like OpenVPN and IPSec. WireGuard is even better because many people support it, and there are lots of free code examples online. This makes it easier to add WireGuard to an Android app.
# Essential Prerequisites for Setting up your project
Before beginning development, it is essential to ensure that you have all the necessary tools installed and that they are up to date.
Make sure you have the following ready before starting:
Android Studio – Install the latest stable version.
VPN Server – You need a server to generate the wg.conf
file for WireGuard.
# Test how VPN works on devices
To test how the VPN is working on your device (laptop, mobile, etc.), you can download available apps from the Store. Here are some of the best apps:
For Android: WireGuard, WG Tunnel, OpenVPN Connect
For Mac: WireGuard
You can also download the platform-specific app from the official website of WireGuard: Download it from here
You need a config file (eg. client.conf
) to run the VPN on the device. Contact your server provider guy or create by own.
Here are some sample files to show how config file look like: client.conf
# Setting up the project
Are you ready to dive into the coding part? Let's get started!
Add dependency to the project
dependencies {
// Add WireGuard dependency
implementation("com.wireguard.android:tunnel:1.0.20210211")
// Add desugaring library for Java 8+ API support
implementation("com.android.tools:desugar_jdk_libs:2.1.5")
}
- Check the latest tunnel library for WireGuard for Android: Maven repository
- Check the latest desugaring library: Maven repository
Configuring VPN Service in AndroidManifest.xml
Before using the WireGuard module, update your AndroidManifest.xml
file.
Add Required Permissions:
These permissions allow the app to access the internet and check network status:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Register VPN Service:
Add the following inside the <application>
tag to enable the WireGuard VPN service:
<application>
<service
android:name="com.wireguard.android.backend.GoBackend$VpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
Dive into the coding logic
- Create a class
WireGuardTunnel.kt
in your project. This class acts as a wrapper for a WireGuard tunnel, allowing you to track and manage its state efficiently.
import com.wireguard.android.backend.Tunnel
typealias StateChangeCallback = (Tunnel.State) -> Unit
class WireGuardTunnel(
private var name: String,
private val onStateChanged: StateChangeCallback? = null
) : Tunnel {
private var state: Tunnel.State = Tunnel.State.DOWN
override fun getName() = name
override fun onStateChange(newState: Tunnel.State) {
state = newState
onStateChanged?.invoke(newState)
}
fun getState(): Tunnel.State = state
}
- To manage VPN server details, add the following
ServerInfo
data class to your project:
import com.google.gson.annotations.SerializedName
data class ServerInfo(
// Interface details
@SerializedName("address") val interfaceAddress: String?,
@SerializedName("dns") val interfaceDns: String?,
@SerializedName("private_key") val interfacePrivateKey: String?,
// Peer details
@SerializedName("public_key") val peerPublicKey: String?,
@SerializedName("preshared_key") val peerPresharedKey: String?,
@SerializedName("allowed_ips") val peerAllowedIPs: String?,
@SerializedName("endpoint") val peerEndpoint: String?,
@SerializedName("persistent_keep_alive") val peerPersistentKeepalive: String?
)
- To track different states of the VPN connection, add the following VPNStatus enum class:
enum class VPNStatus {
PREPARE, // VPN is getting ready
CONNECTING, // Establishing a connection
CONNECTED, // VPN is active and running
DISCONNECTING, // Disconnecting from the VPN
DISCONNECTED, // VPN is not connected
NO_CONNECTION, // No available VPN connection
REFRESHING // Refreshing VPN status
}
- To handle VPN operations, add the following
WireguardManager
class. We'll expand it with more methods later.
import android.app.Activity
import android.content.Context
import com.wireguard.android.backend.Backend
import kotlinx.coroutines.*
class WireguardManager(private val context: Context, private val activity: Activity?) {
private val scope = CoroutineScope(Job() + Dispatchers.Main.immediate)
private var backend: Backend? = null
private var tunnelName: String = "wg_default"
private var config: Config? = null
private var tunnel: WireGuardTunnel? = null
private val futureBackend = CompletableDeferred<Backend>()
private val TAG = "WireguardManager"
companion object {
private var state: VPNStatus = VPNStatus.NO_CONNECTION
}
}
- Implement the initialize method in the
WireguardManager
class:
class WireguardManager(private val context: Context, private val activity: Activity?) {
// Remaining code
init {
scope.launch(Dispatchers.IO) {
try {
cachedTunnelData = SharedPreferenceHelper.getVpnData()
backend = GoBackend(context)
futureBackend.complete(backend!!)
activity?.let { GoBackend.VpnService.prepare(it) }
} catch (e: Throwable) {
Log.e(TAG, "ERROR: Exception during WireguardManager initialization: ${e.localizedMessage}")
Log.e(TAG, Log.getStackTraceString(e))
}
}
}
}
- Add support methods:
class WireguardManager(private val context: Context, private val activity: Activity?) {
// Remaining code
/**
* Generates WireGuard configuration from the provided ServerInfo.
* This creates a wg-quick compatible configuration for the VPN tunnel.
*/
private fun getConfigData(tunnelData: ServerInfo): Config {
val wgQuickConfig = """
[Interface]
Address = ${tunnelData.interfaceAddress ?: ""}
DNS = ${tunnelData.interfaceDns ?: ""}
PrivateKey = ${tunnelData.interfacePrivateKey ?: ""}
[Peer]
PublicKey = ${tunnelData.peerPublicKey ?: ""}
PresharedKey = ${tunnelData.peerPresharedKey ?: ""}
AllowedIPs = ${tunnelData.peerAllowedIPs ?: ""}
Endpoint = ${tunnelData.peerEndpoint ?: ""}
PersistentKeepalive = ${tunnelData.peerPersistentKeepalive ?: ""}
""".trimIndent()
val inputStream = ByteArrayInputStream(wgQuickConfig.toByteArray())
return Config.parse(inputStream)
}
/**
* Checks if any VPN connection is currently active on the device.
* Returns `true` if a VPN connection is detected, otherwise `false`.
*/
val isVpnActive: Boolean
get() {
return try {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
connectivityManager.activeNetwork ?: return false
} else {
// For Android < 6.0, use the old method
val networkInfo = connectivityManager.activeNetworkInfo
return networkInfo != null && networkInfo.type == ConnectivityManager.TYPE_VPN
}
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
// Check if any VPN is active
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
} catch (e: Exception) {
Log.e(TAG, "isVpnActive - ERROR - ${e.localizedMessage}", e)
false
}
}
/**
* Retrieves an existing WireGuard tunnel or creates a new one if none exists.
* The `callback` function listens for state changes in the tunnel.
*/
private fun getTunnel(name: String, callback: StateChangeCallback? = null): WireGuardTunnel {
if (tunnel == null) {
tunnel = WireGuardTunnel(name, callback)
}
return tunnel as WireGuardTunnel
}
}
- Add methods to update the status:
class WireguardManager(private val context: Context, private val activity: Activity?) {
// Remaining code
/**
* Updates the VPN status based on the tunnel's state.
* Runs on the main thread to ensure UI updates happen smoothly.
*/
private fun updateStageFromState(state: Tunnel.State) {
scope.launch(Dispatchers.Main) {
when (state) {
Tunnel.State.UP -> updateStage(VPNStatus.CONNECTED) // VPN is active
Tunnel.State.DOWN -> updateStage(VPNStatus.DISCONNECTED) // VPN is disconnected
else -> updateStage(VPNStatus.NO_CONNECTION) // No active VPN connection
}
}
}
/**
* Sets the VPN status and saves it in shared preferences.
* Ensures status updates run on the main thread.
*/
private fun updateStage(stage: VPNStatus?) {
scope.launch(Dispatchers.Main) {
val updatedStage = stage ?: VPNStatus.NO_CONNECTION
state = updatedStage
// Store VPN status in SharedPreferences if required
}
}
/**
* Returns the VPN status based on the tunnel's state.
*/
fun getStatus(): VPNStatus {
return state
}
}
- Add methods to start the VPN service:
class WireguardManager(private val context: Context, private val activity: Activity?) {
// Remaining code
/**
* Starts the VPN connection process.
* Initializes the tunnel and attempts to connect.
*/
fun start(tunnelData: ServerInfo) {
initialize(tunnelName)
connect(tunnelData)
}
/**
* Initializes the tunnel with a given name.
* Ensures the tunnel name is valid before proceeding.
*/
private fun initialize(localizedDescription: String) {
if (Tunnel.isNameInvalid(localizedDescription)) {
Log.e(TAG, "Invalid Tunnel Name: $localizedDescription")
return
}
tunnelName = localizedDescription
}
/**
* Connects to the VPN using the provided tunnel configuration.
* Updates VPN status at different stages of the connection process.
*/
private fun connect(tunnelData: ServerInfo) {
scope.launch(Dispatchers.IO) {
try {
updateStage(VPNStatus.PREPARE) // Preparing VPN connection
// Generate WireGuard configuration
config = getConfigData(tunnelData)
updateStage(VPNStatus.CONNECTING) // Attempting to connect
// Retrieve or create the WireGuard tunnel
val tunnel = getTunnel(tunnelName) { state ->
scope.launch {
Log.i(TAG, "onStateChange - $state")
updateStageFromState(state)
}
}
// Activate the VPN connection
futureBackend.await().setState(tunnel, Tunnel.State.UP, config)
scope.launch(Dispatchers.Main) {
updateStage(VPNStatus.CONNECTED) // VPN is successfully connected
// Store VPN status in SharedPreferences if required
}
Log.i(TAG, "Connect - success!")
} catch (e: Throwable) {
updateStage(VPNStatus.NO_CONNECTION) // Failed to establish a connection
Log.e(TAG, "Connect - ERROR - ${e.message}")
}
}
}
}
- Add methods to disconnect the VPN service:
class WireguardManager(private val context: Context, private val activity: Activity?) {
// Remaining code
/**
* Stops the VPN connection by calling the disconnect method.
*/
fun stop() {
disconnect()
}
/**
* Disconnects the active VPN tunnel.
* - If no tunnel is running, logs an error.
* - Updates VPN status before and after disconnection.
* - Handles reconnection if cached tunnel data exists.
*/
private fun disconnect() {
scope.launch(Dispatchers.IO) {
try {
// Check if any tunnel is currently running
if (futureBackend.await().runningTunnelNames.isEmpty()) {
throw Exception("Tunnel is not running")
}
updateStage(VPNStatus.DISCONNECTING)
// Retrieve the active tunnel and monitor state changes
val tunnel = getTunnel(tunnelName) { state ->
scope.launch {
Log.i(TAG, "onStateChange - $state")
updateStageFromState(state)
}
}
// Set the tunnel state to DOWN to disconnect
futureBackend.await().setState(tunnel, Tunnel.State.DOWN, config)
// Update VPN status and shared preferences on the main thread
scope.launch(Dispatchers.Main) {
updateStage(VPNStatus.DISCONNECTED)
// Store VPN status in SharedPreferences if required
}
Log.i(TAG, "Disconnect - success!")
} catch (e: Throwable) {
Log.e(TAG, "Disconnect - ERROR - ${e.message}")
}
}
}
}
- Now create a View Model class
VPNViewModel
, responsible for handling the VPN state, starting/stopping the connection, and monitoring the VPN status efficiently.
class VPNViewModel(application: Application) : AndroidViewModel(application) {
private val context by lazy { application.applicationContext }
private var wireguardManager: WireguardManager? = null
// VPN connection status as a StateFlow
private val _vpnState = MutableStateFlow(VPNStatus.NO_CONNECTION)
val vpnState: StateFlow<VPNStatus> = _vpnState.asStateFlow()
// Whether VPN is currently active
private val _isVpnActive = MutableStateFlow(false)
val isVpnActive: StateFlow<Boolean> = _isVpnActive.asStateFlow()
/**
* Initializes the WireGuard manager and starts monitoring VPN state changes.
* This method should be called when the ViewModel is created.
*/
fun initVPN(activity: Activity) {
wireguardManager = WireguardManager(context, activity)
// Observe VPN state changes in a coroutine
viewModelScope.launch {
while (isActive) {
delay(500) // Check every 500ms (half a second)
// Restore last known VPN state from SharedPreferences if needed and assign in fallback
_vpnState.value = wireguardManager?.getStatus()
?: VPNStatus.NO_CONNECTION
_isVpnActive.value = wireguardManager?.isVpnActive ?: false
}
}
}
/**
* Starts the VPN connection with the given tunnel data.
*/
fun startVPN(tunnelData: ServerInfo) {
viewModelScope.launch {
wireguardManager?.start(tunnelData)
}
}
/**
* Stops the active VPN connection.
*/
fun stopVPN() {
viewModelScope.launch {
wireguardManager?.stop()
}
}
}
- Managing the VPN Connections with ViewModel in Jetpack Compose
// Handling VPN Permission Request
val vpnPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
// Permission granted, now connect
viewModel.startVPN(tunnelData)
} else {
// Permission denied, show an error or update UI
}
}
// Starting a VPN Connection
coroutineScope.launch(Dispatchers.IO) {
val intent = GoBackend.VpnService.prepare(activity)
delay(100) // Small delay to prevent UI lag
if (intent != null) {
vpnPermissionLauncher.launch(intent) // Request permission if needed
} else {
viewModel.startVPN(tunnelData) // No permission needed, start directly
}
}
// Stopping a VPN Connection
coroutineScope.launch(Dispatchers.IO) {
delay(100) // Simulate network request
withContext(Dispatchers.Main) {
viewModel.stopVPN() // Stop the VPN connection
}
}
# Let's Wrap
Managing VPN connections in an Android app requires handling permissions, starting connections, and managing disconnections efficiently. Using Jetpack Compose and a ViewModel, we can make the app easy to use while keeping the code organized and easy to manage.
If you found this blog helpful or have any further questions, we would love to hear from you. Feel free to reach out and follow us on our social media platforms for more tips and tutorials on tech-oriented posts.
Top comments (0)