Implement
This commit is contained in:
38
AGENTS.md
Normal file
38
AGENTS.md
Normal 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プロキシも起動し、停止時にプロキシも停止する。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -22,6 +28,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
186
app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
Normal file
186
app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
470
app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
Normal file
470
app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
Normal file
153
app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
Normal 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`
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
Normal file
22
app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
val DeepGreen40 = Color(0xFF2F6F5E)
|
||||
val Slate40 = Color(0xFF4D5663)
|
||||
val SoftBlue40 = Color(0xFF406C86)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
12
app/src/main/jniLibs/README.md
Normal file
12
app/src/main/jniLibs/README.md
Normal 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`.
|
||||
@@ -1,3 +1,8 @@
|
||||
<resources>
|
||||
<string name="app_name">DummyVPN</string>
|
||||
</resources>
|
||||
<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>
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user