From fea2b31097af290f09c41c262ff0cec49dadaaa8 Mon Sep 17 00:00:00 2001
From: CyberRex <26585194+CyberRex0@users.noreply.github.com>
Date: Tue, 28 Apr 2026 22:15:45 +0900
Subject: [PATCH] Implement
---
AGENTS.md | 38 ++
app/build.gradle.kts | 7 +-
app/src/main/AndroidManifest.xml | 22 +-
.../jp/cyberrex/dummyvpn/DummyVpnService.kt | 186 +++++++
.../jp/cyberrex/dummyvpn/LocalSocksProxy.kt | 470 ++++++++++++++++++
.../java/jp/cyberrex/dummyvpn/MainActivity.kt | 218 +++++++-
.../jp/cyberrex/dummyvpn/Tun2SocksProcess.kt | 153 ++++++
.../java/jp/cyberrex/dummyvpn/VpnStatus.kt | 22 +
.../jp/cyberrex/dummyvpn/ui/theme/Color.kt | 12 +-
.../jp/cyberrex/dummyvpn/ui/theme/Theme.kt | 16 +-
app/src/main/jniLibs/README.md | 12 +
app/src/main/res/values/strings.xml | 7 +-
build.gradle.kts | 2 +-
13 files changed, 1134 insertions(+), 31 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
create mode 100644 app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
create mode 100644 app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
create mode 100644 app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
create mode 100644 app/src/main/jniLibs/README.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..eeee11d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,38 @@
+# 概要
+アプリ名: DummyVPN
+ターゲットAPIレベル: 33 (Android 13)
+言語: Kotlin
+UIフレームワーク: Jetpack Compose
+
+DummyVPNは、実際にリモートのVPNサーバーと接続するのではなく、アプリで受けたパケットをただインターネットに中継するVPNアプリである。
+そのためWi-Fiやモバイルネットワークへのパケット転送のみを行う。
+
+# 画面一覧
+## メイン画面
+シンプルかつモダンなデザインにする。まぶしい色は使わない(白は例外)。
+
+### VPNのオンオフボタン
+ボタンのサイズはなるべく大きく分かりやすいようにする。
+VPNが有効の場合は「ON」、無効の場合は「OFF」と表示する。
+ONの場合はボタンの色を変えて、視覚的に分かりやすくする。
+
+# 機能一覧
+## VPN機能
+メインの機能。概要の通り、すべてのパケットを通常のネットワークに転送する。何も制御しない。
+VPNはすべてのアプリに適用する。
+
+## 通知での操作機能
+VPNが有効の場合は、OSに通知を表示して動作している旨を表示する。
+また、「停止」ボタンを設け、通知から本アプリのVPN機能を停止できるようにする。
+
+# 動作
+バックグラウンドサービスを使用して、アプリを離れても動作できるようにする。
+
+# コーディングの注意事項
+- 権限が必要な機能は、requestPermissionを使用してアプリの起動時にユーザーに権限付与のリクエストをする。
+- テストは作成しない。
+
+# 技術的な事項
+- パケットのパススルーはtun2socksを使用する。
+ そのためローカルにSocksプロキシを作成する。
+ VPN開始時にSocksプロキシも起動し、停止時にプロキシも停止する。
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b5ddc30..dc986d4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -37,6 +37,11 @@ android {
buildFeatures {
compose = true
}
+ packaging {
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
}
dependencies {
@@ -55,4 +60,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f5a8fe..7f2b90d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt b/app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
new file mode 100644
index 0000000..5907eb8
--- /dev/null
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
@@ -0,0 +1,186 @@
+package jp.cyberrex.dummyvpn
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.VpnService
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import java.net.Inet4Address
+
+class DummyVpnService : VpnService() {
+ private var tunInterface: ParcelFileDescriptor? = null
+ private var socksProxy: LocalSocksProxy? = null
+ private var tun2SocksProcess: Tun2SocksProcess? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> stopVpn()
+ ACTION_START, null -> startVpn()
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopVpn()
+ super.onDestroy()
+ }
+
+ private fun startVpn() {
+ if (VpnStatusStore.status.value == VpnStatus.On) {
+ return
+ }
+
+ VpnStatusStore.set(VpnStatus.Starting)
+
+ try {
+ createNotificationChannel()
+ startForeground(
+ NOTIFICATION_ID,
+ buildNotification(),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ } else {
+ 0
+ }
+ )
+
+ val underlyingNetwork = findUnderlyingNetwork()
+ val builder = Builder()
+ .setSession(getString(R.string.app_name))
+ .setMtu(1500)
+ .addAddress("198.18.0.2", 24)
+ .addRoute("0.0.0.0", 0)
+
+ val dnsServers = findDnsServers(underlyingNetwork)
+ dnsServers.ifEmpty { listOf("1.1.1.1") }.forEach { dnsServer ->
+ builder.addDnsServer(dnsServer)
+ }
+ underlyingNetwork?.let { network ->
+ builder.setUnderlyingNetworks(arrayOf(network))
+ }
+
+ val tun = builder.establish()
+ ?: throw IllegalStateException("VPN interface could not be established")
+
+ tunInterface = tun
+
+ val proxy = LocalSocksProxy(this)
+ proxy.start()
+ socksProxy = proxy
+
+ val process = Tun2SocksProcess(this)
+ process.start(tun, proxy.port)
+ tun2SocksProcess = process
+ VpnStatusStore.set(VpnStatus.On)
+ } catch (error: Exception) {
+ stopRuntime()
+ val message = error.message ?: getString(R.string.vpn_error_unknown)
+ VpnStatusStore.set(VpnStatus.Error(message))
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+
+ private fun stopVpn() {
+ if (VpnStatusStore.status.value != VpnStatus.Off) {
+ VpnStatusStore.set(VpnStatus.Stopping)
+ }
+ stopRuntime()
+ VpnStatusStore.set(VpnStatus.Off)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ private fun stopRuntime() {
+ tun2SocksProcess?.stop()
+ tun2SocksProcess = null
+
+ socksProxy?.stop()
+ socksProxy = null
+
+ try {
+ tunInterface?.close()
+ } catch (_: Exception) {
+ }
+ tunInterface = null
+ }
+
+ private fun createNotificationChannel() {
+ val manager = getSystemService() ?: return
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.vpn_notification_channel),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun buildNotification(): Notification {
+ val launchIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ val stopIntent = PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, DummyVpnService::class.java).setAction(ACTION_STOP),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(getString(R.string.vpn_notification_title))
+ .setContentText(getString(R.string.vpn_notification_text))
+ .setContentIntent(launchIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .addAction(0, getString(R.string.vpn_notification_stop), stopIntent)
+ .build()
+ }
+
+ private fun findUnderlyingNetwork(): Network? {
+ val connectivityManager = getSystemService() ?: return null
+ return connectivityManager.allNetworks.firstOrNull { network ->
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@firstOrNull false
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+ }
+ }
+
+ private fun findDnsServers(network: Network?): List {
+ val connectivityManager = getSystemService() ?: return emptyList()
+ val linkProperties = connectivityManager.getLinkProperties(network ?: return emptyList())
+ ?: return emptyList()
+ return linkProperties.dnsServers
+ .filterIsInstance()
+ .mapNotNull { it.hostAddress }
+ }
+
+ companion object {
+ const val ACTION_START = "jp.cyberrex.dummyvpn.action.START"
+ const val ACTION_STOP = "jp.cyberrex.dummyvpn.action.STOP"
+
+ private const val CHANNEL_ID = "dummy_vpn"
+ private const val NOTIFICATION_ID = 1001
+
+ fun start(context: android.content.Context) {
+ val intent = Intent(context, DummyVpnService::class.java).setAction(ACTION_START)
+ ContextCompat.startForegroundService(context, intent)
+ }
+
+ fun stop(context: android.content.Context) {
+ context.startService(Intent(context, DummyVpnService::class.java).setAction(ACTION_STOP))
+ }
+ }
+}
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt b/app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
new file mode 100644
index 0000000..0588e02
--- /dev/null
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
@@ -0,0 +1,470 @@
+package jp.cyberrex.dummyvpn
+
+import android.net.VpnService
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.util.Log
+import java.io.EOFException
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketException
+import java.net.SocketTimeoutException
+import java.util.Collections
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+class LocalSocksProxy(private val vpnService: VpnService) {
+ private var serverSocket: ServerSocket? = null
+ private var executor: ExecutorService? = null
+ private val openSockets = Collections.synchronizedSet(mutableSetOf())
+ private val openDatagramSockets = Collections.synchronizedSet(mutableSetOf())
+
+ val port: Int
+ get() = serverSocket?.localPort ?: 0
+
+ fun start() {
+ stop()
+
+ val server = ServerSocket()
+ server.reuseAddress = true
+ server.bind(InetSocketAddress(InetAddress.getByName(LOCALHOST), 0))
+ serverSocket = server
+
+ val pool = Executors.newCachedThreadPool()
+ executor = pool
+ pool.execute {
+ acceptLoop(server, pool)
+ }
+ }
+
+ fun stop() {
+ try {
+ serverSocket?.close()
+ } catch (_: IOException) {
+ }
+ serverSocket = null
+
+ synchronized(openSockets) {
+ openSockets.forEach { socket ->
+ try {
+ socket.close()
+ } catch (_: IOException) {
+ }
+ }
+ openSockets.clear()
+ }
+ synchronized(openDatagramSockets) {
+ openDatagramSockets.forEach { socket ->
+ socket.close()
+ }
+ openDatagramSockets.clear()
+ }
+
+ executor?.shutdownNow()
+ try {
+ executor?.awaitTermination(1, TimeUnit.SECONDS)
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ executor = null
+ }
+
+ private fun acceptLoop(server: ServerSocket, pool: ExecutorService) {
+ while (!server.isClosed) {
+ try {
+ val client = server.accept()
+ openSockets.add(client)
+ pool.execute {
+ handleClient(client)
+ }
+ } catch (_: SocketException) {
+ break
+ } catch (_: IOException) {
+ }
+ }
+ }
+
+ private fun handleClient(client: Socket) {
+ client.use {
+ try {
+ val input = client.getInputStream()
+ val output = client.getOutputStream()
+ negotiate(input, output)
+
+ val requestVersion = input.readByte()
+ if (requestVersion != SOCKS_VERSION) {
+ throw IOException("Unsupported SOCKS request version")
+ }
+
+ when (val command = input.readByte()) {
+ COMMAND_CONNECT -> handleConnect(client, input, output)
+ COMMAND_UDP_ASSOCIATE -> handleUdpAssociate(client, input, output)
+ else -> {
+ skipAddress(input)
+ sendReply(output, REPLY_COMMAND_NOT_SUPPORTED, InetAddress.getByName(LOCALHOST), 0)
+ }
+ }
+ } catch (_: IOException) {
+ } finally {
+ openSockets.remove(client)
+ }
+ }
+ }
+
+ private fun negotiate(input: InputStream, output: OutputStream) {
+ val version = input.readByte()
+ if (version != SOCKS_VERSION) throw IOException("Unsupported SOCKS version")
+
+ val methodCount = input.readByte()
+ var supportsNoAuth = false
+ repeat(methodCount) {
+ if (input.readByte() == METHOD_NO_AUTH) supportsNoAuth = true
+ }
+
+ if (supportsNoAuth) {
+ output.write(byteArrayOf(SOCKS_VERSION.toByte(), METHOD_NO_AUTH.toByte()))
+ } else {
+ output.write(byteArrayOf(SOCKS_VERSION.toByte(), METHOD_NOT_ACCEPTABLE.toByte()))
+ throw IOException("No acceptable SOCKS authentication method")
+ }
+ }
+
+ private fun handleConnect(client: Socket, input: InputStream, output: OutputStream) {
+ input.readByte()
+ val destination = readAddress(input)
+ val remote = createOutboundTcpSocket()
+ openSockets.add(remote)
+
+ try {
+ remote.connect(InetSocketAddress(destination.host, destination.port), CONNECT_TIMEOUT_MS)
+ sendReply(output, REPLY_SUCCEEDED, remote.localAddress, remote.localPort)
+ pipeBothWays(client, remote)
+ } catch (error: IOException) {
+ Log.w(TAG, "CONNECT ${destination.host}:${destination.port} failed: ${error.message}")
+ sendReply(output, REPLY_HOST_UNREACHABLE, InetAddress.getByName(LOCALHOST), 0)
+ } finally {
+ openSockets.remove(remote)
+ try {
+ remote.close()
+ } catch (_: IOException) {
+ }
+ }
+ }
+
+ private fun handleUdpAssociate(client: Socket, input: InputStream, output: OutputStream) {
+ input.readByte()
+ skipAddress(input)
+ val relay = DatagramSocket(InetSocketAddress(InetAddress.getByName(LOCALHOST), 0))
+ openDatagramSockets.add(relay)
+
+ try {
+ protectAndBind(relay)
+ sendReply(output, REPLY_SUCCEEDED, InetAddress.getByName(LOCALHOST), relay.localPort)
+
+ val udpTask = executor?.submit {
+ relayUdpPackets(relay)
+ }
+
+ try {
+ while (client.getInputStream().read() != -1) {
+ }
+ } catch (_: IOException) {
+ }
+ udpTask?.cancel(true)
+ } finally {
+ openDatagramSockets.remove(relay)
+ relay.close()
+ }
+ }
+
+ private fun relayUdpPackets(relay: DatagramSocket) {
+ var clientAddress: InetSocketAddress? = null
+ val buffer = ByteArray(UDP_BUFFER_SIZE)
+
+ while (!relay.isClosed) {
+ try {
+ val packet = DatagramPacket(buffer, buffer.size)
+ relay.receive(packet)
+ val source = InetSocketAddress(packet.address, packet.port)
+ val data = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
+
+ if (clientAddress == null || source == clientAddress) {
+ clientAddress = source
+ val request = parseUdpRequest(data) ?: continue
+ executor?.execute {
+ relaySingleUdpRequest(relay, source, request)
+ }
+ }
+ } catch (_: SocketException) {
+ break
+ } catch (_: IOException) {
+ }
+ }
+ }
+
+ private fun relaySingleUdpRequest(relay: DatagramSocket, client: InetSocketAddress, request: UdpRequest) {
+ val remote = DatagramSocket()
+ openDatagramSockets.add(remote)
+
+ try {
+ protectAndBind(remote)
+ remote.soTimeout = UDP_REPLY_TIMEOUT_MS
+ val targetAddress = InetAddress.getByName(request.host)
+ remote.send(DatagramPacket(request.payload, request.payload.size, targetAddress, request.port))
+
+ val buffer = ByteArray(UDP_BUFFER_SIZE)
+ val reply = DatagramPacket(buffer, buffer.size)
+ remote.receive(reply)
+
+ val response = buildUdpResponse(
+ reply.address,
+ reply.port,
+ reply.data.copyOfRange(reply.offset, reply.offset + reply.length)
+ )
+ relay.send(DatagramPacket(response, response.size, client.address, client.port))
+ } catch (_: SocketTimeoutException) {
+ } catch (_: IOException) {
+ } finally {
+ openDatagramSockets.remove(remote)
+ remote.close()
+ }
+ }
+
+ private fun parseUdpRequest(data: ByteArray): UdpRequest? {
+ if (data.size < 10 || data[0].toInt() != 0 || data[1].toInt() != 0 || data[2].toInt() != 0) {
+ return null
+ }
+
+ var offset = 3
+ val host = when (data[offset++].toInt() and 0xff) {
+ ADDRESS_IPV4 -> {
+ if (data.size < offset + 4 + 2) return null
+ InetAddress.getByAddress(data.copyOfRange(offset, offset + 4)).hostAddress.orEmpty()
+ .also { offset += 4 }
+ }
+
+ ADDRESS_DOMAIN -> {
+ if (data.size < offset + 1) return null
+ val length = data[offset++].toInt() and 0xff
+ if (data.size < offset + length + 2) return null
+ String(data.copyOfRange(offset, offset + length), Charsets.UTF_8)
+ .also { offset += length }
+ }
+
+ ADDRESS_IPV6 -> {
+ if (data.size < offset + 16 + 2) return null
+ InetAddress.getByAddress(data.copyOfRange(offset, offset + 16)).hostAddress.orEmpty()
+ .also { offset += 16 }
+ }
+
+ else -> return null
+ }
+
+ val port = ((data[offset].toInt() and 0xff) shl 8) or (data[offset + 1].toInt() and 0xff)
+ offset += 2
+ return UdpRequest(host, port, data.copyOfRange(offset, data.size))
+ }
+
+ private fun buildUdpResponse(address: InetAddress, port: Int, payload: ByteArray): ByteArray {
+ val addressBytes = address.address
+ val addressType = if (addressBytes.size == 16) ADDRESS_IPV6 else ADDRESS_IPV4
+ return byteArrayOf(0, 0, 0, addressType.toByte()) +
+ addressBytes +
+ byteArrayOf(((port ushr 8) and 0xff).toByte(), (port and 0xff).toByte()) +
+ payload
+ }
+
+ private fun pipeBothWays(client: Socket, remote: Socket) {
+ val pool = executor ?: return
+ val first = pool.submit {
+ copyAndClose(client.getInputStream(), remote.getOutputStream(), client, remote)
+ }
+ val second = pool.submit {
+ copyAndClose(remote.getInputStream(), client.getOutputStream(), remote, client)
+ }
+ try {
+ first.get()
+ second.get()
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun copyAndClose(input: InputStream, output: OutputStream, source: Socket, target: Socket) {
+ try {
+ input.copyTo(output)
+ } catch (_: IOException) {
+ } finally {
+ try {
+ source.shutdownInput()
+ } catch (_: IOException) {
+ }
+ try {
+ target.shutdownOutput()
+ } catch (_: IOException) {
+ }
+ }
+ }
+
+ private fun readAddress(input: InputStream): SocksAddress {
+ return when (val addressType = input.readByte()) {
+ ADDRESS_IPV4 -> {
+ val address = ByteArray(4)
+ input.readFully(address)
+ SocksAddress(InetAddress.getByAddress(address).hostAddress.orEmpty(), input.readPort())
+ }
+
+ ADDRESS_DOMAIN -> {
+ val length = input.readByte()
+ val domain = ByteArray(length)
+ input.readFully(domain)
+ SocksAddress(String(domain, Charsets.UTF_8), input.readPort())
+ }
+
+ ADDRESS_IPV6 -> {
+ val address = ByteArray(16)
+ input.readFully(address)
+ SocksAddress(InetAddress.getByAddress(address).hostAddress.orEmpty(), input.readPort())
+ }
+
+ else -> throw IOException("Unsupported address type: $addressType")
+ }
+ }
+
+ private fun skipAddress(input: InputStream) {
+ when (input.readByte()) {
+ ADDRESS_IPV4 -> input.skipFully(4)
+ ADDRESS_DOMAIN -> input.skipFully(input.readByte())
+ ADDRESS_IPV6 -> input.skipFully(16)
+ else -> throw IOException("Unsupported address type")
+ }
+ input.skipFully(2)
+ }
+
+ private fun sendReply(output: OutputStream, reply: Int, address: InetAddress, port: Int) {
+ val addressBytes = if (address.address.size == 16) {
+ InetAddress.getByName(LOCALHOST).address
+ } else {
+ address.address
+ }
+ output.write(
+ byteArrayOf(
+ SOCKS_VERSION.toByte(),
+ reply.toByte(),
+ 0,
+ ADDRESS_IPV4.toByte(),
+ addressBytes[0],
+ addressBytes[1],
+ addressBytes[2],
+ addressBytes[3],
+ ((port ushr 8) and 0xff).toByte(),
+ (port and 0xff).toByte()
+ )
+ )
+ output.flush()
+ }
+
+ private fun protectAndBind(socket: Socket) {
+ if (!vpnService.protect(socket)) {
+ throw IOException("Failed to protect outbound TCP socket")
+ }
+ findUnderlyingNetwork()?.bindSocket(socket)
+ }
+
+ private fun createOutboundTcpSocket(): Socket {
+ val network = findUnderlyingNetwork()
+ val socket = network?.socketFactory?.createSocket() ?: Socket()
+ try {
+ if (!vpnService.protect(socket)) {
+ throw IOException("Failed to protect outbound TCP socket")
+ }
+ network?.bindSocket(socket)
+ return socket
+ } catch (error: IOException) {
+ try {
+ socket.close()
+ } catch (_: IOException) {
+ }
+ throw error
+ }
+ }
+
+ private fun protectAndBind(socket: DatagramSocket) {
+ if (!vpnService.protect(socket)) {
+ throw IOException("Failed to protect outbound UDP socket")
+ }
+ findUnderlyingNetwork()?.bindSocket(socket)
+ }
+
+ private fun findUnderlyingNetwork(): Network? {
+ val connectivityManager = vpnService.getSystemService(ConnectivityManager::class.java)
+ ?: return null
+ return connectivityManager.allNetworks.firstOrNull { network ->
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@firstOrNull false
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
+ }
+ }
+
+ private fun InputStream.readByte(): Int {
+ val value = read()
+ if (value == -1) throw EOFException()
+ return value and 0xff
+ }
+
+ private fun InputStream.readFully(buffer: ByteArray) {
+ var offset = 0
+ while (offset < buffer.size) {
+ val read = read(buffer, offset, buffer.size - offset)
+ if (read == -1) throw EOFException()
+ offset += read
+ }
+ }
+
+ private fun InputStream.skipFully(length: Int) {
+ var remaining = length
+ while (remaining > 0) {
+ val skipped = skip(remaining.toLong()).toInt()
+ if (skipped <= 0) {
+ readByte()
+ remaining--
+ } else {
+ remaining -= skipped
+ }
+ }
+ }
+
+ private fun InputStream.readPort(): Int {
+ return (readByte() shl 8) or readByte()
+ }
+
+ private data class SocksAddress(val host: String, val port: Int)
+ private data class UdpRequest(val host: String, val port: Int, val payload: ByteArray)
+
+ private companion object {
+ const val LOCALHOST = "127.0.0.1"
+ const val SOCKS_VERSION = 5
+ const val METHOD_NO_AUTH = 0
+ const val METHOD_NOT_ACCEPTABLE = 0xff
+ const val COMMAND_CONNECT = 1
+ const val COMMAND_UDP_ASSOCIATE = 3
+ const val ADDRESS_IPV4 = 1
+ const val ADDRESS_DOMAIN = 3
+ const val ADDRESS_IPV6 = 4
+ const val REPLY_SUCCEEDED = 0
+ const val REPLY_COMMAND_NOT_SUPPORTED = 7
+ const val REPLY_HOST_UNREACHABLE = 4
+ const val CONNECT_TIMEOUT_MS = 10_000
+ const val UDP_REPLY_TIMEOUT_MS = 10_000
+ const val UDP_BUFFER_SIZE = 65_535
+ const val TAG = "LocalSocksProxy"
+ }
+}
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/MainActivity.kt b/app/src/main/java/jp/cyberrex/dummyvpn/MainActivity.kt
index b1204bc..88b0d5e 100644
--- a/app/src/main/java/jp/cyberrex/dummyvpn/MainActivity.kt
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/MainActivity.kt
@@ -1,16 +1,49 @@
package jp.cyberrex.dummyvpn
+import android.Manifest
+import android.app.Activity
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.VpnService
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import jp.cyberrex.dummyvpn.ui.theme.DummyVPNTheme
class MainActivity : ComponentActivity() {
@@ -19,29 +52,188 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
DummyVPNTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
- )
- }
+ DummyVpnApp()
}
}
}
}
@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
+private fun DummyVpnApp() {
+ val context = LocalContext.current
+ val status by VpnStatusStore.status.collectAsState()
+
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {}
+ val vpnPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ DummyVpnService.start(context)
+ } else {
+ VpnStatusStore.set(VpnStatus.Off)
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ if (isVpnNetworkActive(context)) {
+ VpnStatusStore.set(VpnStatus.On)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ DummyVpnScreen(
+ status = status,
+ onToggle = {
+ when (status) {
+ VpnStatus.Off, is VpnStatus.Error -> {
+ if (isVpnNetworkActive(context)) {
+ DummyVpnService.stop(context)
+ } else {
+ VpnStatusStore.set(VpnStatus.Starting)
+ val prepareIntent: Intent? = VpnService.prepare(context)
+ if (prepareIntent != null) {
+ vpnPermissionLauncher.launch(prepareIntent)
+ } else {
+ DummyVpnService.start(context)
+ }
+ }
+ }
+
+ VpnStatus.On -> DummyVpnService.stop(context)
+ VpnStatus.Starting, VpnStatus.Stopping -> Unit
+ }
+ }
)
}
+@Composable
+private fun DummyVpnScreen(
+ status: VpnStatus,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val isOn = status == VpnStatus.On
+ val isBusy = status == VpnStatus.Starting || status == VpnStatus.Stopping
+ val buttonColor by animateColorAsState(
+ targetValue = when {
+ isOn -> Color(0xFF2F6F5E)
+ isBusy -> Color(0xFF6B7280)
+ else -> Color(0xFF31343A)
+ },
+ label = "vpnButtonColor"
+ )
+
+ Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFFF7F8FA))
+ .padding(horizontal = 28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "DummyVPN",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = Color(0xFF202329)
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = statusText(status),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = Color(0xFF5F6673)
+ )
+ Spacer(modifier = Modifier.height(48.dp))
+ Button(
+ onClick = onToggle,
+ enabled = !isBusy,
+ modifier = Modifier
+ .size(210.dp)
+ .shadow(18.dp, CircleShape),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = buttonColor,
+ contentColor = Color.White,
+ disabledContainerColor = buttonColor,
+ disabledContentColor = Color.White
+ )
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Text(
+ text = buttonText(status),
+ fontSize = 44.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 0.sp
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(36.dp))
+ Text(
+ text = detailText(status),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = if (status is VpnStatus.Error) Color(0xFF9B2F2F) else Color(0xFF69717E)
+ )
+ }
+ }
+ }
+}
+
+private fun buttonText(status: VpnStatus): String {
+ return when (status) {
+ VpnStatus.On -> "ON"
+ VpnStatus.Off, is VpnStatus.Error -> "OFF"
+ VpnStatus.Starting -> "..."
+ VpnStatus.Stopping -> "..."
+ }
+}
+
+private fun statusText(status: VpnStatus): String {
+ return when (status) {
+ VpnStatus.On -> "VPN is active"
+ VpnStatus.Off -> "VPN is inactive"
+ VpnStatus.Starting -> "Starting VPN"
+ VpnStatus.Stopping -> "Stopping VPN"
+ is VpnStatus.Error -> "VPN could not start"
+ }
+}
+
+private fun detailText(status: VpnStatus): String {
+ return when (status) {
+ VpnStatus.On -> "All apps are routed through the local pass-through VPN."
+ VpnStatus.Off -> "Tap the button to start the pass-through VPN."
+ VpnStatus.Starting -> "Preparing the VPN interface and local proxy."
+ VpnStatus.Stopping -> "Closing the VPN interface and local proxy."
+ is VpnStatus.Error -> status.message
+ }
+}
+
+private fun isVpnNetworkActive(context: android.content.Context): Boolean {
+ val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
+ ?: return false
+ return connectivityManager.allNetworks.any { network ->
+ connectivityManager.getNetworkCapabilities(network)
+ ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
+ }
+}
+
@Preview(showBackground = true)
@Composable
-fun GreetingPreview() {
+private fun DummyVpnScreenPreview() {
DummyVPNTheme {
- Greeting("Android")
+ DummyVpnScreen(status = VpnStatus.Off, onToggle = {})
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt b/app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
new file mode 100644
index 0000000..f4e89f6
--- /dev/null
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
@@ -0,0 +1,153 @@
+package jp.cyberrex.dummyvpn
+
+import android.content.Context
+import android.os.ParcelFileDescriptor
+import android.system.Os
+import android.system.OsConstants
+import android.util.Log
+import java.io.File
+import java.io.FileNotFoundException
+import java.util.concurrent.TimeUnit
+
+class Tun2SocksProcess(private val context: Context) {
+ private var process: Process? = null
+ private var logThread: Thread? = null
+ private var watchThread: Thread? = null
+
+ fun start(tunInterface: ParcelFileDescriptor, socksPort: Int) {
+ stop()
+
+ val binary = findBinary()
+ val tunForProcess = ParcelFileDescriptor.dup(tunInterface.fileDescriptor)
+ val tunFd = tunForProcess.detachFd()
+ val command = listOf(
+ binary.absolutePath,
+ "-device",
+ "fd://0",
+ "-mtu",
+ "1500",
+ "-proxy",
+ "socks5://127.0.0.1:$socksPort",
+ "-loglevel",
+ "info"
+ )
+
+ val stdinBackup = Os.dup(STDIN)
+ try {
+ Os.dup2(fileDescriptorOf(tunFd), STDIN_FILENO)
+ process = ProcessBuilder(command)
+ .redirectErrorStream(true)
+ .redirectInput(ProcessBuilder.Redirect.INHERIT)
+ .start()
+ val startedProcess = process ?: return
+ startLogging(startedProcess)
+ startWatching(startedProcess)
+ } catch (error: Exception) {
+ throw error
+ } finally {
+ Os.dup2(stdinBackup, STDIN_FILENO)
+ closeFileDescriptor(stdinBackup)
+ closeDetachedFd(tunFd)
+ }
+ }
+
+ fun stop() {
+ logThread?.interrupt()
+ logThread = null
+ watchThread?.interrupt()
+ watchThread = null
+
+ process?.destroy()
+ try {
+ if (process?.waitFor(750, TimeUnit.MILLISECONDS) == false) {
+ process?.destroyForcibly()
+ process?.waitFor(750, TimeUnit.MILLISECONDS)
+ }
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ process?.destroyForcibly()
+ }
+ process = null
+ }
+
+ private fun startLogging(startedProcess: Process) {
+ logThread = Thread {
+ try {
+ startedProcess.inputStream.bufferedReader().useLines { lines ->
+ lines.forEach { line ->
+ Log.i(TAG, line)
+ }
+ }
+ } catch (_: Exception) {
+ }
+ }.apply {
+ name = "tun2socks-log"
+ isDaemon = true
+ start()
+ }
+ }
+
+ private fun startWatching(startedProcess: Process) {
+ watchThread = Thread {
+ try {
+ val exitCode = startedProcess.waitFor()
+ if (process == startedProcess) {
+ Log.e(TAG, "tun2socks exited with code $exitCode")
+ VpnStatusStore.set(VpnStatus.Error("tun2socks exited with code $exitCode"))
+ }
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }.apply {
+ name = "tun2socks-watch"
+ isDaemon = true
+ start()
+ }
+ }
+
+ private fun findBinary(): File {
+ val binary = File(context.applicationInfo.nativeLibraryDir, BINARY_NAME)
+ if (binary.exists() && binary.canExecute()) {
+ return binary
+ }
+
+ throw FileNotFoundException(
+ "tun2socks binary not found. Place it at app/src/main/jniLibs//$BINARY_NAME"
+ )
+ }
+
+ private fun closeDetachedFd(fd: Int) {
+ try {
+ ParcelFileDescriptor.adoptFd(fd).close()
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun closeFileDescriptor(fd: java.io.FileDescriptor) {
+ try {
+ Os.close(fd)
+ } catch (_: Exception) {
+ }
+ }
+
+ private fun fileDescriptorOf(fd: Int): java.io.FileDescriptor {
+ return java.io.FileDescriptor().also { descriptor ->
+ setFileDescriptorInt(descriptor, fd)
+ val flags = Os.fcntlInt(descriptor, OsConstants.F_GETFD, 0)
+ Os.fcntlInt(descriptor, OsConstants.F_SETFD, flags and OsConstants.FD_CLOEXEC.inv())
+ }
+ }
+
+ private fun setFileDescriptorInt(descriptor: java.io.FileDescriptor, fd: Int) {
+ val field = java.io.FileDescriptor::class.java.getDeclaredField("descriptor")
+ field.isAccessible = true
+ field.setInt(descriptor, fd)
+ }
+
+ private companion object {
+ const val TAG = "Tun2SocksProcess"
+ const val BINARY_NAME = "libtun2socks.so"
+ const val STDIN_FILENO = 0
+ val STDIN: java.io.FileDescriptor = java.io.FileDescriptor.`in`
+ }
+}
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt b/app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
new file mode 100644
index 0000000..fca878a
--- /dev/null
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
@@ -0,0 +1,22 @@
+package jp.cyberrex.dummyvpn
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+sealed interface VpnStatus {
+ data object Off : VpnStatus
+ data object Starting : VpnStatus
+ data object On : VpnStatus
+ data object Stopping : VpnStatus
+ data class Error(val message: String) : VpnStatus
+}
+
+object VpnStatusStore {
+ private val mutableStatus = MutableStateFlow(VpnStatus.Off)
+ val status: StateFlow = mutableStatus.asStateFlow()
+
+ fun set(status: VpnStatus) {
+ mutableStatus.value = status
+ }
+}
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Color.kt b/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Color.kt
index cfe954b..bd43fdd 100644
--- a/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Color.kt
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Color.kt
@@ -2,10 +2,10 @@ package jp.cyberrex.dummyvpn.ui.theme
import androidx.compose.ui.graphics.Color
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
+val DeepGreen80 = Color(0xFFA7D7C5)
+val Slate80 = Color(0xFFC8CDD4)
+val SoftBlue80 = Color(0xFFB8D3E6)
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
+val DeepGreen40 = Color(0xFF2F6F5E)
+val Slate40 = Color(0xFF4D5663)
+val SoftBlue40 = Color(0xFF406C86)
diff --git a/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Theme.kt b/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Theme.kt
index 6e659bb..08f7872 100644
--- a/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Theme.kt
+++ b/app/src/main/java/jp/cyberrex/dummyvpn/ui/theme/Theme.kt
@@ -12,15 +12,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
+ primary = DeepGreen80,
+ secondary = Slate80,
+ tertiary = SoftBlue80
)
private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
+ primary = DeepGreen40,
+ secondary = Slate40,
+ tertiary = SoftBlue40
/* Other default colors to override
background = Color(0xFFFFFBFE),
@@ -37,7 +37,7 @@ private val LightColorScheme = lightColorScheme(
fun DummyVPNTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
+ dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -55,4 +55,4 @@ fun DummyVPNTheme(
typography = Typography,
content = content
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/jniLibs/README.md b/app/src/main/jniLibs/README.md
new file mode 100644
index 0000000..206b5d1
--- /dev/null
+++ b/app/src/main/jniLibs/README.md
@@ -0,0 +1,12 @@
+Place ABI-specific tun2socks binaries here and name each executable `libtun2socks.so`.
+
+Expected paths:
+
+- `arm64-v8a/libtun2socks.so`
+- `armeabi-v7a/libtun2socks.so`
+- `x86/libtun2socks.so`
+- `x86_64/libtun2socks.so`
+
+Android does not allow executing files copied into app data directories on modern
+versions. Packaging the executable as a native library keeps it in an executable
+location exposed by `applicationInfo.nativeLibraryDir`.
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 594a504..5aee6cb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,8 @@
DummyVPN
-
\ No newline at end of file
+ VPN status
+ DummyVPN is active
+ Traffic is being passed through the local VPN.
+ Stop
+ Unknown VPN error
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 18318be..b546c74 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,4 +2,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
-}
\ No newline at end of file
+}