Implement

This commit is contained in:
CyberRex
2026-04-28 22:15:45 +09:00
parent 8c4271df62
commit fea2b31097
13 changed files with 1134 additions and 31 deletions

38
AGENTS.md Normal file
View File

@@ -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プロキシも起動し、停止時にプロキシも停止する。

View File

@@ -37,6 +37,11 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
} }
packaging {
jniLibs {
useLegacyPackaging = true
}
}
} }
dependencies { dependencies {

View File

@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -22,6 +28,20 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".DummyVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Pass-through local VPN service" />
</service>
<meta-data android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED" android:value="jp.cyberrex.dummyvpn" />
</application> </application>
</manifest> </manifest>

View File

@@ -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<NotificationManager>() ?: 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<ConnectivityManager>() ?: 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<String> {
val connectivityManager = getSystemService<ConnectivityManager>() ?: return emptyList()
val linkProperties = connectivityManager.getLinkProperties(network ?: return emptyList())
?: return emptyList()
return linkProperties.dnsServers
.filterIsInstance<Inet4Address>()
.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))
}
}
}

View File

@@ -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<Socket>())
private val openDatagramSockets = Collections.synchronizedSet(mutableSetOf<DatagramSocket>())
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"
}
}

View File

@@ -1,16 +1,49 @@
package jp.cyberrex.dummyvpn 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 android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import jp.cyberrex.dummyvpn.ui.theme.DummyVPNTheme import jp.cyberrex.dummyvpn.ui.theme.DummyVPNTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -19,29 +52,188 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
DummyVPNTheme { DummyVPNTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> DummyVpnApp()
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
} }
} }
} }
} }
@Composable @Composable
fun Greeting(name: String, modifier: Modifier = Modifier) { private fun DummyVpnApp() {
Text( val context = LocalContext.current
text = "Hello $name!", val status by VpnStatusStore.status.collectAsState()
modifier = modifier
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) @Preview(showBackground = true)
@Composable @Composable
fun GreetingPreview() { private fun DummyVpnScreenPreview() {
DummyVPNTheme { DummyVPNTheme {
Greeting("Android") DummyVpnScreen(status = VpnStatus.Off, onToggle = {})
} }
} }

View File

@@ -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/<abi>/$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`
}
}

View File

@@ -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>(VpnStatus.Off)
val status: StateFlow<VpnStatus> = mutableStatus.asStateFlow()
fun set(status: VpnStatus) {
mutableStatus.value = status
}
}

View File

@@ -2,10 +2,10 @@ package jp.cyberrex.dummyvpn.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) val DeepGreen80 = Color(0xFFA7D7C5)
val PurpleGrey80 = Color(0xFFCCC2DC) val Slate80 = Color(0xFFC8CDD4)
val Pink80 = Color(0xFFEFB8C8) val SoftBlue80 = Color(0xFFB8D3E6)
val Purple40 = Color(0xFF6650a4) val DeepGreen40 = Color(0xFF2F6F5E)
val PurpleGrey40 = Color(0xFF625b71) val Slate40 = Color(0xFF4D5663)
val Pink40 = Color(0xFF7D5260) val SoftBlue40 = Color(0xFF406C86)

View File

@@ -12,15 +12,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Purple80, primary = DeepGreen80,
secondary = PurpleGrey80, secondary = Slate80,
tertiary = Pink80 tertiary = SoftBlue80
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = DeepGreen40,
secondary = PurpleGrey40, secondary = Slate40,
tertiary = Pink40 tertiary = SoftBlue40
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
@@ -37,7 +37,7 @@ private val LightColorScheme = lightColorScheme(
fun DummyVPNTheme( fun DummyVPNTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {

View File

@@ -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`.

View File

@@ -1,3 +1,8 @@
<resources> <resources>
<string name="app_name">DummyVPN</string> <string name="app_name">DummyVPN</string>
<string name="vpn_notification_channel">VPN status</string>
<string name="vpn_notification_title">DummyVPN is active</string>
<string name="vpn_notification_text">Traffic is being passed through the local VPN.</string>
<string name="vpn_notification_stop">Stop</string>
<string name="vpn_error_unknown">Unknown VPN error</string>
</resources> </resources>