From eea818f5f45789a9aaf278cc93042ea0aa7703ab Mon Sep 17 00:00:00 2001 From: Bluemangoo Date: Sat, 24 Oct 2026 14:46:17 +0800 Subject: [PATCH] =?UTF-8?q?1.0=20=E8=83=BD=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + app/.gitignore | 3 + app/build.gradle.kts | 46 + app/proguard-rules.pro | 26 + app/src/main/AndroidManifest.xml | 46 + .../net/bluemangoo/indpjsk/MainActivity.kt | 83 + .../indpjsk/data/CaptureProfileStep.kt | 34 + .../indpjsk/data/PredefinedProfiles.kt | 76 + .../bluemangoo/indpjsk/data/SettingsStore.kt | 23 + .../indpjsk/data/StepSelectionStore.kt | 54 + .../indpjsk/state/CaptureStateHolder.kt | 66 + .../bluemangoo/indpjsk/ui/CaptureScreen.kt | 331 +++ .../bluemangoo/indpjsk/ui/CaptureViewModel.kt | 70 + .../net/bluemangoo/indpjsk/ui/theme/Color.kt | 11 + .../net/bluemangoo/indpjsk/ui/theme/Theme.kt | 53 + .../net/bluemangoo/indpjsk/ui/theme/Type.kt | 34 + .../net/bluemangoo/indpjsk/upload/Uploader.kt | 107 + .../indpjsk/vpn/CaptureVpnService.kt | 305 +++ .../net/bluemangoo/indpjsk/vpn/VpnBridge.kt | 62 + .../java/net/bluemangoo/indpjsk/vpn/VpnLog.kt | 49 + .../res/drawable/arrow_drop_down_24px.xml | 9 + app/src/main/res/drawable/delete_24px.xml | 9 + app/src/main/res/drawable/file_open_24px.xml | 9 + .../res/drawable/ic_android_black_24dp.xml | 5 + app/src/main/res/drawable/play_arrow_24px.xml | 9 + app/src/main/res/drawable/stop_24px.xml | 9 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/backup_rules.xml | 14 + .../main/res/xml/data_extraction_rules.xml | 20 + app/src/main/res/xml/file_paths.xml | 4 + build.gradle.kts | 5 + core_vpn/.gitignore | 3 + core_vpn/Cargo.lock | 1966 +++++++++++++++++ core_vpn/Cargo.toml | 36 + core_vpn/build.rs | 156 ++ core_vpn/src/cert.rs | 26 + core_vpn/src/lib.rs | 166 ++ core_vpn/src/mitm.rs | 587 +++++ core_vpn/src/tun.rs | 205 ++ gradle.properties | 15 + gradle/wrapper/gradle-wrapper.properties | 2 + local.properties | 10 + settings.gradle.kts | 23 + 48 files changed, 4802 insertions(+) create mode 100644 .gitignore create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/state/CaptureStateHolder.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Type.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt create mode 100644 app/src/main/res/drawable/arrow_drop_down_24px.xml create mode 100644 app/src/main/res/drawable/delete_24px.xml create mode 100644 app/src/main/res/drawable/file_open_24px.xml create mode 100644 app/src/main/res/drawable/ic_android_black_24dp.xml create mode 100644 app/src/main/res/drawable/play_arrow_24px.xml create mode 100644 app/src/main/res/drawable/stop_24px.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 build.gradle.kts create mode 100644 core_vpn/.gitignore create mode 100644 core_vpn/Cargo.lock create mode 100644 core_vpn/Cargo.toml create mode 100644 core_vpn/build.rs create mode 100644 core_vpn/src/cert.rs create mode 100644 core_vpn/src/lib.rs create mode 100644 core_vpn/src/mitm.rs create mode 100644 core_vpn/src/tun.rs create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 local.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2d0900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea +/.kotlin +/.gradle +/build \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..088ed5b --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +/build +/release +/src/main/jniLibs \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..cf57ab4 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..76ca5cb --- /dev/null +++ b/app/proguard-rules.pro @@ -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 ; +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..30e24f2 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt new file mode 100644 index 0000000..cdb4b38 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt new file mode 100644 index 0000000..f9d2292 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt @@ -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 +) + +data class CaptureProfileStep( + val id: String, + val name: String, + /** 该步骤可能命中的上游域名;TW/KR 的 cdn / https 两个域名都会出现 */ + val hosts: List, + /** 匹配 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, +) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt new file mode 100644 index 0000000..1b1637c --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt @@ -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) = CaptureProfileStep( + id = "suite", + name = "Suite", + hosts = hosts, + path = SUITE_PATH, + fileName = "suite" + ) + + private fun mysekaiStep(hosts: List) = 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 = 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 } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt new file mode 100644 index 0000000..01a0fbb --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt @@ -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) + } + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt new file mode 100644 index 0000000..111c6a8 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt @@ -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 { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_JSON, null) ?: return mutableMapOf() + return try { + val json = JSONObject(raw) + val out = mutableMapOf() + json.keys().forEach { k -> out[k] = json.optBoolean(k, true) } + out + } catch (_: Throwable) { + mutableMapOf() + } + } + + fun isEnabled(all: Map, profileId: String, stepId: String): Boolean = + all[key(profileId, stepId)] ?: true + + fun saveAll(context: Context, all: Map) { + 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 { + val all = loadAll(context) + all[key(profileId, stepId)] = enabled + saveAll(context, all) + return all + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/state/CaptureStateHolder.kt b/app/src/main/java/net/bluemangoo/indpjsk/state/CaptureStateHolder.kt new file mode 100644 index 0000000..0af2aa1 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/state/CaptureStateHolder.kt @@ -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 = _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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt new file mode 100644 index 0000000..289890b --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt @@ -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) } + 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, + 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, 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() +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt new file mode 100644 index 0000000..06a1ddb --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt @@ -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 = emptyList(), + val selectedProfile: CaptureProfile? = null, + val status: CaptureStatus = CaptureStatus.IDLE, + val lastCapturedFile: File? = null, + val errorMessage: String? = null +) + +class CaptureViewModel : ViewModel() { + + val uiState: StateFlow = 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt new file mode 100644 index 0000000..ee80048 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt new file mode 100644 index 0000000..2ab0564 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Type.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Type.kt new file mode 100644 index 0000000..b1a10e4 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt new file mode 100644 index 0000000..1c4416d --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt @@ -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)]) } + } + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt new file mode 100644 index 0000000..585530a --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt @@ -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// 下(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() + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt new file mode 100644 index 0000000..4332901 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt new file mode 100644 index 0000000..ac4916d --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt @@ -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>(emptyList()) + val logs: StateFlow> = _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() + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_drop_down_24px.xml b/app/src/main/res/drawable/arrow_drop_down_24px.xml new file mode 100644 index 0000000..7d3040f --- /dev/null +++ b/app/src/main/res/drawable/arrow_drop_down_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete_24px.xml b/app/src/main/res/drawable/delete_24px.xml new file mode 100644 index 0000000..2b9dbee --- /dev/null +++ b/app/src/main/res/drawable/delete_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/file_open_24px.xml b/app/src/main/res/drawable/file_open_24px.xml new file mode 100644 index 0000000..7a8b6d8 --- /dev/null +++ b/app/src/main/res/drawable/file_open_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_android_black_24dp.xml b/app/src/main/res/drawable/ic_android_black_24dp.xml new file mode 100644 index 0000000..171b390 --- /dev/null +++ b/app/src/main/res/drawable/ic_android_black_24dp.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/play_arrow_24px.xml b/app/src/main/res/drawable/play_arrow_24px.xml new file mode 100644 index 0000000..582bcd1 --- /dev/null +++ b/app/src/main/res/drawable/play_arrow_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/stop_24px.xml b/app/src/main/res/drawable/stop_24px.xml new file mode 100644 index 0000000..f3cbd5f --- /dev/null +++ b/app/src/main/res/drawable/stop_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..df1969c --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..df1969c --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..0cd6db3 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #00E676 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8deb5a1 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 咖喱烧烤 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..de99f59 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +