1.0 能用
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/build
|
||||
/release
|
||||
/src/main/jniLibs
|
||||
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.bluemangoo.indpjsk"
|
||||
compileSdk {
|
||||
version = release(37)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.bluemangoo.indpjsk"
|
||||
minSdk = 28
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.19.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class net.bluemangoo.indpjsk.vpn.VpnBridge { *; }
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.IndPjsk">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.IndPjsk">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".vpn.CaptureVpnService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE" tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.bluemangoo.indpjsk
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.FileProvider
|
||||
import net.bluemangoo.indpjsk.ui.CaptureScreen
|
||||
import net.bluemangoo.indpjsk.ui.CaptureViewModel
|
||||
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: CaptureViewModel by viewModels()
|
||||
|
||||
private val vpnPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
viewModel.startCapture(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
IndPjskTheme {
|
||||
CaptureScreen(
|
||||
viewModel = viewModel,
|
||||
onStartCapture = { requestVpnAndStart() },
|
||||
onStopCapture = { viewModel.stopCapture(this) },
|
||||
onOpenFile = { openCapturedFile(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestVpnAndStart() {
|
||||
val prepare: Intent? = VpnService.prepare(this)
|
||||
if (prepare != null) {
|
||||
vpnPermissionLauncher.launch(prepare)
|
||||
} else {
|
||||
viewModel.startCapture(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCapturedFile(file: File) {
|
||||
if (!file.exists()) return
|
||||
val authority = "$packageName.fileprovider"
|
||||
val uri: Uri = FileProvider.getUriForFile(this, authority, file)
|
||||
val mime = mimeTypeFor(file.extension)
|
||||
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
try {
|
||||
startActivity(Intent.createChooser(viewIntent, "打开捕获文件"))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mime
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(shareIntent, "分享捕获文件"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(ext: String?): String = when (ext?.lowercase()) {
|
||||
"json" -> "application/json"
|
||||
"txt", "log" -> "text/plain"
|
||||
"xml" -> "application/xml"
|
||||
"html", "htm" -> "text/html"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.bluemangoo.indpjsk.data
|
||||
|
||||
import java.io.File
|
||||
|
||||
data class CaptureProfile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val steps: List<CaptureProfileStep>
|
||||
)
|
||||
|
||||
data class CaptureProfileStep(
|
||||
val id: String,
|
||||
val name: String,
|
||||
/** 该步骤可能命中的上游域名;TW/KR 的 cdn / https 两个域名都会出现 */
|
||||
val hosts: List<String>,
|
||||
/** 匹配 path + query 的正则(Rust 侧 Regex::is_match) */
|
||||
val path: String,
|
||||
val fileName: String,
|
||||
/**
|
||||
* 该步骤抓成功后异步执行(不阻塞下一步的监听)。
|
||||
* 没有填上传码时会走这里;参数是本次抓包的结果。
|
||||
*/
|
||||
val postAction: suspend (CaptureResult) -> Unit = {},
|
||||
)
|
||||
|
||||
/** 一步抓包完成后的结果,交给 postAction 用 */
|
||||
data class CaptureResult(
|
||||
val profile: CaptureProfile,
|
||||
val step: CaptureProfileStep,
|
||||
/** 命中的原始完整 URL(https://host/path?query,MySekai 的 flag 已改写为 True) */
|
||||
val url: String,
|
||||
/** 抓到的响应体文件 */
|
||||
val file: File,
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package net.bluemangoo.indpjsk.data
|
||||
|
||||
object PredefinedProfiles {
|
||||
|
||||
/**
|
||||
* 两个数据接口的正则(Host 已去掉,Rust 侧是拿 path + query 去匹配的):
|
||||
*
|
||||
* 1. Suite(个人信息 / 卡牌 / 仓库主数据)
|
||||
* Path 模板: /api/suite/user/{uid}
|
||||
*
|
||||
* 2. MySekai(烤森房间 / 地图全量数据)
|
||||
* Path 模板: /api/user/{uid}/mysekai?isForceAllReloadOnlyMysekai=True
|
||||
*/
|
||||
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$"
|
||||
private const val MYSEKAI_PATH =
|
||||
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
|
||||
|
||||
private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
|
||||
id = "suite",
|
||||
name = "Suite",
|
||||
hosts = hosts,
|
||||
path = SUITE_PATH,
|
||||
fileName = "suite"
|
||||
)
|
||||
|
||||
private fun mysekaiStep(hosts: List<String>) = CaptureProfileStep(
|
||||
id = "mysekai",
|
||||
name = "烤森",
|
||||
hosts = hosts,
|
||||
path = MYSEKAI_PATH,
|
||||
fileName = "mysekai"
|
||||
)
|
||||
|
||||
private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.dailygn.com")
|
||||
private val JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org")
|
||||
private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com")
|
||||
private val TW_HOSTS = listOf(
|
||||
"mk-zian-obt-cdn.bytedgame.com",
|
||||
"mk-zian-obt-https.bytedgame.com"
|
||||
)
|
||||
private val KR_HOSTS = listOf(
|
||||
"mkkorea-obt-prod01-cdn.bytedgame.com",
|
||||
"mkkorea-obt-prod01-https.bytedgame.com"
|
||||
)
|
||||
|
||||
val all: List<CaptureProfile> = listOf(
|
||||
// 国服不需要烤森
|
||||
CaptureProfile(
|
||||
id = "cn",
|
||||
name = "国服",
|
||||
steps = listOf(suiteStep(CN_HOSTS))
|
||||
),
|
||||
CaptureProfile(
|
||||
id = "jp",
|
||||
name = "日服",
|
||||
steps = listOf(suiteStep(JP_HOSTS), mysekaiStep(JP_HOSTS))
|
||||
),
|
||||
CaptureProfile(
|
||||
id = "en",
|
||||
name = "国际服",
|
||||
steps = listOf(suiteStep(EN_HOSTS), mysekaiStep(EN_HOSTS))
|
||||
),
|
||||
CaptureProfile(
|
||||
id = "tw",
|
||||
name = "台服",
|
||||
steps = listOf(suiteStep(TW_HOSTS), mysekaiStep(TW_HOSTS))
|
||||
),
|
||||
CaptureProfile(
|
||||
id = "kr",
|
||||
name = "韩服",
|
||||
steps = listOf(suiteStep(KR_HOSTS), mysekaiStep(KR_HOSTS))
|
||||
),
|
||||
)
|
||||
|
||||
fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package net.bluemangoo.indpjsk.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
|
||||
/** 一些零碎的全局设置,暂时只有上传码 */
|
||||
object SettingsStore {
|
||||
|
||||
private const val PREFS = "settings"
|
||||
private const val KEY_UPLOAD_CODE = "upload_code"
|
||||
|
||||
fun loadUploadCode(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_UPLOAD_CODE, "")
|
||||
.orEmpty()
|
||||
|
||||
fun saveUploadCode(context: Context, code: String) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putString(KEY_UPLOAD_CODE, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package net.bluemangoo.indpjsk.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import androidx.core.content.edit
|
||||
|
||||
/**
|
||||
* 每个 profile 下每个步骤「要不要抓」的持久化。
|
||||
*
|
||||
* 就一个 JSON 字符串塞进 SharedPreferences,键是 "profileId/stepId",值 Boolean,
|
||||
* 没存过的默认 true(全抓)。
|
||||
*/
|
||||
object StepSelectionStore {
|
||||
|
||||
private const val PREFS = "step_selection"
|
||||
private const val KEY_JSON = "json"
|
||||
|
||||
fun key(profileId: String, stepId: String) = "$profileId/$stepId"
|
||||
|
||||
fun loadAll(context: Context): MutableMap<String, Boolean> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_JSON, null) ?: return mutableMapOf()
|
||||
return try {
|
||||
val json = JSONObject(raw)
|
||||
val out = mutableMapOf<String, Boolean>()
|
||||
json.keys().forEach { k -> out[k] = json.optBoolean(k, true) }
|
||||
out
|
||||
} catch (_: Throwable) {
|
||||
mutableMapOf()
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnabled(all: Map<String, Boolean>, profileId: String, stepId: String): Boolean =
|
||||
all[key(profileId, stepId)] ?: true
|
||||
|
||||
fun saveAll(context: Context, all: Map<String, Boolean>) {
|
||||
val json = JSONObject()
|
||||
all.forEach { (k, v) -> json.put(k, v) }
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit { putString(KEY_JSON, json.toString()) }
|
||||
}
|
||||
|
||||
fun setEnabled(
|
||||
context: Context,
|
||||
profileId: String,
|
||||
stepId: String,
|
||||
enabled: Boolean
|
||||
): MutableMap<String, Boolean> {
|
||||
val all = loadAll(context)
|
||||
all[key(profileId, stepId)] = enabled
|
||||
saveAll(context, all)
|
||||
return all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package net.bluemangoo.indpjsk.state
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfile
|
||||
import net.bluemangoo.indpjsk.data.PredefinedProfiles
|
||||
import java.io.File
|
||||
|
||||
enum class CaptureStatus { IDLE, RUNNING, CAPTURED }
|
||||
|
||||
data class CaptureState(
|
||||
val profile: CaptureProfile,
|
||||
val status: CaptureStatus = CaptureStatus.IDLE,
|
||||
val lastCapturedFile: File? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
object CaptureStateHolder {
|
||||
|
||||
private val _state = MutableStateFlow(
|
||||
CaptureState(profile = PredefinedProfiles.all.first())
|
||||
)
|
||||
val state: StateFlow<CaptureState> = _state.asStateFlow()
|
||||
|
||||
@Synchronized
|
||||
fun selectProfile(profile: CaptureProfile) {
|
||||
if (_state.value.status == CaptureStatus.RUNNING) return
|
||||
_state.value = CaptureState(profile = profile)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setRunning() {
|
||||
_state.value = _state.value.copy(
|
||||
status = CaptureStatus.RUNNING,
|
||||
lastCapturedFile = null,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setCaptured(file: File) {
|
||||
_state.value = _state.value.copy(
|
||||
status = CaptureStatus.CAPTURED,
|
||||
lastCapturedFile = file,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setIdle() {
|
||||
_state.value = _state.value.copy(
|
||||
status = CaptureStatus.IDLE,
|
||||
lastCapturedFile = null,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setError(message: String) {
|
||||
_state.value = _state.value.copy(
|
||||
status = CaptureStatus.IDLE,
|
||||
errorMessage = message
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package net.bluemangoo.indpjsk.ui
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import net.bluemangoo.indpjsk.R
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfile
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfileStep
|
||||
import net.bluemangoo.indpjsk.data.SettingsStore
|
||||
import net.bluemangoo.indpjsk.data.StepSelectionStore
|
||||
import net.bluemangoo.indpjsk.state.CaptureStatus
|
||||
import net.bluemangoo.indpjsk.vpn.LogEntry
|
||||
import net.bluemangoo.indpjsk.vpn.LogLevel
|
||||
import net.bluemangoo.indpjsk.vpn.VpnLog
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CaptureScreen(
|
||||
viewModel: CaptureViewModel,
|
||||
onStartCapture: () -> Unit,
|
||||
onStopCapture: () -> Unit,
|
||||
onOpenFile: (File) -> Unit
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val logs by VpnLog.logs.collectAsState()
|
||||
var dropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
|
||||
var uploadCode by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
|
||||
|
||||
val isRunning = state.status == CaptureStatus.RUNNING
|
||||
val isCaptured = state.status == CaptureStatus.CAPTURED
|
||||
val selected = state.selectedProfile
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("咖喱烧烤无ROOT抓包") },
|
||||
actions = {
|
||||
IconButton(onClick = { VpnLog.clear() }) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.delete_24px),
|
||||
contentDescription = "清空日志"
|
||||
)
|
||||
}
|
||||
Box {
|
||||
TextButton(
|
||||
onClick = { dropdownExpanded = true },
|
||||
enabled = !isRunning
|
||||
) {
|
||||
Text(
|
||||
text = selected?.name ?: "无配置",
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.arrow_drop_down_24px),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = dropdownExpanded,
|
||||
onDismissRequest = { dropdownExpanded = false }
|
||||
) {
|
||||
state.profiles.forEach { profile ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(profile.name) },
|
||||
onClick = {
|
||||
viewModel.selectProfile(profile)
|
||||
dropdownExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uploadCode,
|
||||
onValueChange = {
|
||||
uploadCode = it.trim()
|
||||
SettingsStore.saveUploadCode(context, uploadCode)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isRunning,
|
||||
singleLine = true,
|
||||
label = { Text("上传码(从 Haruki 工具箱 - 数据上传 - iOS 获取)") },
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
|
||||
if (selected != null) {
|
||||
StepSelector(
|
||||
profile = selected,
|
||||
enabledMap = enabledMap,
|
||||
editable = !isRunning,
|
||||
onToggle = { step, checked ->
|
||||
enabledMap = StepSelectionStore.setEnabled(
|
||||
context, selected.id, step.id, checked
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LogPanel(
|
||||
logs = logs,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
StatusLine(status = state.status, error = state.errorMessage)
|
||||
|
||||
Button(
|
||||
onClick = if (isRunning) onStopCapture else onStartCapture,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
colors = if (isRunning) {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
} else ButtonDefaults.buttonColors()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (isRunning) R.drawable.stop_24px else R.drawable.play_arrow_24px
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isRunning) "停止监听" else "启动抓包")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { state.lastCapturedFile?.let(onOpenFile) },
|
||||
enabled = isCaptured && state.lastCapturedFile != null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.file_open_24px),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("打开捕获文件")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepSelector(
|
||||
profile: CaptureProfile,
|
||||
enabledMap: Map<String, Boolean>,
|
||||
editable: Boolean,
|
||||
onToggle: (CaptureProfileStep, Boolean) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "抓包步骤",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
profile.steps.forEach { step ->
|
||||
val checked = StepSelectionStore.isEnabled(enabledMap, profile.id, step.id)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { onToggle(step, it) },
|
||||
enabled = editable
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(step.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogPanel(logs: List<LogEntry>, modifier: Modifier = Modifier) {
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.lastIndex)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
if (logs.isEmpty()) {
|
||||
Text(
|
||||
text = "暂无日志,点击下方按钮开始抓包",
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(logs) { entry -> LogRow(entry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogRow(entry: LogEntry) {
|
||||
val color = when (entry.level) {
|
||||
LogLevel.ERROR -> MaterialTheme.colorScheme.error
|
||||
LogLevel.WARN -> Color(0xFFB26A00)
|
||||
LogLevel.SUCCESS -> Color(0xFF2E7D32)
|
||||
LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = entry.time,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = entry.message,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusLine(status: CaptureStatus, error: String?) {
|
||||
val text = when (status) {
|
||||
CaptureStatus.IDLE -> if (error != null) "失败:$error" else "就绪"
|
||||
CaptureStatus.RUNNING -> "正在监听目标流量…"
|
||||
CaptureStatus.CAPTURED -> "抓包成功,可打开文件"
|
||||
}
|
||||
val color = when (status) {
|
||||
CaptureStatus.RUNNING -> MaterialTheme.colorScheme.primary
|
||||
CaptureStatus.CAPTURED -> Color(0xFF2E7D32)
|
||||
CaptureStatus.IDLE ->
|
||||
if (error != null) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package net.bluemangoo.indpjsk.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfile
|
||||
import net.bluemangoo.indpjsk.data.PredefinedProfiles
|
||||
import net.bluemangoo.indpjsk.state.CaptureStateHolder
|
||||
import net.bluemangoo.indpjsk.state.CaptureStatus
|
||||
import net.bluemangoo.indpjsk.vpn.CaptureVpnService
|
||||
import net.bluemangoo.indpjsk.vpn.VpnLog
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.io.File
|
||||
|
||||
data class CaptureUiState(
|
||||
val profiles: List<CaptureProfile> = emptyList(),
|
||||
val selectedProfile: CaptureProfile? = null,
|
||||
val status: CaptureStatus = CaptureStatus.IDLE,
|
||||
val lastCapturedFile: File? = null,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
class CaptureViewModel : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state
|
||||
.map { s ->
|
||||
CaptureUiState(
|
||||
profiles = PredefinedProfiles.all,
|
||||
selectedProfile = s.profile,
|
||||
status = s.status,
|
||||
lastCapturedFile = s.lastCapturedFile,
|
||||
errorMessage = s.errorMessage
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = CaptureUiState(
|
||||
profiles = PredefinedProfiles.all,
|
||||
selectedProfile = PredefinedProfiles.all.firstOrNull()
|
||||
)
|
||||
)
|
||||
|
||||
fun selectProfile(profile: CaptureProfile) {
|
||||
CaptureStateHolder.selectProfile(profile)
|
||||
}
|
||||
|
||||
fun startCapture(context: Context) {
|
||||
val profile = uiState.value.selectedProfile ?: return
|
||||
VpnLog.clear()
|
||||
CaptureStateHolder.setRunning()
|
||||
|
||||
val intent = Intent(context, CaptureVpnService::class.java).apply {
|
||||
action = CaptureVpnService.ACTION_START
|
||||
putExtra(CaptureVpnService.EXTRA_PROFILE_ID, profile.id)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun stopCapture(context: Context) {
|
||||
val intent = Intent(context, CaptureVpnService::class.java).apply {
|
||||
action = CaptureVpnService.ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.bluemangoo.indpjsk.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,53 @@
|
||||
package net.bluemangoo.indpjsk.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun IndPjskTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package net.bluemangoo.indpjsk.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package net.bluemangoo.indpjsk.upload
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.bluemangoo.indpjsk.vpn.VpnLog
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.RandomAccessFile
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* 把抓到的响应体上传到工具箱:
|
||||
*
|
||||
* POST https://toolbox-api-direct.haruki.seiunx.com/ios/script/{upload_code}/upload
|
||||
* Content-Type: application/octet-stream (原始二进制,每片最大 1MB)
|
||||
* X-Script-Version: 1.0.1
|
||||
* X-Upload-Code: {upload_code}
|
||||
* X-Upload-Id: 9 位随机字母数字(同一文件的所有分片共用)
|
||||
* X-Chunk-Index: "0", "1", ...
|
||||
* X-Total-Chunks: 分片总数(ceil(size / 1MB))
|
||||
* X-Original-Url: 抓到的原始游戏 URL(后端用它识别区服 / UID / 数据类型)
|
||||
*
|
||||
* 分片串行发送,每一片都必须返回 HTTP 200。
|
||||
*/
|
||||
object Uploader {
|
||||
|
||||
private const val BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
|
||||
private const val CHUNK_SIZE = 1024 * 1024
|
||||
private const val SCRIPT_VERSION = "1.0.1"
|
||||
|
||||
suspend fun upload(
|
||||
uploadCode: String,
|
||||
originalUrl: String,
|
||||
file: File,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
|
||||
|
||||
val uploadId = randomUploadId()
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val size = raf.length()
|
||||
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
|
||||
val buf = ByteArray(CHUNK_SIZE)
|
||||
VpnLog.info("${file.name} 上传开始")
|
||||
|
||||
for (index in 0 until total) {
|
||||
val remaining = size - raf.filePointer
|
||||
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
|
||||
if (len > 0) raf.readFully(buf, 0, len)
|
||||
val code = postChunk(uploadCode, uploadId, index, total, originalUrl, buf, len)
|
||||
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
|
||||
}
|
||||
VpnLog.info("${file.name} 上传完成")
|
||||
}
|
||||
}
|
||||
|
||||
private fun postChunk(
|
||||
uploadCode: String,
|
||||
uploadId: String,
|
||||
index: Int,
|
||||
total: Int,
|
||||
originalUrl: String,
|
||||
data: ByteArray,
|
||||
length: Int,
|
||||
): Int {
|
||||
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "POST"
|
||||
conn.doOutput = true
|
||||
conn.instanceFollowRedirects = false
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 60_000
|
||||
conn.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
conn.setRequestProperty("X-Script-Version", SCRIPT_VERSION)
|
||||
conn.setRequestProperty("X-Upload-Code", uploadCode)
|
||||
conn.setRequestProperty("X-Upload-Id", uploadId)
|
||||
conn.setRequestProperty("X-Chunk-Index", index.toString())
|
||||
conn.setRequestProperty("X-Total-Chunks", total.toString())
|
||||
conn.setRequestProperty("X-Original-Url", originalUrl)
|
||||
conn.setFixedLengthStreamingMode(length)
|
||||
conn.outputStream.use { it.write(data, 0, length) }
|
||||
|
||||
val code = conn.responseCode
|
||||
if (code != 200) {
|
||||
val body = runCatching {
|
||||
(conn.errorStream ?: conn.inputStream)
|
||||
?.bufferedReader()
|
||||
?.use { it.readText() }
|
||||
?.take(300)
|
||||
}.getOrNull().orEmpty()
|
||||
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
|
||||
}
|
||||
return code
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** 9 位随机字母数字,同一文件所有分片共用一个 */
|
||||
private fun randomUploadId(): String {
|
||||
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
return buildString(9) {
|
||||
repeat(9) { append(chars[Random.nextInt(chars.length)]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package net.bluemangoo.indpjsk.vpn
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfile
|
||||
import net.bluemangoo.indpjsk.data.CaptureProfileStep
|
||||
import net.bluemangoo.indpjsk.data.CaptureResult
|
||||
import net.bluemangoo.indpjsk.data.PredefinedProfiles
|
||||
import net.bluemangoo.indpjsk.data.SettingsStore
|
||||
import net.bluemangoo.indpjsk.data.StepSelectionStore
|
||||
import net.bluemangoo.indpjsk.state.CaptureStateHolder
|
||||
import net.bluemangoo.indpjsk.upload.Uploader
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 多步骤抓包:
|
||||
* establish() 只做一次,同一个 tun fd 按顺序喂给每一步,中间不拆 VPN。
|
||||
* 每一步抓到后异步跑它的 postAction(不阻塞下一步的监听)。
|
||||
*
|
||||
* 停止机制:
|
||||
* ACTION_STOP → requestStop() + close(activePfd),当前步骤的 runCapture 返回 null,
|
||||
* 循环发现 stopRequested 后终止后续步骤。
|
||||
*
|
||||
* fd 所有权:
|
||||
* 全生命周期在 Android 侧持有。传给 Rust 的是 pfd.fd(int 缓存值),
|
||||
* Rust 内部用 BorrowedAsyncDevice 借用,不做任何 close。
|
||||
* Android 在 finally 里统一 close(activePfd)。
|
||||
*/
|
||||
@SuppressLint("VpnServicePolicy")
|
||||
class CaptureVpnService : VpnService() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "net.bluemangoo.indpjsk.START"
|
||||
const val ACTION_STOP = "net.bluemangoo.indpjsk.STOP"
|
||||
|
||||
const val EXTRA_PROFILE_ID = "extra_profile_id"
|
||||
|
||||
private const val VPN_ADDRESS = "10.7.0.2"
|
||||
private const val VPN_PREFIX = 32
|
||||
|
||||
/**
|
||||
* 后处理(上传)用的 scope:进程级,故意不随 Service 销毁取消。
|
||||
* 否则 stopSelf() → onDestroy 会把还在上传的分片掐掉(Job was cancelled)。
|
||||
*/
|
||||
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
/** 捕获文件统一放在 filesDir/captures/<profileId>/ 下(file_paths.xml 的 FileProvider 路径) */
|
||||
fun captureFile(
|
||||
filesDir: File,
|
||||
profile: CaptureProfile,
|
||||
step: CaptureProfileStep
|
||||
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
|
||||
}
|
||||
|
||||
/** 工作线程持有;供 ACTION_STOP 从主线程关闭以中断 Rust */
|
||||
@Volatile
|
||||
private var activePfd: ParcelFileDescriptor? = null
|
||||
|
||||
@Volatile
|
||||
private var captureThread: Thread? = null
|
||||
|
||||
/** 区分"用户主动停止"与"抓到包后自然结束/失败" */
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
val id = intent.getStringExtra(EXTRA_PROFILE_ID) ?: return START_NOT_STICKY
|
||||
val profile = PredefinedProfiles.byId(id) ?: return START_NOT_STICKY
|
||||
startCapture(profile)
|
||||
}
|
||||
|
||||
ACTION_STOP -> {
|
||||
stopRequested = true
|
||||
VpnLog.warn("手动停止…")
|
||||
VpnBridge.requestStop()
|
||||
closeActivePfd()
|
||||
// 立即把 UI 从 RUNNING 拉回来;capture 线程退出时还会再写一次
|
||||
CaptureStateHolder.setIdle()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
else -> return START_NOT_STICKY
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun closeActivePfd() {
|
||||
val pfd = activePfd
|
||||
activePfd = null
|
||||
try {
|
||||
pfd?.close()
|
||||
VpnBridge.requestStop()
|
||||
} catch (t: Throwable) {
|
||||
VpnLog.warn("关闭代理失败: ${t.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCapture(profile: CaptureProfile) {
|
||||
if (captureThread?.isAlive == true) {
|
||||
VpnLog.warn("抓包已在运行,忽略重复启动")
|
||||
return
|
||||
}
|
||||
|
||||
// 按 profile 里的顺序,过滤掉没勾选的步骤(默认全勾)
|
||||
val savedSelection = StepSelectionStore.loadAll(this)
|
||||
val enabledSteps = profile.steps.filter { step ->
|
||||
StepSelectionStore.isEnabled(savedSelection, profile.id, step.id)
|
||||
}
|
||||
if (enabledSteps.isEmpty()) {
|
||||
VpnLog.error("没有勾选任何步骤")
|
||||
CaptureStateHolder.setError("没有勾选任何步骤")
|
||||
return
|
||||
}
|
||||
|
||||
// 上传码在开始时快照,运行中改 UI 不影响本次
|
||||
val uploadCode = SettingsStore.loadUploadCode(this).trim()
|
||||
if (uploadCode.isNotEmpty() && uploadCode.length != 32) {
|
||||
VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败")
|
||||
}
|
||||
|
||||
stopRequested = false
|
||||
VpnLog.info("==== 开始抓包 ====")
|
||||
VpnLog.info("配置: ${profile.name}")
|
||||
VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}")
|
||||
|
||||
captureThread = Thread({
|
||||
var pfd: ParcelFileDescriptor?
|
||||
var lastFile: File? = null
|
||||
var completed = 0
|
||||
var errorMsg: String? = null
|
||||
|
||||
try {
|
||||
if (!VpnBridge.isAvailable()) {
|
||||
throw IllegalStateException("Rust 动态库 core_vpn 未加载")
|
||||
}
|
||||
|
||||
// 2) 建立 VPN:仅 /32 目标路由 + 排除自身(防止 Rust 转发流量回环)
|
||||
val builder = Builder()
|
||||
.setSession("Capture-${profile.id}")
|
||||
.addAddress(VPN_ADDRESS, VPN_PREFIX)
|
||||
.addDisallowedApplication(packageName)// 1.0.0.0/8 (排除 0.0.0.0/8 本地识别地址)
|
||||
.addRoute("1.0.0.0", 8)
|
||||
|
||||
.addRoute("2.0.0.0", 7)
|
||||
.addRoute("4.0.0.0", 6)
|
||||
.addRoute("8.0.0.0", 7)
|
||||
|
||||
.addRoute("11.0.0.0", 8)
|
||||
.addRoute("12.0.0.0", 6)
|
||||
.addRoute("16.0.0.0", 4)
|
||||
.addRoute("32.0.0.0", 3)
|
||||
.addRoute("64.0.0.0", 5)
|
||||
.addRoute("96.0.0.0", 10)
|
||||
|
||||
.addRoute("100.128.0.0", 9)
|
||||
.addRoute("101.0.0.0", 8)
|
||||
.addRoute("102.0.0.0", 7)
|
||||
.addRoute("104.0.0.0", 5)
|
||||
.addRoute("112.0.0.0", 4)
|
||||
|
||||
.addRoute("128.0.0.0", 3)
|
||||
.addRoute("160.0.0.0", 5)
|
||||
.addRoute("168.0.0.0", 6)
|
||||
.addRoute("169.0.0.0", 9)
|
||||
.addRoute("169.128.0.0", 10)
|
||||
.addRoute("169.192.0.0", 11)
|
||||
.addRoute("169.224.0.0", 12)
|
||||
.addRoute("169.240.0.0", 13)
|
||||
.addRoute("169.248.0.0", 14)
|
||||
.addRoute("169.252.0.0", 15)
|
||||
|
||||
.addRoute("169.255.0.0", 16)
|
||||
.addRoute("170.0.0.0", 7)
|
||||
.addRoute("172.0.0.0", 12)
|
||||
|
||||
.addRoute("172.32.0.0", 11)
|
||||
.addRoute("172.64.0.0", 10)
|
||||
.addRoute("172.128.0.0", 9)
|
||||
.addRoute("173.0.0.0", 8)
|
||||
.addRoute("174.0.0.0", 7)
|
||||
.addRoute("176.0.0.0", 4)
|
||||
.addRoute("192.0.0.0", 9)
|
||||
.addRoute("192.128.0.0", 11)
|
||||
.addRoute("192.160.0.0", 13)
|
||||
.addRoute("192.169.0.0", 16)
|
||||
.addRoute("192.170.0.0", 15)
|
||||
.addRoute("192.172.0.0", 14)
|
||||
.addRoute("192.176.0.0", 12)
|
||||
.addRoute("192.192.0.0", 10)
|
||||
|
||||
.addRoute("193.0.0.0", 8)
|
||||
.addRoute("194.0.0.0", 7)
|
||||
.addRoute("196.0.0.0", 6)
|
||||
.addRoute("200.0.0.0", 5)
|
||||
.addRoute("208.0.0.0", 4)
|
||||
.addRoute("::", 0)
|
||||
.addDnsServer("8.8.8.8")
|
||||
.addDnsServer("114.114.114.114")
|
||||
|
||||
pfd = builder.establish()
|
||||
?: throw IllegalStateException("VpnService.establish() 返回 null")
|
||||
activePfd = pfd
|
||||
val fd = pfd.fd
|
||||
VpnLog.info("网络代理已连接")
|
||||
|
||||
for ((index, step) in enabledSteps.withIndex()) {
|
||||
if (stopRequested) {
|
||||
VpnLog.warn("用户停止,终止后续步骤")
|
||||
break
|
||||
}
|
||||
|
||||
val label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
|
||||
val target = captureFile(filesDir, profile, step)
|
||||
target.parentFile?.mkdirs()
|
||||
if (target.exists()) target.delete()
|
||||
|
||||
// 阻塞调用 Rust,直到抓到本步骤的目标请求(或停止 / 出错)
|
||||
val capturedUrl = VpnBridge.runCapture(
|
||||
fd,
|
||||
step.hosts.joinToString(","),
|
||||
step.path,
|
||||
target.absolutePath
|
||||
)
|
||||
|
||||
if (capturedUrl == null) {
|
||||
if (stopRequested) {
|
||||
VpnLog.warn("$label 被用户停止")
|
||||
break
|
||||
}
|
||||
errorMsg = "$label 未抓到目标请求"
|
||||
VpnLog.error(errorMsg)
|
||||
break
|
||||
}
|
||||
if (!target.exists() || target.length() == 0L) {
|
||||
errorMsg = "$label 返回了 URL 但文件为空"
|
||||
VpnLog.error(errorMsg)
|
||||
break
|
||||
}
|
||||
|
||||
lastFile = target
|
||||
completed++
|
||||
VpnLog.success("$label 抓取成功")
|
||||
|
||||
// 抓到后异步做后处理(上传 / 该步骤自己的 postAction),不阻塞下一步监听
|
||||
val result = CaptureResult(profile, step, capturedUrl, target)
|
||||
postScope.launch {
|
||||
try {
|
||||
if (uploadCode.isNotEmpty()) {
|
||||
VpnLog.info("$label 开始上传")
|
||||
Uploader.upload(uploadCode, capturedUrl, target)
|
||||
VpnLog.success("${profile.name}${step.name} 上传完成")
|
||||
} else {
|
||||
step.postAction(result)
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
VpnLog.warn("$label 后处理被取消: ${c.message}")
|
||||
throw c
|
||||
} catch (t: Throwable) {
|
||||
VpnLog.error("$label 后处理失败: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
errorMsg = t.message ?: "抓包异常"
|
||||
VpnLog.error("抓包异常: ${t.message}")
|
||||
} finally {
|
||||
// 关闭 fd(若 ACTION_STOP 已关,这次 close 是幂等的/会抛可忽略异常)
|
||||
closeActivePfd()
|
||||
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
|
||||
|
||||
if (errorMsg != null) {
|
||||
CaptureStateHolder.setError(errorMsg)
|
||||
} else if (completed == enabledSteps.size && lastFile != null) {
|
||||
CaptureStateHolder.setCaptured(lastFile)
|
||||
} else {
|
||||
CaptureStateHolder.setIdle()
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
}, "capture-blocking-worker").apply { start() }
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopRequested = true
|
||||
VpnLog.warn("VPN 权限被撤销")
|
||||
closeActivePfd()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
closeActivePfd()
|
||||
captureThread = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package net.bluemangoo.indpjsk.vpn
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Rust core_vpn 的 JNI 桥。
|
||||
*
|
||||
* 契约(由 Rust lib.rs 实现):
|
||||
* runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath) -> String?
|
||||
* - 阻塞直到抓包结束
|
||||
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
|
||||
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
|
||||
* - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath
|
||||
* - 返回 null = 失败 / 被 requestStop 打断 / 未匹配
|
||||
*
|
||||
* requestStop() -> void
|
||||
* - 立即返回,仅设置原子停止标志,使阻塞中的 runCapture 尽快返回 false
|
||||
*
|
||||
* nativeLog(String)(Kotlin -> Rust 回调的落地端)
|
||||
* - Rust 通过 JNIEnv.call_static_method(".../VpnBridge", "nativeLog", ...) 调用
|
||||
* - 用于把 TLS 握手 / 匹配结果等关键日志写进 UI 日志框
|
||||
*/
|
||||
object VpnBridge {
|
||||
|
||||
private const val TAG = "VpnBridge"
|
||||
private var loaded = false
|
||||
|
||||
init {
|
||||
loaded = try {
|
||||
System.loadLibrary("core_vpn")
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "loadLibrary core_vpn failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = loaded
|
||||
|
||||
@JvmStatic
|
||||
external fun runCapture(
|
||||
tunFd: Int,
|
||||
targetHosts: String,
|
||||
targetPath: String,
|
||||
savePath: String
|
||||
): String?
|
||||
|
||||
@JvmStatic
|
||||
external fun requestStop()
|
||||
|
||||
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
|
||||
@Suppress("unused")
|
||||
@JvmStatic
|
||||
fun nativeLog(level: String, msg: String) {
|
||||
when (level) {
|
||||
"error" -> VpnLog.error(msg)
|
||||
"warn" -> VpnLog.warn(msg)
|
||||
"info" -> VpnLog.info(msg)
|
||||
else -> VpnLog.info(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package net.bluemangoo.indpjsk.vpn
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
enum class LogLevel { INFO, WARN, ERROR, SUCCESS }
|
||||
|
||||
data class LogEntry(
|
||||
val time: String,
|
||||
val level: LogLevel,
|
||||
val message: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 全局日志容器:
|
||||
* - Android 侧各处直接调用 info/warn/error/success
|
||||
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
|
||||
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
|
||||
*/
|
||||
object VpnLog {
|
||||
|
||||
private const val MAX = 800
|
||||
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
|
||||
|
||||
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
|
||||
|
||||
private fun append(level: LogLevel, msg: String) {
|
||||
val entry = LogEntry(
|
||||
time = LocalTime.now().format(timeFmt),
|
||||
level = level,
|
||||
message = msg
|
||||
)
|
||||
_logs.update { (it + entry).takeLast(MAX) }
|
||||
}
|
||||
|
||||
fun info(msg: String) = append(LogLevel.INFO, msg)
|
||||
fun warn(msg: String) = append(LogLevel.WARN, msg)
|
||||
fun error(msg: String) = append(LogLevel.ERROR, msg)
|
||||
fun success(msg: String) = append(LogLevel.SUCCESS, msg)
|
||||
|
||||
fun clear() {
|
||||
_logs.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,600L280,400L680,400L480,600Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M320,320L320,320L320,640L320,640L320,320ZM240,720L240,240L720,240L720,720L240,720ZM320,640L640,640L640,320L320,320L320,640Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_android_black_24dp" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_android_black_24dp" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#00E676</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">咖喱烧烤</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.IndPjsk" parent="android:Theme.Material.Light.NoActionBar"/>
|
||||
</resources>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<files-path name="captures" path="captures/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user