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 +}