DEV Community

Cover image for Building a Secure VPN in Android with WireGuard: A Complete Guide
Ankush Lokhande
Ankush Lokhande

Posted on

3 2 2 2 2

Building a Secure VPN in Android with WireGuard: A Complete Guide

👋 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:

If you're already familiar with VPNs or have already read the blog, let's get started!!!

Table Of Contents


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:

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")  
}
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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?
)
Enter fullscreen mode Exit fullscreen mode
  • 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  
}
Enter fullscreen mode Exit fullscreen mode
  • 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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}")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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}")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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
    }
}
Enter fullscreen mode Exit fullscreen mode

# 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.

Thanks for reading

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.

Happy coding!👨‍💻

Heroku

Amplify your impact where it matters most — building exceptional apps.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

Jetbrains Survey

Calling all developers!

Participate in the Developer Ecosystem Survey 2025 and get the chance to win a MacBook Pro, an iPhone 16, or other exciting prizes. Contribute to our research on the development landscape.

Take the survey

AWS Security LIVE!

Hosted by security experts, AWS Security LIVE! showcases AWS Partners tackling real-world security challenges. Join live and get your security questions answered.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️