This commit is contained in:
asxalex 2025-07-14 14:03:01 +08:00
parent 91042c083f
commit f24dd7cfe9
18 changed files with 424 additions and 72 deletions

1
.idea/.name generated Normal file
View File

@ -0,0 +1 @@
punchnet

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View File

@ -49,6 +49,10 @@
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />

1
.idea/misc.xml generated
View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

BIN
app/release/app-release.apk Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.jihe.punchnet",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}

View File

@ -11,7 +11,7 @@
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@drawable/punchnet_log"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"

View File

@ -12,6 +12,9 @@ import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.ViewModelProvider
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.sdlan.config.Arguments import com.jihe.punchnet.sdlan.config.Arguments
import com.jihe.punchnet.sdlan.config.toIPV4String import com.jihe.punchnet.sdlan.config.toIPV4String
import com.jihe.punchnet.sdlan.logs.TerminalLogger import com.jihe.punchnet.sdlan.logs.TerminalLogger
@ -23,7 +26,9 @@ import com.jihe.punchnet.sdlan.network.RouteDetail
import com.jihe.punchnet.sdlan.network.RouteTable import com.jihe.punchnet.sdlan.network.RouteTable
import com.jihe.punchnet.sdlan.network.cidrToRouteDetail import com.jihe.punchnet.sdlan.network.cidrToRouteDetail
import com.jihe.punchnet.sdlan.network.ipInt2ByteArray import com.jihe.punchnet.sdlan.network.ipInt2ByteArray
import com.jihe.punchnet.sdlan.network.maskIPToDigit
import com.jihe.punchnet.sdlan.network.run_sdlan import com.jihe.punchnet.sdlan.network.run_sdlan
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -34,10 +39,13 @@ import java.io.FileOutputStream
class PunchnetService : VpnService() , IfaceTun { class PunchnetService : VpnService() , IfaceTun {
private val TAG = "PunchnetService" private val TAG = "PunchnetService"
private val scope = CoroutineScope(Dispatchers.Default) private val scope = CoroutineScope(Dispatchers.IO)
private val routes: MutableList<RouteItem> = mutableListOf()
private var vpnDescriptor: ParcelFileDescriptor? = null private var vpnDescriptor: ParcelFileDescriptor? = null
var input: FileInputStream? = null var input: FileInputStream? = null
var output: FileOutputStream? = null var output: FileOutputStream? = null
@ -91,10 +99,16 @@ class PunchnetService : VpnService() , IfaceTun {
} }
vpnDescriptor?.close() vpnDescriptor?.close()
vpnDescriptor = Builder() var tempVpnDescriptor = Builder()
.setMtu(config.mtu) .setMtu(config.mtu)
.addAddress(ip, config.ip.netBitLen.toInt()) .addAddress(ip, config.ip.netBitLen.toInt())
.setBlocking(true) .setBlocking(true)
for (route in routes) {
tempVpnDescriptor = tempVpnDescriptor.addRoute(ipToString(route.net_ip), maskIPToDigit(route.mask_ip))
}
vpnDescriptor = tempVpnDescriptor
.establish() .establish()
input = FileInputStream(vpnDescriptor!!.fileDescriptor) input = FileInputStream(vpnDescriptor!!.fileDescriptor)
@ -107,7 +121,7 @@ class PunchnetService : VpnService() , IfaceTun {
output?.close() output?.close()
vpnDescriptor?.close() vpnDescriptor?.close()
// stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
} }
@ -121,12 +135,17 @@ class PunchnetService : VpnService() , IfaceTun {
Log.d("DIR", "filesdir = ${this.filesDir.path}") Log.d("DIR", "filesdir = ${this.filesDir.path}")
arpTable.routeTable.clearRoute() arpTable.routeTable.clearRoute()
routes.clear()
for (route in startArg?.routes?.toList()?:listOf()) { for (route in startArg?.routes?.toList()?:listOf()) {
val r = cidrToRouteDetail(route.targetNetCIDR, route.gateway) val r = RouteDetail(
if (r != null) { mask = route.mask_ip,
gw = route.gateway,
maskedAddr = route.net_ip,
)
routes.add(route)
// val r = cidrToRouteDetail(route.targetNetCIDR, route.gateway)
arpTable.routeTable.addRoute(r) arpTable.routeTable.addRoute(r)
} }
}
val argument = Arguments( val argument = Arguments(
baseDir = this.filesDir.path, baseDir = this.filesDir.path,

View File

@ -2,6 +2,7 @@ package com.jihe.punchnet
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import com.jihe.punchnet.data.RouteItem
data class RouteInfo( data class RouteInfo(
// 192.168.80.0/24 // 192.168.80.0/24
@ -12,14 +13,16 @@ data class RouteInfo(
data class PunchnetServiceArgument( data class PunchnetServiceArgument(
val token: String, val token: String,
val routes: Array<RouteInfo>, val routes: Array<RouteItem>,
): Parcelable { ): Parcelable {
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(token) dest.writeString(token)
dest.writeInt(routes.size) dest.writeInt(routes.size)
routes.forEach { item -> routes.forEach { item ->
dest.writeString(item.targetNetCIDR) dest.writeLong(item.id)
dest.writeString(item.gateway) dest.writeInt(item.net_ip)
dest.writeInt(item.mask_ip)
dest.writeInt(item.gateway)
} }
} }
@ -31,13 +34,18 @@ data class PunchnetServiceArgument(
override fun createFromParcel(source: Parcel): PunchnetServiceArgument { override fun createFromParcel(source: Parcel): PunchnetServiceArgument {
val token = source.readString()!! val token = source.readString()!!
val size = source.readInt() val size = source.readInt()
var routes = mutableListOf<RouteInfo>() var routes = mutableListOf<RouteItem>()
for (i in 0..<size) { for (i in 0..<size) {
val net = source.readString()!! val id = source.readLong()!!
val gw = source.readString()!! val netIP = source.readInt()!!
routes.add(RouteInfo( val maskIP = source.readInt()!!
targetNetCIDR = net, val gateway = source.readInt()!!
gateway = gw,
routes.add(RouteItem(
id = id,
net_ip = netIP,
mask_ip = maskIP,
gateway = gateway,
)) ))
} }
return PunchnetServiceArgument(token, routes.toTypedArray()) return PunchnetServiceArgument(token, routes.toTypedArray())

View File

@ -16,44 +16,34 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat.startForegroundService import androidx.core.content.ContextCompat.startForegroundService
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.jihe.punchnet.PunchnetService import com.jihe.punchnet.PunchnetService
import com.jihe.punchnet.PunchnetServiceArgument import com.jihe.punchnet.PunchnetServiceArgument
import com.jihe.punchnet.RouteInfo import com.jihe.punchnet.RouteInfo
import com.jihe.punchnet.sdlan.utils.ipToString
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.forEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
class ServiceViewModel(application: Application): AndroidViewModel(application) { class ServiceViewModel(application: Application): AndroidViewModel(application) {
private val _isRunning = mutableStateOf(false) private val _isRunning = mutableStateOf(false)
val isRunning: State<Boolean> = _isRunning val isRunning: State<Boolean> = _isRunning
private val _hasVpnPermission = mutableStateOf(false) fun startService(context: Context, routes: Array<RouteItem>) {
val hasVpnPermission: State<Boolean> = _hasVpnPermission // should has the permission
startVpnService(context, routes)
fun setVPNPermission(isGranted: Boolean) {
_hasVpnPermission.value = isGranted
}
fun startService(context: Context, noPermissionCallback: (Intent)->Unit) {
val intent = VpnService.prepare(context)
if (intent != null) {
noPermissionCallback(intent)
} else {
startVpnService(context)
_isRunning.value = true _isRunning.value = true
} }
}
private fun startVpnService(context: Context) { private fun startVpnService(context: Context, routes: Array<RouteItem>) {
val intent = Intent(context, PunchnetService::class.java) val intent = Intent(context, PunchnetService::class.java)
intent.putExtra("argument", PunchnetServiceArgument( intent.putExtra("argument", PunchnetServiceArgument(
"", "",
arrayOf( routes,
RouteInfo( ))
"192.168.80.0/24", "10.211.188.2"
)
)
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent) context.startForegroundService(intent)
} else { } else {

View File

@ -1,6 +1,80 @@
package com.jihe.punchnet.helper package com.jihe.punchnet.helper
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.sdlan.network.maskDigitToInt
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
object ScreenRoutes: Screen("routes") object ScreenRoutes: Screen("routes")
object ScreenMain: Screen("main") object ScreenMain: Screen("main")
} }
fun IntToIPString(ip: Int): String {
val one = ((ip ushr 24) and 0x000000ff)
val two = ((ip ushr 16) and 0x000000ff)
val three = ((ip ushr 8) and 0x000000ff)
val four = ((ip) and 0x000000ff)
return "${one}.${two}.${three}.${four}"
}
fun IPStringToInt(ipStr: String): Int? {
val digits = ipStr.split('.').filter{
val single = try {
it.toInt()
} catch(e: Exception) {
return@filter false
}
single in 0..255
}
if (digits.size != 4) {
return null
}
val result: Int
try {
result = (digits[0].toInt() shl 24) +
(digits[1].toInt() shl 16) +
(digits[2].toInt() shl 8) +
(digits[3].toInt())
} catch(e: Exception) {
return null
}
return result
}
fun parseCIDRAndGW(cidr: String, gw: String): RouteItem? {
val cidrSplit = cidr.split('/')
var ip = 0
var digit = 0
if (cidrSplit.size == 1) {
digit = 32
} else if (cidrSplit.size == 2) {
try {
val k = cidrSplit[1].toInt()
if (k in 1..32) {
digit = k
} else {
return null
}
} catch (e: Exception) {
return null
}
} else {
return null
}
val ipvalue = IPStringToInt(cidrSplit[0])
val gateway = IPStringToInt(gw)
if (ipvalue == null) {
return null
}
if (gateway == null) {
return null
}
val mask = maskDigitToInt(digit)!!
return RouteItem(
net_ip = ipvalue,
gateway = gateway,
mask_ip = mask,
)
}

View File

@ -54,12 +54,22 @@ fun AppNavHost2(
} }
composable(Screen.RouteScreen.route) { composable(Screen.RouteScreen.route) {
CustomHeaderScreen( CustomHeaderScreen(
"光强计" "路由设置"
) { ) {
RouteScreen(!serviceViewModel.isRunning.value, routeViewModel)
// LightScreen() // LightScreen()
} }
} }
composable(Screen.ProfileScreen.route) { composable(Screen.ProfileScreen.route) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
"TODO",
style = MaterialTheme.typography.titleLarge,
)
}
// Profile(dbdao, navController) // Profile(dbdao, navController)
} }
} }

View File

@ -4,6 +4,7 @@ import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.VpnService
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -25,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -46,17 +48,18 @@ fun HomeScreen(
// started: MutableState<Boolean>, // started: MutableState<Boolean>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val allroutes = routeViewModel.allRoutes.collectAsState(emptyList())
val vpnLauncher = rememberLauncherForActivityResult( val vpnLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult() contract = ActivityResultContracts.StartActivityForResult()
) {result -> ) {result ->
when(result.resultCode) { when(result.resultCode) {
RESULT_OK -> { RESULT_OK -> {
serviceViewModel.setVPNPermission(true) // serviceViewModel.startService(context)
// serviceViewModel.setVPNPermission(true)
serviceViewModel.startService(context, allroutes.value.toTypedArray())
} }
RESULT_CANCELED -> { RESULT_CANCELED -> {
serviceViewModel.setVPNPermission(false)
Toast.makeText(context, "vpn permission denied", Toast.LENGTH_SHORT).show() Toast.makeText(context, "vpn permission denied", Toast.LENGTH_SHORT).show()
} }
} }
@ -115,12 +118,12 @@ fun HomeScreen(
// if is running, should stop service // if is running, should stop service
serviceViewModel.stopVpnService(context) serviceViewModel.stopVpnService(context)
} else { } else {
serviceViewModel.startService( val intent = VpnService.prepare(context)
context, if (intent != null) {
{intent->
vpnLauncher.launch(intent) vpnLauncher.launch(intent)
}, } else {
) serviceViewModel.startService(context, allroutes.value.toTypedArray())
}
} }
// started.value = !started.value // started.value = !started.value
}, },
@ -142,23 +145,3 @@ fun HomeScreen(
} }
} }
private fun startVpnService(context: Context) {
val intent = Intent(context, PunchnetService::class.java)
intent.putExtra("argument", PunchnetServiceArgument(
"",
arrayOf(
RouteInfo(
"192.168.80.0/24", "10.211.188.2"
)
)
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
Toast.makeText(context, "VPN service started", Toast.LENGTH_SHORT).show()
}

View File

@ -0,0 +1,211 @@
package com.jihe.punchnet.screen
import android.widget.Toast
import androidx.collection.emptyLongSet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.room.util.TableInfo
import com.jihe.punchnet.data.RouteItem
import com.jihe.punchnet.data.RouteViewModel
import com.jihe.punchnet.helper.IntToIPString
import com.jihe.punchnet.helper.parseCIDRAndGW
import com.jihe.punchnet.sdlan.network.maskIPToDigit
@Composable
fun AddOrModifyRoute(
routeViewModel: RouteViewModel,
initRoute: MutableState<RouteItem?>,
shouldShowRoute: MutableState<Boolean>,
) {
if (shouldShowRoute.value) {
val context = LocalContext.current
var cidr by remember {
mutableStateOf(
if (initRoute.value == null)
""
else
""
)
}
var gw by remember {
mutableStateOf(
if (initRoute.value == null)
""
else
""
)
}
AlertDialog(
onDismissRequest = {
shouldShowRoute.value = false
},
confirmButton = {
Button(
onClick = {
val item = parseCIDRAndGW(cidr, gw)
if (item == null) {
Toast.makeText(context, "数据解析出错", Toast.LENGTH_SHORT).show()
} else {
routeViewModel.insert(item)
shouldShowRoute.value = false
}
}
) {
Text("确定")
}
},
title = {
Text(
"添加新路由"
)
},
text = {
Column {
TextField(
value = cidr,
onValueChange = { newValue ->
cidr = newValue
},
placeholder = {
Text(
"192.168.0.0/24",
color = MaterialTheme.colorScheme.onBackground.copy(.5f)
)
},
label = {
Text("CIDR")
},
modifier = Modifier.padding(start = 16.dp)
)
TextField(
value = gw,
onValueChange = { newValue ->
gw = newValue
},
placeholder = {
Text(
"10.167.69.13",
color = MaterialTheme.colorScheme.onBackground.copy(.5f)
)
},
label = {
Text("gateway")
},
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
)
}
}
)
}
}
@Composable
fun RouteScreen(
enabled: Boolean,
routeViewModel: RouteViewModel,
modifier: Modifier = Modifier,
) {
val showAddRoute: MutableState<RouteItem?> = remember { mutableStateOf(null) }
val shouldShow: MutableState<Boolean> = remember { mutableStateOf(false) }
AddOrModifyRoute(routeViewModel, showAddRoute, shouldShow)
val routeItems by routeViewModel.allRoutes.collectAsState(initial = emptyList())
LazyColumn {
item {
Row(
) {
}
}
items(routeItems) {item ->
RouteCard(routeViewModel, item, enabled)
}
item {
Button(
enabled = enabled,
shape = RectangleShape,
onClick = {
shouldShow.value = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("添加路由")
}
}
}
}
@Composable
fun RouteCard(
routeViewModel: RouteViewModel,
item: RouteItem,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Card (
modifier = Modifier.fillMaxWidth()
){
Row (
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(36.dp)
){
Icon(
Icons.Default.Clear,
contentDescription = "delete route",
modifier = Modifier.size(30.dp).clickable(
enabled = enabled,
) {
routeViewModel.deleteById(item.id)
}
)
Text(
"${IntToIPString(item.net_ip)}/${maskIPToDigit(item.mask_ip)}",
modifier = Modifier.padding(start = 8.dp)
)
Text(
IntToIPString(item.gateway),
modifier = Modifier.padding(start = 8.dp)
)
}
}
}

View File

@ -45,11 +45,21 @@ fun cidrToRouteDetail(cidr: String, gw: String): RouteDetail? {
) )
} }
fun maskIPToDigit(mask: Int): Int {
var count = 0
var temp = mask
while (temp != 0) {
temp = temp shl 1
count += 1
}
return count
}
fun maskDigitToInt(digit: Int): Int? { fun maskDigitToInt(digit: Int): Int? {
if (digit <= 0 || digit >= 32) { if (digit <= 0 || digit >= 32) {
return null return null
} }
return ((2 shl (32-digit)) - 1) return ((1 shl (32-digit)) - 1).inv()
} }
fun ipStringToInt(ip: String): Int? { fun ipStringToInt(ip: String): Int? {

BIN
key-store/punchnet.jks Normal file

Binary file not shown.