How to Make a VPN App
How to Vibe Code a Native Android VPN Client App
Stay in your creative flow. Direct AI models with high-level privacy and network security intent, stitch together a Jetpack Compose VPN shield dashboard with multi-region server ping selection, protocol switching, and Android VpnService wiring in under 30 minutes.
Core Stack
Compose + VpnService
Protocols
WireGuard / IKEv2
AI Partner
Claude / Gemini
Target Time
25β35 Mins
The Vibe Coding Mobile Philosophy
Traditional VPN client development on Android meant digging through sparse android.net.VpnService documentation, manually configuring TUN file descriptors, and building clunky legacy XML toggles.
Vibe coding lets you architect a high-trust cybersecurity UI firstβcomplete with 3-state connection rings, live throughput telemetry, server latency sorting, and Kill Switch controlsβand then bind the system VpnService.Builder via targeted AI prompts.
Old-School Mobile Stress
- Forgetting the
BIND_VPN_SERVICEmanifest permission and debugging silent failures. - Not handling the system
VpnService.prepare(context)user consent dialog properly. - Static UI screens that give zero feedback while a tunnel is handshaking.
- Hardcoding a single server IP with no protocol or split-tunneling options.
The Vibe Coder Way
- Prompt AI for a 3-state connection machine (
DISCONNECTED→HANDSHAKING→PROTECTED). - Render live encrypted download/upload telemetry and masked virtual IP badges.
- Switch between global server nodes sorted by millisecond ping latency.
- Generate the complete
VpnServiceTUN interface and notification boilerplate on demand.
The 4-Step Blueprint
From a fresh Android Studio project to a high-trust VPN client and system tunnel service.
Empty Compose Setup
Create a new Empty Activity (Compose) project in Android Studio named VibeGuardVPN. You can build and test the entire interactive shield dashboard in pure Compose first.
enum class TunnelState { DISCONNECTED, HANDSHAKING, PROTECTED } so every color and label reacts automatically.
Configure Your Shield
Pick your security aesthetic, protocol options, and privacy safeguards in the configurator below, then paste the prompt into Claude or Gemini.
HANDSHAKING state when switching server locations while already connected.
Single-File Drop-In
Paste the generated code into MainActivity.kt to test the 1-tap connect ring, masked virtual IP display, throughput counters, and server list.
FontFamily.Monospace for IP addresses, session timers, and ping latencies to give your UI an authentic cybersecurity feel.
Bind Android VpnService
Use the Prompt Vault below to generate the VpnService class, AndroidManifest.xml declaration, and per-app split tunneling.
Vibe Prompt Configurator
Choose your cybersecurity aesthetic, tunnel protocol options, and privacy safeguards. Copy the generated prompt directly into Claude, Cursor, or Gemini.
Essential Elements of a VPN App
A Virtual Private Network (VPN) app protects user traffic on public Wi-Fi and untrusted networks. Make sure your client covers these 6 core pillars:
-
Global Server Selector & Ping Latency
Let users switch between international server locations (Zurich, Tokyo, New York, Frankfurt) with clear virtual IPs and millisecond latency indicators.
-
1-Tap Client GUI & Status Readout
Provide an unmistakable central connect shield that transitions from Unprotected Red to Handshaking Amber to Encrypted Emerald Green.
-
Strong Encryption & Modern Protocols
Support fast, modern tunneling protocols like WireGuard (ChaCha20-Poly1305) alongside OpenVPN (UDP/TCP) and IKEv2.
-
Kill Switch Protection
Prevent accidental IP exposure if the VPN connection drops by blocking unencrypted traffic or engaging Android's Always-On VPN lockdown mode.
-
DNS Leak Protection
Route all DNS queries inside the encrypted TUN interface using privacy-first DNS resolvers so ISPs cannot log domain lookups.
-
Strict Zero-Log Privacy Architecture
Never log browsing history, traffic destination, or DNS queries; keep local client state strictly limited to server and protocol preferences.
The "Golden" Single-File Compose Template
Replace the contents of your MainActivity.kt with this to run a working VPN Client Dashboard right now.
package com.example.vibevpn
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
enum class TunnelState { DISCONNECTED, HANDSHAKING, PROTECTED }
data class VpnServerNode(
val id: Int,
val countryFlag: String,
val city: String,
val virtualIp: String,
val pingMs: Int
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(
colorScheme = darkColorScheme(
primary = Color(0xFF10B981),
background = Color(0xFF060B11),
surface = Color(0xFF111A27)
)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
VpnShieldDashboard()
}
}
}
}
}
@Composable
fun VpnShieldDashboard() {
val servers = remember {
listOf(
VpnServerNode(1, "π¨π", "Zurich, Switzerland", "185.212.14.89", 18),
VpnServerNode(2, "π―π΅", "Tokyo, Japan", "103.152.220.41", 34),
VpnServerNode(3, "πΊπΈ", "New York, USA", "198.51.100.72", 26),
VpnServerNode(4, "π©πͺ", "Frankfurt, Germany", "192.0.2.118", 21)
)
}
var selectedServer by remember { mutableStateOf(servers.first()) }
var tunnelState by remember { mutableStateOf(TunnelState.DISCONNECTED) }
var selectedProtocol by remember { mutableStateOf("WireGuard") }
var killSwitchOn by remember { mutableStateOf(true) }
var connectedSeconds by remember { mutableIntStateOf(0) }
var downMbps by remember { mutableFloatStateOf(0f) }
var upMbps by remember { mutableFloatStateOf(0f) }
LaunchedEffect(tunnelState) {
if (tunnelState == TunnelState.HANDSHAKING) {
delay(900)
tunnelState = TunnelState.PROTECTED
}
while (tunnelState == TunnelState.PROTECTED) {
delay(1000)
connectedSeconds += 1
downMbps = 42.4f + (connectedSeconds % 7) * 3.1f
upMbps = 18.2f + (connectedSeconds % 5) * 1.4f
}
if (tunnelState == TunnelState.DISCONNECTED) {
connectedSeconds = 0
downMbps = 0f
upMbps = 0f
}
}
val statusColor = when (tunnelState) {
TunnelState.PROTECTED -> Color(0xFF10B981)
TunnelState.HANDSHAKING -> Color(0xFFF59E0B)
TunnelState.DISCONNECTED -> Color(0xFFF43F5E)
}
val mins = connectedSeconds / 60
val secs = connectedSeconds % 60
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Top Header & Encryption Status Pill
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("VibeGuard VPN", fontSize = 22.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text("Zero-Log RAM Tunnel β’ ChaCha20", fontSize = 12.sp, color = Color(0xFF94A3B8))
}
Surface(color = statusColor.copy(alpha = 0.16f), shape = RoundedCornerShape(20.dp)) {
Text(
text = tunnelState.name,
color = statusColor,
fontWeight = FontWeight.ExtraBold,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Central 1-Tap Shield Connect Button
Box(
modifier = Modifier
.size(156.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(statusColor.copy(alpha = 0.32f), Color(0xFF111A27))
)
)
.border(width = 3.dp, color = statusColor, shape = CircleShape)
.clickable {
tunnelState = when (tunnelState) {
TunnelState.DISCONNECTED -> TunnelState.HANDSHAKING
else -> TunnelState.DISCONNECTED
}
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = when (tunnelState) {
TunnelState.PROTECTED -> "π‘οΈ ON"
TunnelState.HANDSHAKING -> "β³..."
TunnelState.DISCONNECTED -> "β» TAP"
},
fontSize = 24.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (tunnelState == TunnelState.PROTECTED) String.format("%02d:%02d", mins, secs) else "Tap to Connect",
fontSize = 12.sp,
color = Color(0xFFCBD5E1),
fontFamily = FontFamily.Monospace
)
}
}
Spacer(modifier = Modifier.height(14.dp))
// Masked IP & Live Throughput Telemetry Card
Card(
shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("VIRTUAL IP", fontSize = 10.sp, color = Color(0xFF94A3B8), fontWeight = FontWeight.Bold)
Text(
text = if (tunnelState == TunnelState.PROTECTED) selectedServer.virtualIp else "Unmasked (ISP)",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily.Monospace
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("DOWNLOAD", fontSize = 10.sp, color = Color(0xFF94A3B8), fontWeight = FontWeight.Bold)
Text("${String.format("%.1f", downMbps)} Mbps", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF34D399), fontFamily = FontFamily.Monospace)
}
Column(horizontalAlignment = Alignment.End) {
Text("UPLOAD", fontSize = 10.sp, color = Color(0xFF94A3B8), fontWeight = FontWeight.Bold)
Text("${String.format("%.1f", upMbps)} Mbps", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = Color(0xFF38BDF8), fontFamily = FontFamily.Monospace)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Protocol & Safeguard Chips
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("WireGuard", "OpenVPN", "IKEv2").forEach { proto ->
FilterChip(
selected = selectedProtocol == proto,
onClick = { selectedProtocol = proto },
label = { Text(proto, fontSize = 11.sp) }
)
}
}
FilterChip(
selected = killSwitchOn,
onClick = { killSwitchOn = !killSwitchOn },
label = { Text(if (killSwitchOn) "Kill Switch ON" else "Kill Switch", fontSize = 11.sp) }
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "SELECT SERVER LOCATION",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF94A3B8),
modifier = Modifier.align(Alignment.Start).padding(bottom = 6.dp)
)
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
items(servers, key = { it.id }) { node ->
val isSelected = node.id == selectedServer.id
Card(
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) Color(0xFF1E293B) else MaterialTheme.colorScheme.surface
),
modifier = Modifier
.fillMaxWidth()
.border(
width = if (isSelected) 1.5.dp else 0.dp,
color = if (isSelected) statusColor else Color.Transparent,
shape = RoundedCornerShape(14.dp)
)
.clickable {
selectedServer = node
if (tunnelState == TunnelState.PROTECTED) {
tunnelState = TunnelState.HANDSHAKING
}
}
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text(node.countryFlag, fontSize = 20.sp)
Column {
Text(node.city, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text("IP: ${node.virtualIp} β’ $selectedProtocol", fontSize = 11.sp, color = Color(0xFF94A3B8), fontFamily = FontFamily.Monospace)
}
}
Surface(color = Color(0xFF064E3B), shape = RoundedCornerShape(8.dp)) {
Text(
text = "${node.pingMs} ms",
fontSize = 11.sp,
color = Color(0xFF34D399),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
}
}
}
}
}
How to Debug Android VpnService Quirks
Avoid silent TUN failures and routing loops with these three Android system fixes:
Builder.establish() Returns Null
Android requires explicit user consent before opening a VPN tunnel. Call VpnService.prepare(context) first:
"Launch the Intent returned by VpnService.prepare(context) via rememberLauncherForActivityResult(StartActivityForResult())."
VPN Loops Its Own Socket Traffic
Exclude your own VPN package or protect the outbound tunnel socket so it bypasses the TUN interface: builder.addDisallowedApplication(context.packageName)
Android 14 Stops Tunnel in Background
Promote your VpnService to a foreground service with a persistent notification and manifest service permission:
android:permission="android.permission.BIND_VPN_SERVICE"
The Vibe Prompt Vault
Copy-paste prompts to generate the underlying Android VpnService class, split tunneling sheet, and real TrafficStats sparklines.
βοΈ Prompt: Generate Complete Android VpnService & Manifest Setup
"Write a complete Android VpnService subclass named VibeTunnelService along with the required AndroidManifest.xml service declaration (including android.permission.BIND_VPN_SERVICE and android.net.VpnService intent-filter). Configure VpnService.Builder with addAddress('10.8.0.2', 24), addDnsServer('1.1.1.1'), addRoute('0.0.0.0', 0), and wire VpnService.prepare(context) into VpnShieldDashboard."
π± Prompt: Add Per-App Split Tunneling Bottom Sheet
"Add a 'Split Tunneling' button to VpnShieldDashboard that opens a Material 3 ModalBottomSheet listing installed/common apps with checkboxes. Pass the excluded package names to VpnService.Builder.addDisallowedApplication(pkg) when establishing the tunnel."
π Prompt: Add Real-Time TrafficStats Sparkline & Auto-Shield on Open Wi-Fi
"Add a Jetpack Compose Canvas sparkline graph inside the telemetry card that plots real device network throughput sampled every second via android.net.TrafficStats.getTotalRxBytes() and getTotalTxBytes(), plus an 'Auto-Shield on Open Wi-Fi' toggle."