2 Commits
Author SHA1 Message Date
Bluemangoo 0cf352cabb 重构上传 2026-10-29 14:39:14 +08:00
Bluemangoo 5739f7f8e7 复用连接 2026-10-28 13:51:20 +08:00
23 changed files with 1882 additions and 469 deletions
+11 -2
View File
@@ -5,11 +5,12 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 使用
- 安装并打开应用
- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码并粘贴到应用中
- 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 在右上角选择需要的配置
- 可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束,如果配置了上传会自动上传
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
## 触发位置
@@ -20,3 +21,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 上游代理
你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。
在「代理设置」里填写代理地址后,还要勾选「使用上游代理」才会生效;地址为空时无法勾选(等同于直连)。
「持续运行」勾选后抓到包不会自动退出。
## 自定义上传配置
我们提供了三个Header的占位符:`$original_url` `$file_name` `$user_id`
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId = "net.bluemangoo.indpjsk"
minSdk = appMinSdk
targetSdk = 37
versionCode = 3
versionName = "1.2"
versionCode = 4
versionName = "1.3"
}
@@ -25,10 +25,11 @@ import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.CapturedFilesScreen
import net.bluemangoo.indpjsk.ui.ProxySettingsScreen
import net.bluemangoo.indpjsk.ui.UploadSettingsScreen
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File
enum class AppScreen { MAIN, PROXY, APP_SELECTION, FILES, ABOUT }
enum class AppScreen { MAIN, PROXY, UPLOAD, APP_SELECTION, FILES, ABOUT }
class MainActivity : ComponentActivity() {
@@ -43,7 +44,7 @@ class MainActivity : ComponentActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
SettingsStore.loadSelectedProfileId(this)
SettingsStore.selectedProfileId
?.let { PredefinedProfiles.byId(it) }
?.let { CaptureStateHolder.selectProfile(it) }
@@ -52,7 +53,7 @@ class MainActivity : ComponentActivity() {
IndPjskTheme {
var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) }
var bypassPackages by remember {
mutableStateOf(SettingsStore.loadBypassPackages(this))
mutableStateOf(SettingsStore.bypassPackages)
}
val readme = remember {
runCatching {
@@ -73,15 +74,20 @@ class MainActivity : ComponentActivity() {
onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) },
onOpenProxySettings = { screen = AppScreen.PROXY },
onOpenUploadSettings = { screen = AppScreen.UPLOAD },
onOpenFiles = { screen = AppScreen.FILES },
onOpenAbout = { screen = AppScreen.ABOUT },
)
AppScreen.UPLOAD -> UploadSettingsScreen(
onBack = { screen = AppScreen.MAIN },
)
AppScreen.PROXY -> ProxySettingsScreen(
bypassPackages = bypassPackages,
onBypassChange = { newSet ->
bypassPackages = newSet
SettingsStore.saveBypassPackages(this, newSet)
SettingsStore.bypassPackages = newSet
},
onSelectApps = { screen = AppScreen.APP_SELECTION },
onBack = { screen = AppScreen.MAIN },
@@ -95,7 +101,7 @@ class MainActivity : ComponentActivity() {
} else {
bypassPackages + entry.packageName
}
SettingsStore.saveBypassPackages(this, bypassPackages)
SettingsStore.bypassPackages = bypassPackages
},
onBack = { screen = AppScreen.PROXY },
)
@@ -3,7 +3,7 @@ package net.bluemangoo.indpjsk.data
import net.bluemangoo.indpjsk.upload.Uploader
object PredefinedProfiles {
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$"
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$"
private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
@@ -40,7 +40,10 @@ object PredefinedProfiles {
}
)
private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.dailygn.com")
private val CN_HOSTS = listOf(
"mkcn-prod-public-60001-1.dailygn.com",
"mkcn-prod-public-60001-2.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(
@@ -1,49 +1,116 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit
import net.bluemangoo.indpjsk.App
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
/** 全局配置持久化:上传码、上游代理、忽略的应用、选中的配置 */
object SettingsStore {
private fun prefs() =
App.appContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
private const val PREFS = "settings"
private const val KEY_UPLOAD_CODE = "upload_code"
private const val KEY_PROXY = "upstream_proxy"
private const val KEY_BYPASS = "bypass_packages"
private const val KEY_PROFILE = "selected_profile"
private inline fun <reified T> pref(
default: T? = null,
key: String? = null
) = PrefProperty(
clazz = T::class,
customDefault = default,
customKey = key,
prefs = { prefs() }
)
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
var uploadProfilesJson by pref<String>()
var upstreamProxy by pref<String>()
var useProxy by pref<Boolean>()
var bypassPackages by pref<Set<String>>()
var selectedProfileId by pref<String?>(default = null)
var continueRun by pref<Boolean>()
// ---- 上传码 ----
fun loadUploadCode(context: Context): String =
prefs(context).getString(KEY_UPLOAD_CODE, "").orEmpty()
}
fun saveUploadCode(context: Context, code: String) {
prefs(context).edit { putString(KEY_UPLOAD_CODE, code) }
class PrefProperty<T>(
private val clazz: KClass<*>,
private val customDefault: T? = null,
private val customKey: String? = null,
private val prefs: () -> SharedPreferences
) : ReadWriteProperty<Any?, T> {
private var internalState: MutableState<T>? = null
private var resolvedKey: String? = null
private fun getKey(property: KProperty<*>): String =
resolvedKey ?: (customKey ?: property.name).also { resolvedKey = it }
@Suppress("UNCHECKED_CAST")
private fun getDefault(): T {
if (customDefault != null) return customDefault
return when {
clazz == String::class -> "" as T
clazz == Boolean::class -> false as T
clazz == Int::class -> 0 as T
clazz == Long::class -> 0L as T
clazz == Float::class -> 0f as T
clazz == Double::class -> 0.0 as T
Set::class.java.isAssignableFrom(clazz.java) -> emptySet<String>() as T
else -> throw IllegalArgumentException("Unsupported type: ${clazz.simpleName}")
}
}
// ---- 上游代理(空 = 直连) ----
fun loadProxy(context: Context): String =
prefs(context).getString(KEY_PROXY, "").orEmpty()
@Suppress("UNCHECKED_CAST")
private fun getOrCreateState(property: KProperty<*>): MutableState<T> {
return internalState ?: run {
val key = getKey(property)
val sp = prefs()
val fallback = getDefault()
fun saveProxy(context: Context, proxy: String) {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) }
val initialVal: T = when {
clazz == String::class -> sp.getString(key, fallback as String) as T
clazz == Boolean::class -> sp.getBoolean(key, fallback as Boolean) as T
clazz == Int::class -> sp.getInt(key, fallback as Int) as T
clazz == Long::class -> sp.getLong(key, fallback as Long) as T
clazz == Float::class -> sp.getFloat(key, fallback as Float) as T
Set::class.java.isAssignableFrom(clazz.java) -> {
(sp.getStringSet(key, fallback as? Set<String>)?.toSet() ?: emptySet<String>()) as T
}
// ---- 不走 VPN 的应用(包名) ----
fun loadBypassPackages(context: Context): Set<String> =
prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet()
fun saveBypassPackages(context: Context, packages: Set<String>) {
prefs(context).edit { putStringSet(KEY_BYPASS, packages) }
else -> fallback
}
mutableStateOf(initialVal).also { internalState = it }
}
}
// ---- 当前选中的配置 ----
fun loadSelectedProfileId(context: Context): String? =
prefs(context).getString(KEY_PROFILE, null)
@Suppress("UNCHECKED_CAST")
private fun saveToSp(value: T) {
val key = checkNotNull(resolvedKey) { "Property has not been initialized yet" }
prefs().edit {
when (value) {
is String -> putString(key, value)
is Boolean -> putBoolean(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Set<*> -> putStringSet(key, HashSet(value as Set<String>))
}
}
}
fun saveSelectedProfileId(context: Context, id: String) {
prefs(context).edit { putString(KEY_PROFILE, id) }
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return getOrCreateState(property).value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
getOrCreateState(property).value = value
saveToSp(value)
}
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): PrefProperty<T> {
resolvedKey = customKey ?: property.name
getOrCreateState(property)
return this
}
}
@@ -4,12 +4,6 @@ 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"
@@ -15,14 +15,12 @@ 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
@@ -36,6 +34,7 @@ fun CaptureScreen(
onStartCapture: () -> Unit,
onStopCapture: () -> Unit,
onOpenProxySettings: () -> Unit,
onOpenUploadSettings: () -> Unit,
onOpenFiles: () -> Unit,
onOpenAbout: () -> Unit,
) {
@@ -46,7 +45,6 @@ fun CaptureScreen(
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var menuExpanded by remember { mutableStateOf(false) }
var profileExpanded by remember { mutableStateOf(false) }
var showUploadCodeDialog by remember { mutableStateOf(false) }
val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED
@@ -69,10 +67,10 @@ fun CaptureScreen(
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("上传码") },
text = { Text("上传设置") },
onClick = {
menuExpanded = false
showUploadCodeDialog = true
onOpenUploadSettings()
}
)
DropdownMenuItem(
@@ -143,7 +141,7 @@ fun CaptureScreen(
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(context, profile)
viewModel.selectProfile(profile)
profileExpanded = false
}
)
@@ -215,48 +213,6 @@ fun CaptureScreen(
}
}
}
if (showUploadCodeDialog) {
UploadCodeDialog(onDismiss = { showUploadCodeDialog = false })
}
}
@Composable
private fun UploadCodeDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
var text by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("上传码") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it.trim() },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Haruki 上传码") },
visualTransformation = PasswordVisualTransformation(),
textStyle = MaterialTheme.typography.bodyMedium
)
Text(
text = "从 Haruki 工具箱 - 数据上传 - iOS 获取。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = {
SettingsStore.saveUploadCode(context, text)
onDismiss()
}) { Text("保存") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("取消") }
}
)
}
@Composable
@@ -46,9 +46,9 @@ class CaptureViewModel : ViewModel() {
)
)
fun selectProfile(context: Context, profile: CaptureProfile) {
fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id)
SettingsStore.selectedProfileId = profile.id
}
fun startCapture(context: Context) {
@@ -18,6 +18,7 @@ import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.util.asState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -28,7 +29,9 @@ fun ProxySettingsScreen(
onBack: () -> Unit,
) {
val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) }
var proxy by SettingsStore::upstreamProxy.asState()
var useProxy by SettingsStore::useProxy.asState()
var continueRun by SettingsStore::continueRun.asState()
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) {
@@ -37,6 +40,7 @@ fun ProxySettingsScreen(
val looksValid = proxy.isBlank() ||
((proxy.startsWith("http://") || proxy.startsWith("https://")) && proxy.contains(":"))
val canUseProxy = proxy.isNotBlank() && looksValid
Scaffold(
topBar = {
@@ -60,17 +64,61 @@ fun ProxySettingsScreen(
.padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = continueRun,
onCheckedChange = { continueRun = it }
)
Text(
text = "持续运行",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "抓到包后不自动停止,继续接受同类型的包",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, bottom = 4.dp)
)
}
HorizontalDivider()
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("上游代理", style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = useProxy && canUseProxy,
onCheckedChange = {
useProxy = it
},
enabled = canUseProxy
)
Text(
text = "使用上游代理",
style = MaterialTheme.typography.bodyMedium
)
}
OutlinedTextField(
value = proxy,
onValueChange = {
proxy = it
SettingsStore.saveProxy(context, it)
proxy = it.trim()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -79,10 +127,9 @@ fun ProxySettingsScreen(
isError = !looksValid,
supportingText = {
Text(
text = if (looksValid) {
"留空以忽略;只支持 http / https 代理"
} else {
"格式示例:http://127.0.0.1:7890"
text = when {
!looksValid -> "格式示例:http://127.0.0.1:7890"
else -> "只支持 http / https 代理"
}
)
}
@@ -0,0 +1,846 @@
package net.bluemangoo.indpjsk.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UploadSettingsScreen(onBack: () -> Unit) {
var entries by remember { mutableStateOf(UploadStore.load()) }
var editing by remember { mutableStateOf<UploadEditTarget?>(null) }
var pendingDelete by remember { mutableStateOf<Int?>(null) }
val target = editing
if (target != null) {
UploadEditScreen(
initial = target.initial,
takenIds = entries
.filterIndexed { index, _ -> index != target.index }
.map { it.profile.id }
.toSet(),
onCancel = { editing = null },
onSave = { entry ->
val index = target.index
entries = if (index == null) {
entries + entry
} else {
entries.toMutableList().also { it[index] = entry }
}
UploadStore.save(entries)
editing = null
},
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("上传设置") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
},
actions = {
IconButton(onClick = { editing = UploadEditTarget(null, null) }) {
Icon(
painter = painterResource(R.drawable.add_24px),
contentDescription = "添加"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (entries.isEmpty()) {
Text(
text = "还没有上传配置",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
)
} else {
UploadTableHeader()
HorizontalDivider()
entries.forEachIndexed { index, entry ->
UploadTableRow(
entry = entry,
onEnabledChange = { enabled ->
entries = entries.toMutableList().also {
it[index] = entry.copy(enabled = enabled)
}
UploadStore.save(entries)
},
onEdit = { editing = UploadEditTarget(index, entry) },
onDelete = { pendingDelete = index },
)
HorizontalDivider()
}
}
}
}
pendingDelete?.let { index ->
val entry = entries.getOrNull(index)
if (entry != null) {
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("删除上传配置") },
text = { Text("确定删除「${entry.profile.id}」?") },
confirmButton = {
TextButton(onClick = {
entries = entries.filterIndexed { i, _ -> i != index }
UploadStore.save(entries)
pendingDelete = null
}) { Text("删除") }
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) { Text("取消") }
}
)
}
}
}
private data class UploadEditTarget(
val index: Int?,
val initial: UploadEntry?,
)
@Composable
private fun UploadTableHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.width(48.dp))
Text("ID", style = MaterialTheme.typography.labelMedium, modifier = Modifier.weight(1f))
Text(
"类型",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.width(72.dp)
)
Spacer(Modifier.width(80.dp))
}
}
@Composable
private fun UploadTableRow(
entry: UploadEntry,
onEnabledChange: (Boolean) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = entry.enabled,
onCheckedChange = onEnabledChange,
)
Text(
text = entry.profile.id,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = entry.profile.type.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(72.dp)
)
IconButton(onClick = onEdit, modifier = Modifier.size(40.dp)) {
Icon(
painter = painterResource(R.drawable.edit_24px),
contentDescription = "编辑",
modifier = Modifier.size(20.dp)
)
}
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(
painter = painterResource(R.drawable.delete_24px),
contentDescription = "删除",
modifier = Modifier.size(20.dp)
)
}
}
}
/**
* 添加 / 编辑共用的界面。
* 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UploadEditScreen(
initial: UploadEntry?,
takenIds: Set<String>,
onCancel: () -> Unit,
onSave: (UploadEntry) -> Unit,
) {
BackHandler(onBack = onCancel)
val context = LocalContext.current
val original = initial?.profile
var id by remember(initial) { mutableStateOf(original?.id ?: "") }
var type by remember(initial) { mutableStateOf(original?.type ?: UploadProfileType.HARUKI) }
var uploadCode by remember(initial) {
mutableStateOf(
when (original) {
is UploadProfileHaruki -> original.uploadCode
is UploadProfileReasona -> original.uploadCode
else -> ""
}
)
}
var urlMode by remember(initial) {
mutableStateOf(
when (val url = (original as? UploadProfileCustom)?.uploadUrl) {
is UploadUrlEach -> UploadStore.URL_MODE_EACH
is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL
null -> UploadStore.URL_MODE_GENERAL
}
)
}
var generalUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlGeneral)?.url ?: "")
}
var suiteUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.suite ?: "")
}
var mysekaiUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.mysekai ?: "")
}
var birthdayUrl by remember(initial) {
mutableStateOf(((original as? UploadProfileCustom)?.uploadUrl as? UploadUrlEach)?.birthday ?: "")
}
var headers by remember(initial) {
mutableStateOf(
(original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList()
)
}
var typeExpanded by remember { mutableStateOf(false) }
var urlModeExpanded by remember { mutableStateOf(false) }
var importDialog by remember { mutableStateOf(false) }
var exportDialog by remember { mutableStateOf(false) }
var importText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) }
val trimmedId = id.trim()
val idError = when {
trimmedId.isEmpty() -> "ID 不能为空"
trimmedId.any { it.isWhitespace() } -> "ID 不能包含空白字符"
trimmedId in takenIds -> "ID 已存在"
else -> null
}
val codeError = when (type) {
UploadProfileType.HARUKI, UploadProfileType.REASONA ->
if (uploadCode.isBlank()) "请填写上传码" else null
else -> null
}
val canSave = idError == null && codeError == null
fun buildProfile(): UploadProfile = when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim())
UploadProfileType.REASONA -> UploadProfileReasona(trimmedId, uploadCode.trim())
UploadProfileType._8823 -> UploadProfile8823(trimmedId)
UploadProfileType.CUSTOM -> UploadProfileCustom(
id = trimmedId,
uploadUrl = if (urlMode == UploadStore.URL_MODE_EACH) {
UploadUrlEach(
suite = suiteUrl.trim().takeIf { it.isNotEmpty() },
mysekai = mysekaiUrl.trim().takeIf { it.isNotEmpty() },
birthday = birthdayUrl.trim().takeIf { it.isNotEmpty() },
)
} else {
UploadUrlGeneral(generalUrl.trim())
},
customHeaders = headers
.map { it.first.trim() to it.second.trim() }
.filter { it.first.isNotEmpty() }
.toMap(),
)
}
val preview = runCatching { buildProfile() }.getOrNull()
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (initial == null) "添加上传配置" else "编辑上传配置") },
navigationIcon = {
IconButton(onClick = onCancel) {
Icon(
painter = painterResource(R.drawable.arrow_back_24px),
contentDescription = "返回"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ID 和类型放在同一行
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "ID",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(40.dp)
)
CompactTextField(
value = id,
onValueChange = { id = it },
modifier = Modifier.weight(1f),
placeholder = "唯一 ID",
isError = idError != null,
)
CompactDropdown(
text = type.displayName,
expanded = typeExpanded,
onExpandedChange = { typeExpanded = it },
modifier = Modifier.weight(1f),
) {
UploadProfileType.entries.forEach { item ->
DropdownMenuItem(
text = { Text(item.displayName, style = MaterialTheme.typography.bodySmall) },
onClick = {
type = item
typeExpanded = false
}
)
}
}
}
if (idError != null) {
Text(
text = idError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 48.dp)
)
}
preview?.helpText?.let { HelpText(it) }
when (type) {
UploadProfileType.HARUKI, UploadProfileType.REASONA -> {
FormRow(if (type == UploadProfileType.HARUKI) "上传码" else "JWT") {
CompactTextField(
value = uploadCode,
onValueChange = { uploadCode = it.trim() },
modifier = Modifier.weight(1f),
isError = codeError != null,
)
}
if (codeError != null) {
Text(
text = codeError,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 72.dp)
)
}
}
UploadProfileType._8823 -> Unit
UploadProfileType.CUSTOM -> {
FormRow("上传地址") {
CompactDropdown(
text = if (urlMode == UploadStore.URL_MODE_EACH) {
"分类 URL"
} else {
"单一 URL"
},
expanded = urlModeExpanded,
onExpandedChange = { urlModeExpanded = it },
modifier = Modifier.weight(1f),
) {
DropdownMenuItem(
text = { Text("单一 URL", style = MaterialTheme.typography.bodySmall) },
onClick = {
urlMode = UploadStore.URL_MODE_GENERAL
urlModeExpanded = false
}
)
DropdownMenuItem(
text = { Text("分类 URL", style = MaterialTheme.typography.bodySmall) },
onClick = {
urlMode = UploadStore.URL_MODE_EACH
urlModeExpanded = false
}
)
}
}
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (urlMode == UploadStore.URL_MODE_EACH) {
FormRow("Suite") {
CompactTextField(
value = suiteUrl,
onValueChange = { suiteUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
FormRow("烤森") {
CompactTextField(
value = mysekaiUrl,
onValueChange = { mysekaiUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
FormRow("生日") {
CompactTextField(
value = birthdayUrl,
onValueChange = { birthdayUrl = it },
modifier = Modifier.weight(1f),
placeholder = "可留空",
)
}
} else {
FormRow("URL") {
CompactTextField(
value = generalUrl,
onValueChange = { generalUrl = it },
modifier = Modifier.weight(1f),
)
}
}
}
HeadersEditor(headers = headers, onChange = { headers = it })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
importText = ""
importError = null
importDialog = true
},
modifier = Modifier
.weight(1f)
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) { Text("从 JSON 导入", style = MaterialTheme.typography.bodySmall) }
OutlinedButton(
onClick = { exportDialog = true },
modifier = Modifier
.weight(1f)
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) { Text("导出 JSON", style = MaterialTheme.typography.bodySmall) }
}
}
}
if (preview != null && preview.extraInfo.isNotEmpty()) {
HorizontalDivider()
Text("解析信息", style = MaterialTheme.typography.labelLarge)
preview.extraInfo.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = key,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = value, style = MaterialTheme.typography.bodySmall)
}
}
}
Button(
onClick = { onSave(UploadEntry(initial?.enabled ?: true, buildProfile())) },
enabled = canSave,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
.height(44.dp)
) {
Text("保存")
}
}
}
if (importDialog) {
AlertDialog(
onDismissRequest = { importDialog = false },
title = { Text("从 JSON 导入") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = importText,
onValueChange = {
importText = it
importError = null
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp),
placeholder = { Text("{ ... }") },
textStyle = MaterialTheme.typography.bodySmall
)
importError?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
confirmButton = {
TextButton(onClick = {
val imported = runCatching {
UploadStore.decodeProfile(JSONObject(importText))
}.getOrNull()
when (imported) {
is UploadProfileCustom -> {
id = imported.id
type = UploadProfileType.CUSTOM
when (val url = imported.uploadUrl) {
is UploadUrlGeneral -> {
urlMode = UploadStore.URL_MODE_GENERAL
generalUrl = url.url
}
is UploadUrlEach -> {
urlMode = UploadStore.URL_MODE_EACH
suiteUrl = url.suite.orEmpty()
mysekaiUrl = url.mysekai.orEmpty()
birthdayUrl = url.birthday.orEmpty()
}
}
headers = imported.customHeaders.map { it.key to it.value }
importDialog = false
}
null -> importError = "JSON 解析失败,或不是上传配置"
else -> importError = "这个 JSON 不是自定义类型的配置"
}
}) { Text("导入") }
},
dismissButton = {
TextButton(onClick = { importDialog = false }) { Text("取消") }
}
)
}
if (exportDialog) {
val json = runCatching {
UploadStore.encodeProfile(buildProfile()).toString(2)
}.getOrDefault("")
AlertDialog(
onDismissRequest = { exportDialog = false },
title = { Text("导出 JSON") },
text = {
OutlinedTextField(
value = json,
onValueChange = {},
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 320.dp),
textStyle = MaterialTheme.typography.bodySmall
)
},
confirmButton = {
TextButton(onClick = {
copyToClipboard(context, json)
exportDialog = false
}) { Text("复制") }
},
dismissButton = {
TextButton(onClick = { exportDialog = false }) { Text("关闭") }
}
)
}
}
@Composable
private fun HeadersEditor(
headers: List<Pair<String, String>>,
onChange: (List<Pair<String, String>>) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Headers", style = MaterialTheme.typography.labelLarge)
if (headers.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"名称",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
"值",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1.4f)
)
Spacer(Modifier.width(36.dp))
}
}
headers.forEachIndexed { index, (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CompactTextField(
value = key,
onValueChange = { newKey ->
onChange(headers.toMutableList().also { it[index] = newKey to value })
},
modifier = Modifier.weight(1f),
)
CompactTextField(
value = value,
onValueChange = { newValue ->
onChange(headers.toMutableList().also { it[index] = key to newValue })
},
modifier = Modifier.weight(1.4f),
)
IconButton(
onClick = { onChange(headers.filterIndexed { i, _ -> i != index }) },
modifier = Modifier.size(36.dp)
) {
Icon(
painter = painterResource(R.drawable.delete_24px),
contentDescription = "删除 Header",
modifier = Modifier.size(18.dp)
)
}
}
}
TextButton(
onClick = { onChange(headers + ("" to "")) },
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Text("添加 Header", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun FormRow(
label: String,
labelWidth: Dp = 64.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(labelWidth)
)
content()
}
}
@Composable
private fun CompactTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String = "",
isError: Boolean = false,
) {
val colors = MaterialTheme.colorScheme
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.height(40.dp),
textStyle = MaterialTheme.typography.bodySmall.copy(color = colors.onSurface),
singleLine = true,
cursorBrush = SolidColor(colors.primary),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxSize()
.border(
width = 1.dp,
color = if (isError) colors.error else colors.outline,
shape = RoundedCornerShape(6.dp)
)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (value.isEmpty() && placeholder.isNotEmpty()) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodySmall,
color = colors.onSurfaceVariant
)
}
innerTextField()
}
}
)
}
@Composable
private fun CompactDropdown(
text: String,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
items: @Composable ColumnScope.() -> Unit,
) {
Box(modifier = modifier) {
OutlinedButton(
onClick = { onExpandedChange(true) },
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp),
shape = RoundedCornerShape(6.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Icon(
painter = painterResource(R.drawable.arrow_drop_down_24px),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange(false) },
) {
items()
}
}
}
private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""")
@Composable
private fun HelpText(text: String) {
val linkColor = MaterialTheme.colorScheme.primary
val annotated = remember(text, linkColor) {
buildAnnotatedString {
var last = 0
helpLinkRegex.findAll(text).forEach { match ->
append(text.substring(last, match.range.first))
withLink(
LinkAnnotation.Url(
url = match.groupValues[2],
styles = TextLinkStyles(
style = SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline
)
)
)
) {
append(match.groupValues[1])
}
last = match.range.last + 1
}
append(text.substring(last))
}
}
Text(
text = annotated,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 48.dp)
)
}
private fun copyToClipboard(context: Context, text: String) {
val manager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(ClipData.newPlainText("upload profile", text))
Toast.makeText(context, "已复制", Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,148 @@
package net.bluemangoo.indpjsk.upload
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONArray
import org.json.JSONObject
data class UploadEntry(
val enabled: Boolean,
val profile: UploadProfile,
)
object UploadStore {
private const val VERSION = 1
private const val KEY_VERSION = "version"
private const val KEY_PROFILES = "profiles"
private const val KEY_ENABLED = "enabled"
private const val KEY_PROFILE = "profile"
private const val KEY_ID = "id"
private const val KEY_TYPE = "type"
private const val KEY_UPLOAD_CODE = "uploadCode"
private const val KEY_UPLOAD_URL = "uploadUrl"
private const val KEY_MODE = "mode"
private const val KEY_URL = "url"
private const val KEY_HEADERS = "headers"
private const val KEY_SUITE = "suite"
private const val KEY_MYSEKAI = "mysekai"
private const val KEY_BIRTHDAY = "birthday"
const val URL_MODE_GENERAL = "general"
const val URL_MODE_EACH = "each"
fun load(): List<UploadEntry> {
val raw = SettingsStore.uploadProfilesJson
return runCatching { decode(raw) }
.onFailure { VpnLog.error("上传配置读取失败: ${it.message}") }
.getOrDefault(emptyList())
}
fun save(entries: List<UploadEntry>) {
SettingsStore.uploadProfilesJson = encode(entries)
}
fun encode(entries: List<UploadEntry>): String {
val profiles = JSONArray()
entries.forEach { entry ->
profiles.put(JSONObject().apply {
put(KEY_ENABLED, entry.enabled)
put(KEY_PROFILE, encodeProfile(entry.profile))
})
}
return JSONObject().apply {
put(KEY_VERSION, VERSION)
put(KEY_PROFILES, profiles)
}.toString()
}
fun decode(raw: String): List<UploadEntry> {
val root = JSONObject(raw)
val arr = root.optJSONArray(KEY_PROFILES) ?: JSONArray()
val out = mutableListOf<UploadEntry>()
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val obj = item.optJSONObject(KEY_PROFILE) ?: continue
val profile = decodeProfile(obj)
if (profile == null) {
VpnLog.warn("跳过无法识别的上传配置: ${obj.optString(KEY_TYPE)}")
continue
}
out += UploadEntry(item.optBoolean(KEY_ENABLED, false), profile)
}
return out
}
fun encodeProfile(profile: UploadProfile): JSONObject = JSONObject().apply {
put(KEY_TYPE, profile.type.name)
put(KEY_ID, profile.id)
when (profile) {
is UploadProfileHaruki -> put(KEY_UPLOAD_CODE, profile.uploadCode)
is UploadProfileReasona -> put(KEY_UPLOAD_CODE, profile.uploadCode)
is UploadProfile8823 -> Unit
is UploadProfileCustom -> {
put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl))
put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>))
}
}
}
fun decodeProfile(obj: JSONObject): UploadProfile? {
val id = obj.optString(KEY_ID)
val type = UploadProfileType.entries.firstOrNull { it.name == obj.optString(KEY_TYPE) }
?: return null
return when (type) {
UploadProfileType.HARUKI -> UploadProfileHaruki(id, obj.optString(KEY_UPLOAD_CODE))
UploadProfileType.REASONA -> UploadProfileReasona(id, obj.optString(KEY_UPLOAD_CODE))
UploadProfileType._8823 -> UploadProfile8823(id)
UploadProfileType.CUSTOM -> UploadProfileCustom(
id = id,
uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)),
customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)),
)
}
}
private fun encodeUrl(url: UploadUrl): JSONObject = when (url) {
is UploadUrlGeneral -> JSONObject().apply {
put(KEY_MODE, URL_MODE_GENERAL)
put(KEY_URL, url.url)
}
is UploadUrlEach -> JSONObject().apply {
put(KEY_MODE, URL_MODE_EACH)
put(KEY_SUITE, url.suite ?: JSONObject.NULL)
put(KEY_MYSEKAI, url.mysekai ?: JSONObject.NULL)
put(KEY_BIRTHDAY, url.birthday ?: JSONObject.NULL)
}
}
private fun decodeUrl(obj: JSONObject?): UploadUrl {
if (obj == null) return UploadUrlGeneral("")
return when (obj.optString(KEY_MODE, URL_MODE_GENERAL)) {
URL_MODE_EACH -> UploadUrlEach(
suite = obj.optUrlOrNull(KEY_SUITE),
mysekai = obj.optUrlOrNull(KEY_MYSEKAI),
birthday = obj.optUrlOrNull(KEY_BIRTHDAY),
)
else -> UploadUrlGeneral(obj.optString(KEY_URL))
}
}
private fun JSONObject.optUrlOrNull(key: String): String? =
optString(key).trim().takeIf { it.isNotEmpty() }
private fun decodeHeaders(obj: JSONObject?): Map<String, String> {
if (obj == null) return emptyMap()
val map = linkedMapOf<String, String>()
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = obj.optString(key)
}
return map
}
}
@@ -1,96 +1,105 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.App
import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.util.*
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,
suspend fun tryUpload(result: CaptureResult) {
val entries = UploadStore.load().filter { it.enabled }
if (entries.isEmpty()) return
VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)")
for ((_, profile) in entries) {
try {
val uploaded = uploadChunked(profile, result.url, result.file)
if (uploaded) {
VpnLog.success("[${profile.id}] ${result.step.name} 上传完成")
} else {
VpnLog.info("[${profile.id}] ${result.step.name} 没有对应的上传地址,跳过")
}
} catch (c: CancellationException) {
throw c
} catch (t: Throwable) {
VpnLog.error("[${profile.id}] ${result.step.name} 上传失败: ${t.message}")
}
}
}
suspend fun uploadChunked(
profile: UploadProfile,
originalUrl: String,
file: File,
) = withContext(Dispatchers.IO) {
): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val type = when (file.name) {
"suite" -> "suite"
"mysekai" -> "mysekai"
"mysekai_birthday" -> "birthday"
else -> {
VpnLog.error("${profile.id} 未知的文件类型 ${file.name}")
return@withContext false
}
}
val url = profile.getUploadUrl(type)?.takeIf { it.isNotBlank() }
?: return@withContext false
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} 上传开始")
VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
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)
val code = postChunk(profile, url, uploadId, index, total, originalUrl, buf, file, len)
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
}
VpnLog.info("${file.name} 上传完成")
}
}
suspend fun tryUpload(result: CaptureResult) {
val uploadCode = SettingsStore.loadUploadCode(App.appContext).trim()
if (uploadCode.isNotEmpty()) {
VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
upload(uploadCode, result.url, result.file)
VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
}
true
}
private fun postChunk(
uploadCode: String,
profile: UploadProfile,
url: String,
uploadId: String,
index: Int,
total: Int,
originalUrl: String,
data: ByteArray,
file: File,
length: Int,
): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.instanceFollowRedirects = false
conn.instanceFollowRedirects = true
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)
profile.preRequest(conn, originalUrl, file)
conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode
@@ -101,7 +110,7 @@ object Uploader {
?.use { it.readText() }
?.take(300)
}.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
}
return code
} finally {
@@ -109,7 +118,6 @@ object Uploader {
}
}
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) {
@@ -117,3 +125,182 @@ object Uploader {
}
}
}
sealed interface UploadUrl {
fun getUrl(type: String): String?
}
data class UploadUrlGeneral(
val url: String
) : UploadUrl {
override fun getUrl(type: String): String {
return url
}
}
data class UploadUrlEach(
val suite: String?,
val mysekai: String?,
val birthday: String?,
) : UploadUrl {
override fun getUrl(type: String): String? {
return when (type) {
"suite" -> suite
"mysekai" -> mysekai
"birthday" -> birthday
else -> null
}
}
}
enum class UploadProfileType(val displayName: String) {
HARUKI("Haruki"),
REASONA("Reasona"),
_8823("8823"),
CUSTOM("自定义")
}
sealed interface UploadProfile {
val id: String
val type: UploadProfileType
val helpText: String?
fun getUploadUrl(type: String): String?
fun preRequest(
conn: HttpURLConnection, originalUrl: String, file: File,
)
val extraInfo: Map<String, String>
}
class UploadProfileHaruki(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
}
override val extraInfo: Map<String, String>
get() = emptyMap()
override val helpText: String =
"在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 的「iOS模块」里复制上传码。"
override val type: UploadProfileType = UploadProfileType.HARUKI
override fun getUploadUrl(type: String): String {
return "$API_BASE/$uploadCode/upload"
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "1.0.1")
conn.setRequestProperty("X-Upload-Code", uploadCode)
}
}
class UploadProfileReasona(override val id: String, val uploadCode: String) : UploadProfile {
companion object {
private const val API_BASE = "https://api.resona.cn/scripts/"
}
override val type: UploadProfileType = UploadProfileType.REASONA
override val helpText: String =
"在 [Reasona 工具箱](https://web.resona.cn/tools/upload) 的 Haruki Proxy 里复制 token"
private val _uploadUrl = UploadUrlEach(
suite = "$API_BASE/suite/upload?jwt=$uploadCode",
mysekai = "$API_BASE/mysekai/upload?jwt=$uploadCode",
birthday = "$API_BASE/mysekai_birthday_party/upload?jwt=$uploadCode"
)
override fun getUploadUrl(type: String): String? {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.4.2")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
private fun parseJwt(): JSONObject? {
val parts = uploadCode.split(".")
if (parts.size < 2) return null
val payload =
try {
val payloadBytes = Base64.getUrlDecoder().decode(parts[1])
String(payloadBytes, Charsets.UTF_8)
} catch (_: IllegalArgumentException) {
return null
}
return runCatching { JSONObject(payload) }.getOrNull()
}
override val extraInfo: Map<String, String>
get() {
val payload = parseJwt() ?: return emptyMap()
val map = mutableMapOf<String, String>()
if (payload.has("qqnum")) {
map["QQ 号"] = payload.optString("qqnum")
}
if (payload.has("sync_8823")) {
map["同步给 8823"] = payload.optBoolean("sync_8823").toString()
}
return map
}
}
class UploadProfile8823(override val id: String) : UploadProfile {
override val type: UploadProfileType = UploadProfileType._8823
override val helpText: String? = null
private val _uploadUrl = UploadUrlGeneral("https://api-8823.resona.cn/api/public/V1/upload")
override fun getUploadUrl(type: String): String {
return _uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
conn.setRequestProperty("X-Script-Version", "0.3.0")
val userId = originalUrl.tryParseUserId()
if (userId != null) {
conn.setRequestProperty("X-User-Id", userId)
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map<String, String>) :
UploadProfile {
override val type: UploadProfileType = UploadProfileType.CUSTOM
override val helpText: String? = null
override fun getUploadUrl(type: String): String? {
return uploadUrl.getUrl(type)
}
override fun preRequest(conn: HttpURLConnection, originalUrl: String, file: File) {
customHeaders.forEach { (key, value) ->
when (value) {
$$"$original_url" -> conn.setRequestProperty(key, originalUrl)
$$"$file_name" -> conn.setRequestProperty(key, file.name)
$$"$user_id" -> originalUrl.tryParseUserId()?.let { conn.setRequestProperty(key, it) }
else -> conn.setRequestProperty(key, value)
}
}
}
override val extraInfo: Map<String, String> = emptyMap()
}
private fun String.tryParseUserId(): String? {
val regex = Regex("""/user/(\d+)|suite/user/(\d+)""")
val match = regex.find(this) ?: return null
return match.groupValues[1].ifEmpty { match.groupValues[2] }
.ifEmpty { null }
}
@@ -0,0 +1,20 @@
package net.bluemangoo.indpjsk.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import kotlin.reflect.KMutableProperty0
@Composable
fun <T> KMutableProperty0<T>.asState(): MutableState<T> {
return remember {
object : MutableState<T> {
override var value: T
get() = this@asState.get()
set(v) { this@asState.set(v) }
override fun component1(): T = value
override fun component2(): (T) -> Unit = { value = it }
}
}
}
@@ -0,0 +1 @@
package net.bluemangoo.indpjsk.util
@@ -7,7 +7,11 @@ import android.os.ParcelFileDescriptor
import kotlinx.coroutines.*
import net.bluemangoo.indpjsk.data.*
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() {
@@ -23,9 +27,7 @@ class CaptureVpnService : VpnService() {
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun captureFile(
filesDir: File,
profile: CaptureProfile,
step: CaptureProfileStep
filesDir: File, profile: CaptureProfile, step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
}
@@ -38,6 +40,16 @@ class CaptureVpnService : VpnService() {
@Volatile
private var stopRequested = false
private val stepResults = ConcurrentHashMap<String, String>()
private val finishedSteps = ConcurrentHashMap.newKeySet<String>()
@Volatile
private var allDone: CountDownLatch? = null
@Volatile
private var lastCaptured: File? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
@@ -49,12 +61,14 @@ class CaptureVpnService : VpnService() {
ACTION_STOP -> {
stopRequested = true
VpnLog.warn("手动停止…")
VpnBridge.requestStop()
closeActivePfd()
VpnBridge.stopSession()
allDone?.countDown()
CaptureStateHolder.setIdle()
if (captureThread?.isAlive != true) {
closeActivePfd()
stopSelf()
}
}
else -> return START_NOT_STICKY
}
@@ -66,7 +80,6 @@ class CaptureVpnService : VpnService() {
activePfd = null
try {
pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}")
}
@@ -88,22 +101,51 @@ class CaptureVpnService : VpnService() {
return
}
val proxy = SettingsStore.loadProxy(this).trim()
val bypassPackages = SettingsStore.loadBypassPackages(this)
if (proxy.isNotEmpty()) VpnLog.info("上游代理: $proxy")
val proxy: String? = if (SettingsStore.useProxy) {
SettingsStore.upstreamProxy.trim().takeIf { it.isNotEmpty() }
} else {
null
}
val bypassPackages = SettingsStore.bypassPackages
if (proxy != null) {
VpnLog.info("上游代理: $proxy")
} else {
VpnLog.info("未启用上游代理")
}
if (bypassPackages.isNotEmpty()) {
VpnLog.info("忽略的应用: ${bypassPackages.joinToString(", ")}")
}
stopRequested = false
stepResults.clear()
finishedSteps.clear()
lastCaptured = null
val latch = CountDownLatch(1)
allDone = latch
// 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除)
val continueRun = SettingsStore.continueRun
VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}")
VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}")
if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)")
VpnBridge.stepListener = { stepId, url ->
val step = enabledSteps.firstOrNull { it.id == stepId }
if (step == null) {
VpnLog.warn("收到未知步骤的结果: $stepId")
} else {
handleStepResult(profile, step, url)
finishedSteps.add(step.id)
if (!continueRun && finishedSteps.size >= enabledSteps.size) {
latch.countDown()
}
}
}
captureThread = Thread({
var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null
try {
@@ -111,159 +153,134 @@ class CaptureVpnService : VpnService() {
throw IllegalStateException("Rust 动态库 core_vpn 未加载")
}
val builder = Builder()
.setSession("Capture-${profile.id}")
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)
.addRoute("1.0.0.0", 8)
val builder = Builder().setSession("Capture-${profile.id}").addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName).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("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("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("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("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("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("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")
.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")
for (pkg in bypassPackages) {
if (pkg == packageName) continue
runCatching { builder.addDisallowedApplication(pkg) }
.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
runCatching { builder.addDisallowedApplication(pkg) }.onFailure { VpnLog.warn("忽略应用 $pkg 失败: ${it.message}") }
}
pfd = builder.establish()
?: throw IllegalStateException("VpnService.establish() 返回 null")
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 stepsJson = JSONArray().apply {
enabledSteps.forEach { step ->
put(JSONObject().apply {
put("id", step.id)
put("hosts", JSONArray(step.hosts))
put("path", step.path)
put("savePath", captureFile(filesDir, profile, step).absolutePath)
put("repeat", continueRun)
})
}
}.toString()
val label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
val target = captureFile(filesDir, profile, step)
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath,
proxy
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
if (!VpnBridge.boot(fd, stepsJson, proxy)) {
throw IllegalStateException("tun core 启动失败")
}
errorMsg = "$label 未抓到目标请求"
VpnLog.info("VPN 已建立")
latch.await()
if (continueRun) {
VpnLog.warn("持续运行结束(用户停止),已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stopRequested) {
VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stepResults.size < enabledSteps.size) {
errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步"
VpnLog.error(errorMsg)
break
}
VpnLog.info("$label 抓到目标请求: $capturedUrl")
if (!target.exists() || target.length() == 0L) {
errorMsg = "$label 返回了 URL 但文件为空"
VpnLog.error(errorMsg)
break
}
lastFile = target
completed++
VpnLog.success("$label 抓取成功")
val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch {
try {
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 {
VpnBridge.stepListener = null
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
VpnLog.info("==== 抓包结束(${stepResults.size}/${enabledSteps.size} 步完成)====")
if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) {
CaptureStateHolder.setCaptured(lastFile)
} else if (stepResults.size == enabledSteps.size && lastCaptured != null) {
CaptureStateHolder.setCaptured(lastCaptured!!)
} else {
CaptureStateHolder.setIdle()
}
allDone = null
stopSelf()
}
}, "capture-blocking-worker").apply { start() }
}
private fun handleStepResult(
profile: CaptureProfile,
step: CaptureProfileStep,
url: String?,
) {
val target = captureFile(filesDir, profile, step)
if (url == null || !target.exists() || target.length() == 0L) {
VpnLog.error("${step.name} 抓取失败")
return
}
stepResults[step.id] = url
lastCaptured = target
VpnLog.success("${step.name} 抓到目标请求")
val result = CaptureResult(profile, step, url, target)
VpnLog.info("${step.name} 立即执行后处理(不等其他步骤)")
postScope.launch {
try {
step.postAction(result)
} catch (c: CancellationException) {
VpnLog.warn("${step.name} 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("${step.name} 后处理失败: ${t.message}")
}
}
}
override fun onRevoke() {
stopRequested = true
VpnLog.warn("VPN 权限被撤销")
closeActivePfd()
VpnBridge.stopSession()
allDone?.countDown()
super.onRevoke()
}
override fun onDestroy() {
if (captureThread?.isAlive != true) {
VpnBridge.stopSession()
VpnBridge.shutdownSession()
closeActivePfd()
}
captureThread = null
super.onDestroy()
}
@@ -2,30 +2,14 @@ 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, jstring proxy) -> String?
* - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - proxy 可为 null / 空串(直连),否则必须是 http(s)://host:port 形式,Rust 侧会再校验一次
* - 返回非 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
@Volatile
var stepListener: ((String, String?) -> Unit)? = null
init {
loaded = try {
System.loadLibrary("core_vpn")
@@ -39,18 +23,20 @@ object VpnBridge {
fun isAvailable(): Boolean = loaded
@JvmStatic
external fun runCapture(
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String,
proxy: String?
): String?
external fun boot(tunFd: Int, stepsJson: String, proxy: String?): Boolean
@JvmStatic
external fun requestStop()
external fun stopSession()
@JvmStatic
external fun shutdownSession()
@Suppress("unused")
@JvmStatic
fun nativeStepResult(stepId: String, url: String?) {
stepListener?.invoke(stepId, url)
}
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
@Suppress("unused")
@JvmStatic
fun nativeLog(level: String, msg: String) {
@@ -15,12 +15,6 @@ data class LogEntry(
val message: String
)
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog {
private const val MAX = 800
+9
View File
@@ -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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
+9
View File
@@ -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="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z"/>
</vector>
+2
View File
@@ -278,6 +278,8 @@ dependencies = [
"rustls",
"rustls-native-certs",
"rustls-pemfile",
"serde",
"serde_json",
"tokio",
"tokio-rustls",
"tokio-util",
+2
View File
@@ -26,6 +26,8 @@ hyper-util = { version = "0.1", features = ["tokio", "server", "client"] }
http-body-util = "0.1"
http = "1.5.0"
qstring = "0.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
url = "2"
base64 = "0.23.1"
+148 -64
View File
@@ -1,18 +1,17 @@
mod cert;
mod mitm;
mod tun;
mod mut_qstring;
mod tun;
use crate::mitm::{ProxyConfig, run_capture_async};
use crate::mitm::{ProxyConfig, StepSpec, Waiter, Waiters, run_session};
use jni::objects::{Global, JClass, JObject, JString};
use jni::sys::{jint, jstring};
use jni::errors::ThrowRuntimeExAndDefault;
use jni::sys::{jboolean, jint};
use jni::{Env, EnvUnowned, JValue, JavaVM, jni_sig, jni_str};
use regex::Regex;
use std::sync::{OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock};
use tokio_util::sync::CancellationToken;
static CANCLE_TOKEN: RwLock<Option<CancellationToken>> = RwLock::new(None);
static SESSION: Mutex<Option<Session>> = Mutex::new(None);
static JVM: OnceLock<JavaVM> = OnceLock::new();
static VPN_BRIDGE_CLASS: OnceLock<Global<JClass>> = OnceLock::new();
@@ -21,7 +20,6 @@ fn init_jvm(env: &mut Env) {
return;
}
if let Ok(vm) = env.get_java_vm() {
// 关键:在调用线程(Java 线程)上 find_class,此时 classloader 正确
if let Ok(class) = env.find_class(jni_str!("net/bluemangoo/indpjsk/vpn/VpnBridge")) {
match env.new_global_ref(class) {
Ok(g) => {
@@ -80,101 +78,187 @@ macro_rules! error {
($($arg:tt)*) => {$crate::log_to_kotlin("error", &format!($($arg)*))};
}
struct Session {
cancel: CancellationToken,
waiters: Waiters,
done: std::sync::mpsc::Receiver<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
pub fn notify_step_result(step_id: &str, url: Option<&str>) {
let Some(vm) = JVM.get() else { return };
let Some(class_ref) = VPN_BRIDGE_CLASS.get() else {
return;
};
let _ = vm.attach_current_thread(|env: &mut Env| -> Result<(), jni::errors::Error> {
let jid = env.new_string(step_id)?;
let jurl = match url {
Some(u) => Some(env.new_string(u)?),
None => None,
};
let jclass: &JClass = unsafe { &*(class_ref.as_obj() as *const JObject as *const JClass) };
let url_value = match &jurl {
Some(s) => JValue::Object(s),
None => JValue::Object(&JObject::null()),
};
env.call_static_method(
jclass,
jni_str!("nativeStepResult"),
jni_sig!("(Ljava/lang/String;Ljava/lang/String;)V"),
&[JValue::Object(&jid), url_value],
)?;
Ok(())
});
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_runCapture(
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot(
mut env: EnvUnowned,
_class: JClass,
tun_fd: jint,
target_host: JString,
target_path: JString,
save_path: JString,
steps_json: JString,
proxy: JString,
) -> jstring {
) -> jboolean {
let _ = env.with_env(|env: &mut Env| -> Result<(), jni::errors::Error> {
init_jvm(env);
Ok(())
});
let host_arg: String = target_host.to_string();
let path: String = target_path.to_string();
let out: String = save_path.to_string();
let hosts: Vec<String> = host_arg
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if hosts.is_empty() {
error!("target hosts 为空");
return std::ptr::null_mut();
if SESSION.lock().unwrap().is_some() {
error!("会话已在运行,忽略重复 boot");
return false;
}
let re = match Regex::new(&path) {
Ok(re) => re,
Err(e) => {
error!("Invalid regex pattern: {path} ({e})");
return std::ptr::null_mut();
}
};
let steps_raw: String = steps_json.to_string();
let proxy_raw: String = if proxy.is_null() {
String::new()
} else {
proxy.to_string()
};
let steps: Vec<StepSpec> = match serde_json::from_str(&steps_raw) {
Ok(v) => v,
Err(e) => {
error!("steps JSON 解析失败: {e}");
return false;
}
};
if steps.is_empty() {
error!("没有需要抓的步骤");
return false;
}
let proxy_cfg = match ProxyConfig::parse(&proxy_raw) {
Ok(cfg) => cfg,
Err(e) => {
error!("代理配置无效: {e}");
return std::ptr::null_mut();
return false;
}
};
if let Some(cfg) = &proxy_cfg {
info!("upstream proxy: {}://{}:{}", if cfg.tls { "https" } else { "http" }, cfg.host, cfg.port);
info!(
"upstream proxy: {}://{}:{}",
if cfg.tls { "https" } else { "http" },
cfg.host,
cfg.port
);
}
let mut hosts: Vec<String> = Vec::new();
let mut waiter_list: Vec<Waiter> = Vec::new();
for step in steps {
let re = match Regex::new(&step.path) {
Ok(re) => re,
Err(e) => {
error!("步骤 {} 的正则不合法: {e}", step.id);
return false;
}
};
for h in step.hosts {
if !h.is_empty() && !hosts.iter().any(|x| x.eq_ignore_ascii_case(&h)) {
hosts.push(h);
}
}
waiter_list.push(Waiter {
id: step.id,
path: re,
save_path: step.save_path,
repeat: step.repeat,
});
}
if hosts.is_empty() {
error!("所有步骤的 host 都是空的");
return false;
}
let cancel = CancellationToken::new();
let waiters: Waiters = Arc::new(Mutex::new(waiter_list));
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let thread_waiters = waiters.clone();
let thread_cancel = cancel.clone();
let thread = match std::thread::Builder::new()
.name("vpn-session".into())
.spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let cancel_token = CancellationToken::new();
*CANCLE_TOKEN.write().unwrap() = Some(cancel_token.clone());
let cancel_token_clone = cancel_token.clone();
let result = rt.block_on(async move {
run_capture_async(tun_fd, hosts, re, out, proxy_cfg, cancel_token_clone).await
});
cancel_token.cancel();
if let Err(e) = rt.block_on(run_session(
tun_fd,
hosts,
proxy_cfg,
thread_waiters,
thread_cancel,
)) {
error!("session error: {e:?}");
}
rt.shutdown_timeout(std::time::Duration::from_secs(2));
let _ = done_tx.send(());
}) {
Ok(t) => t,
Err(e) => {
error!("启动 session 线程失败: {e}");
return false;
}
};
let url = result.unwrap_or_else(|e| {
error!("capture failed: {e:?}");
None
*SESSION.lock().unwrap() = Some(Session {
cancel,
waiters,
done: done_rx,
thread: Some(thread),
});
let obj: JObject = env
.with_env(|env: &mut Env| -> Result<JObject, jni::errors::Error> {
match &url {
Some(url) => {
let s = env.new_string(url)?;
Ok(unsafe { JObject::from_raw(env, s.as_raw()) })
}
None => Ok(JObject::null()),
}
})
.resolve::<ThrowRuntimeExAndDefault>();
obj.into_raw()
true
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_requestStop(
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_stopSession(
_env: EnvUnowned,
_class: JClass,
) {
info!("Stopping capture...");
if let Some(token) = CANCLE_TOKEN.write().unwrap().take() {
token.cancel()
info!("session stop requested");
if let Some(session) = SESSION.lock().unwrap().as_ref() {
session.waiters.lock().unwrap().clear();
session.cancel.cancel();
}
}
#[allow(non_snake_case)]
#[unsafe(no_mangle)]
pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_shutdownSession(
_env: EnvUnowned,
_class: JClass,
) {
let session = SESSION.lock().unwrap().take();
let Some(mut session) = session else { return };
session.waiters.lock().unwrap().clear();
session.cancel.cancel();
let _ = session.done.recv();
if let Some(t) = session.thread.take() {
let _ = t.join();
}
info!("session shutdown complete");
}
+114 -88
View File
@@ -12,12 +12,11 @@ use rustls::RootCertStore;
use std::io;
use std::net::SocketAddr;
use std::os::fd::RawFd;
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration;
use http::Uri;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::UdpSocket;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use tokio_rustls::StartHandshake;
use tokio_util::sync::CancellationToken;
@@ -115,11 +114,9 @@ async fn handle_tls_connection<S>(
dst: SocketAddr,
upstream: Upstream,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
waiters: Waiters,
server_config: Arc<rustls::ServerConfig>,
cancel: CancellationToken,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
) -> anyhow::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
@@ -136,7 +133,6 @@ where
let Some((sni, accepted)) = sniffed else {
io.rewind();
io.stop_recording();
// 非 TLS:拿不到 SNI,走代理时只能按 IP CONNECT
return passthrough_cancel_wrapped(io, dst, None, upstream, &cancel).await;
};
@@ -154,7 +150,6 @@ where
}
return r;
}
info!("Encountered target host.");
io.stop_recording();
let client_tls = StartHandshake::from_parts(accepted, io)
@@ -162,16 +157,13 @@ where
.await?;
let svc_host = sni.clone().unwrap_or_default();
let svc_upstream = upstream;
let svc = hyper::service::service_fn(move |req: hyper::Request<Incoming>| {
let host = svc_host.clone();
let path = target_path.clone();
let save = save_path.clone();
let result_tx = result_tx.clone();
let upstream = svc_upstream.clone();
let waiters = waiters.clone();
let upstream = upstream.clone();
async move {
Ok::<_, std::convert::Infallible>(
forward_request(req, dst, host, path, save, result_tx, upstream).await,
forward_request(req, dst, host, waiters, upstream).await,
)
}
});
@@ -191,6 +183,7 @@ where
break;
}
_ = cancel.cancelled(), if !graceful => {
info!("teardown: graceful shutdown of downstream connection");
conn.as_mut().graceful_shutdown();
graceful = true;
}
@@ -218,38 +211,42 @@ where
#[allow(clippy::too_many_arguments)]
async fn forward_request(
req: hyper::Request<Incoming>,
mut req: hyper::Request<Incoming>,
dst: SocketAddr,
url_host: String,
target_path: Regex,
save_path: String,
result_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<String>>>>,
waiters: Waiters,
upstream: Upstream,
) -> hyper::Response<Full<Bytes>> {
match forward_request_inner(req, dst, &url_host, &target_path, &upstream).await {
Ok((response, matched_body)) => {
if let Some((url, body)) = matched_body {
info!("Target matched! Downloading {} bytes", body.len());
let path = std::path::Path::new(&save_path);
if !path.is_absolute() {
error!("Failed to resolve path: {save_path}");
if let Some(rewritten) = force_full_mysekai(req.uri()) {
warn!("rewrite mysekai flag: {} -> {}", req.uri(), rewritten);
*req.uri_mut() = rewritten;
}
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(path, &body).await {
Ok(()) => {
if let Some(tx) = result_tx.lock().await.take() {
let _ = tx.send(url);
}
}
Err(e) => error!("write {save_path} failed: {e:?}"),
let uri = req.uri().clone();
let url = build_url(&url_host, &uri);
let path_and_query = uri
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let waiter = take_matching_waiter(&waiters, &path_and_query);
if let Some(w) = &waiter {
info!("step '{}' matched", w.id);
}
let want_body = waiter.is_some();
match forward_request_inner(req, dst, &url_host, upstream, want_body).await {
Ok((response, saved_body)) => {
if let (Some(w), Some(body)) = (waiter, saved_body) {
save_and_notify(w, url, body);
}
response
}
Err(e) => {
error!("forward to {dst} failed: {e:?}");
if let Some(w) = waiter {
crate::notify_step_result(&w.id, None);
}
hyper::Response::builder()
.status(hyper::StatusCode::BAD_GATEWAY)
.header("content-type", "text/plain; charset=utf-8")
@@ -259,28 +256,39 @@ async fn forward_request(
}
}
fn save_and_notify(waiter: Waiter, url: String, body: Bytes) {
tokio::spawn(async move {
let path = std::path::Path::new(&waiter.save_path);
if !path.is_absolute() {
error!("save_path 不是绝对路径({})", waiter.save_path);
}
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(path, &body).await {
Ok(()) => {
info!("step '{}' saved ({} bytes)", waiter.id, body.len());
crate::notify_step_result(&waiter.id, Some(&url));
}
Err(e) => {
error!("write {} failed: {e:?}", waiter.save_path);
crate::notify_step_result(&waiter.id, None);
}
}
});
}
async fn forward_request_inner(
req: hyper::Request<Incoming>,
dst: SocketAddr,
url_host: &str,
target_path: &Regex,
upstream: &Upstream,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<(String, Bytes)>)> {
upstream: Upstream,
want_body: bool,
) -> anyhow::Result<(hyper::Response<Full<Bytes>>, Option<Bytes>)> {
let (mut parts, body) = req.into_parts();
if let Some(rewritten) = force_full_mysekai(&parts.uri) {
warn!("rewrite mysekai flag: {} -> {}", parts.uri, rewritten);
parts.uri = rewritten;
}
let parts_uri = parts.uri.clone();
let path_and_query = parts_uri
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let body_bytes = body.collect().await?.to_bytes();
// 拒绝长链接,反正抓到就跑。
// 拒绝长链接
for h in [
"connection",
"keep-alive",
@@ -297,7 +305,6 @@ async fn forward_request_inner(
let mut client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store())
.with_no_client_auth();
// 问就是有服务器不接受 H2
client_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = rustls::pki_types::ServerName::try_from(url_host.to_string())?;
@@ -329,17 +336,14 @@ async fn forward_request_inner(
res_parts.headers.remove(h);
}
let is_target = target_path.is_match(&path_and_query);
let url = build_url(url_host, &parts_uri);
let save = if is_target {
Some((url, res_bytes.clone()))
let saved = if want_body {
Some(res_bytes.clone())
} else {
None
};
Ok((
hyper::Response::from_parts(res_parts, Full::new(res_bytes)),
save,
saved,
))
}
@@ -362,6 +366,40 @@ fn force_full_mysekai(uri: &Uri) -> Option<Uri> {
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct StepSpec {
pub id: String,
#[serde(default)]
pub hosts: Vec<String>,
pub path: String,
#[serde(rename = "savePath")]
pub save_path: String,
/// 持续运行:命中后不摘除 waiter,同类型的包可以反复抓
#[serde(default)]
pub repeat: bool,
}
#[derive(Clone)]
pub struct Waiter {
pub id: String,
pub path: Regex,
pub save_path: String,
pub repeat: bool,
}
pub type Waiters = Arc<StdMutex<Vec<Waiter>>>;
fn take_matching_waiter(waiters: &StdMutex<Vec<Waiter>>, path_and_query: &str) -> Option<Waiter> {
let mut list = waiters.lock().unwrap();
let idx = list.iter().position(|w| w.path.is_match(path_and_query))?;
if list[idx].repeat {
// 持续运行:保留 waiter,让后续同类型的请求继续命中
Some(list[idx].clone())
} else {
Some(list.remove(idx))
}
}
#[derive(Clone, Debug)]
pub struct ProxyConfig {
pub tls: bool,
@@ -500,14 +538,13 @@ async fn connect_via_proxy(
Ok(io)
}
pub async fn run_capture_async(
pub async fn run_session(
tun_fd: i32,
target_hosts: Vec<String>,
target_path: Regex,
save_path: String,
proxy: Option<ProxyConfig>,
cancel_token: CancellationToken,
) -> anyhow::Result<Option<String>> {
waiters: Waiters,
cancel: CancellationToken,
) -> anyhow::Result<()> {
let device = unsafe { BorrowedAsyncDevice::borrow_raw(tun_fd as RawFd)? };
let mut ipstack_config = ipstack::IpStackConfig::default();
@@ -520,19 +557,11 @@ pub async fn run_capture_async(
let tls_acceptor = build_tls_acceptor();
let upstream = Upstream::new(proxy);
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel::<String>();
let result_tx = Arc::new(Mutex::new(Some(result_tx)));
let result: anyhow::Result<Option<String>> = loop {
let token = cancel_token.clone();
loop {
let token = cancel.clone();
tokio::select! {
_ = cancel_token.cancelled() => {
let final_result = result_rx.try_recv().ok();
break Ok(final_result);
}
Ok(url) = &mut result_rx => {
cancel_token.cancel();
break Ok(Some(url));
_ = cancel.cancelled() => {
break;
}
_ = bridge_tasks.join_next(), if !bridge_tasks.is_empty() => {}
@@ -541,7 +570,7 @@ pub async fn run_capture_async(
Ok(s) => s,
Err(e) => {
error!("ip_stack accept error (device might be dropped): {e:?}");
break Err(anyhow::Error::from(e));
break;
}
};
@@ -551,7 +580,7 @@ pub async fn run_capture_async(
if dst.port() == 443 {
// drop quic roughly
drop(udp);
} else{
} else {
bridge_tasks.spawn(async move {
if let Err(e) = udp_passthrough(udp, dst, token).await {
error!("udp passthrough error: {e:?}");
@@ -564,7 +593,7 @@ pub async fn run_capture_async(
if dst.port() == 443 {
let (app_io, mut tun_io) = tokio::io::duplex(256 * 1024);
let pump_token = cancel_token.clone();
let pump_token = cancel.clone();
bridge_tasks.spawn(async move {
tokio::select! {
_ = pump_token.cancelled() => {
@@ -585,11 +614,9 @@ pub async fn run_capture_async(
});
let hosts = target_hosts.clone();
let path = target_path.clone();
let save = save_path.clone();
let server_config = tls_acceptor.clone();
let token = cancel_token.clone();
let result_tx = result_tx.clone();
let token = cancel.clone();
let waiters = waiters.clone();
let upstream = upstream.clone();
tokio::spawn(async move {
@@ -598,18 +625,16 @@ pub async fn run_capture_async(
dst,
upstream,
hosts,
path,
save,
waiters,
server_config,
token,
result_tx
).await {
Ok(()) => { }
Ok(()) => {}
Err(e) => error!("handle tls conn error: {e:?}"),
}
});
}else{
let pump_token = cancel_token.clone();
} else {
let pump_token = cancel.clone();
let upstream = upstream.clone();
bridge_tasks.spawn(async move {
let _ =
@@ -622,7 +647,7 @@ pub async fn run_capture_async(
}
}
}
};
}
let _ = tokio::time::timeout(TEARDOWN_GRACE, async {
while bridge_tasks.join_next().await.is_some() {}
@@ -631,7 +656,8 @@ pub async fn run_capture_async(
bridge_tasks.abort_all();
while bridge_tasks.join_next().await.is_some() {}
result
info!("session stopped");
Ok(())
}
async fn tcp_passthrough<IO>(