重构上传

This commit is contained in:
2026-10-29 14:39:14 +08:00
parent 5739f7f8e7
commit 0cf352cabb
19 changed files with 1398 additions and 190 deletions
+11 -2
View File
@@ -5,11 +5,12 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 使用 ## 使用
- 安装并打开应用 - 安装并打开应用
- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码,粘贴到右上角菜单的「上传设置」界面,并勾选「上传到 Haruki」 - 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。
- 在右上角选择需要的配置 - 在右上角选择需要的配置
- 可以取消勾选不需要的抓包步骤 - 可以取消勾选不需要的抓包步骤
- 点击开始,如果跳出建立 VPN 的提示,请允许 - 点击开始,如果跳出建立 VPN 的提示,请允许
- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束;每一步抓到后就会立刻执行该步的后处理(勾选了上传就会马上上传,不用等所有步骤) - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束
- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」
## 触发位置 ## 触发位置
@@ -20,3 +21,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传
## 上游代理 ## 上游代理
你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。 你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。
在「代理设置」里填写代理地址后,还要勾选「使用上游代理」才会生效;地址为空时无法勾选(等同于直连)。
「持续运行」勾选后抓到包不会自动退出。
## 自定义上传配置
我们提供了三个Header的占位符:`$original_url` `$file_name` `$user_id`
@@ -44,7 +44,7 @@ class MainActivity : ComponentActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
SettingsStore.loadSelectedProfileId(this) SettingsStore.selectedProfileId
?.let { PredefinedProfiles.byId(it) } ?.let { PredefinedProfiles.byId(it) }
?.let { CaptureStateHolder.selectProfile(it) } ?.let { CaptureStateHolder.selectProfile(it) }
@@ -53,7 +53,7 @@ class MainActivity : ComponentActivity() {
IndPjskTheme { IndPjskTheme {
var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) } var screen by rememberSaveable { mutableStateOf(AppScreen.MAIN) }
var bypassPackages by remember { var bypassPackages by remember {
mutableStateOf(SettingsStore.loadBypassPackages(this)) mutableStateOf(SettingsStore.bypassPackages)
} }
val readme = remember { val readme = remember {
runCatching { runCatching {
@@ -87,7 +87,7 @@ class MainActivity : ComponentActivity() {
bypassPackages = bypassPackages, bypassPackages = bypassPackages,
onBypassChange = { newSet -> onBypassChange = { newSet ->
bypassPackages = newSet bypassPackages = newSet
SettingsStore.saveBypassPackages(this, newSet) SettingsStore.bypassPackages = newSet
}, },
onSelectApps = { screen = AppScreen.APP_SELECTION }, onSelectApps = { screen = AppScreen.APP_SELECTION },
onBack = { screen = AppScreen.MAIN }, onBack = { screen = AppScreen.MAIN },
@@ -101,7 +101,7 @@ class MainActivity : ComponentActivity() {
} else { } else {
bypassPackages + entry.packageName bypassPackages + entry.packageName
} }
SettingsStore.saveBypassPackages(this, bypassPackages) SettingsStore.bypassPackages = bypassPackages
}, },
onBack = { screen = AppScreen.PROXY }, onBack = { screen = AppScreen.PROXY },
) )
@@ -1,65 +1,116 @@
package net.bluemangoo.indpjsk.data package net.bluemangoo.indpjsk.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.edit import androidx.core.content.edit
import net.bluemangoo.indpjsk.App
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
object SettingsStore { object SettingsStore {
private fun prefs() =
App.appContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
private const val PREFS = "settings" private inline fun <reified T> pref(
private const val KEY_UPLOAD_CODE = "upload_code" default: T? = null,
private const val KEY_USE_UPLOAD = "use_upload" key: String? = null
private const val KEY_PROXY = "upstream_proxy" ) = PrefProperty(
private const val KEY_USE_PROXY = "use_proxy" clazz = T::class,
private const val KEY_BYPASS = "bypass_packages" customDefault = default,
private const val KEY_PROFILE = "selected_profile" customKey = key,
prefs = { prefs() }
)
private fun prefs(context: Context) = var uploadProfilesJson by pref<String>()
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) 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) }
}
fun loadUseUpload(context: Context): Boolean {
return loadUploadCode(context).isNotBlank() && prefs(context).getBoolean(KEY_USE_UPLOAD, true)
}
fun saveUseUpload(context: Context, enabled: Boolean) {
prefs(context).edit { putBoolean(KEY_USE_UPLOAD, enabled) }
}
fun loadProxy(context: Context): String =
prefs(context).getString(KEY_PROXY, "").orEmpty()
fun saveProxy(context: Context, proxy: String) {
prefs(context).edit { putString(KEY_PROXY, proxy.trim()) }
}
fun loadUseProxy(context: Context): Boolean {
return loadProxy(context).isNotBlank() && prefs(context).getBoolean(KEY_USE_PROXY, true)
}
fun saveUseProxy(context: Context, enabled: Boolean) {
prefs(context).edit { putBoolean(KEY_USE_PROXY, enabled) }
}
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) }
}
fun loadSelectedProfileId(context: Context): String? =
prefs(context).getString(KEY_PROFILE, null)
fun saveSelectedProfileId(context: Context, id: String) {
prefs(context).edit { putString(KEY_PROFILE, id) }
}
} }
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}")
}
}
@Suppress("UNCHECKED_CAST")
private fun getOrCreateState(property: KProperty<*>): MutableState<T> {
return internalState ?: run {
val key = getKey(property)
val sp = prefs()
val fallback = getDefault()
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
}
else -> fallback
}
mutableStateOf(initialVal).also { internalState = it }
}
}
@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>))
}
}
}
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 org.json.JSONObject
import androidx.core.content.edit import androidx.core.content.edit
/**
* 每个 profile 下每个步骤「要不要抓」的持久化。
*
* 就一个 JSON 字符串塞进 SharedPreferences,键是 "profileId/stepId",值 Boolean,
* 没存过的默认 true(全抓)。
*/
object StepSelectionStore { object StepSelectionStore {
private const val PREFS = "step_selection" private const val PREFS = "step_selection"
@@ -141,7 +141,7 @@ fun CaptureScreen(
DropdownMenuItem( DropdownMenuItem(
text = { Text(profile.name) }, text = { Text(profile.name) },
onClick = { onClick = {
viewModel.selectProfile(context, profile) viewModel.selectProfile(profile)
profileExpanded = false profileExpanded = false
} }
) )
@@ -46,9 +46,9 @@ class CaptureViewModel : ViewModel() {
) )
) )
fun selectProfile(context: Context, profile: CaptureProfile) { fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile) CaptureStateHolder.selectProfile(profile)
SettingsStore.saveSelectedProfileId(context, profile.id) SettingsStore.selectedProfileId = profile.id
} }
fun startCapture(context: Context) { fun startCapture(context: Context) {
@@ -18,6 +18,7 @@ import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.AppEntry import net.bluemangoo.indpjsk.data.AppEntry
import net.bluemangoo.indpjsk.data.InstalledApps import net.bluemangoo.indpjsk.data.InstalledApps
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.util.asState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -28,8 +29,9 @@ fun ProxySettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) } var proxy by SettingsStore::upstreamProxy.asState()
var useProxy by remember { mutableStateOf(SettingsStore.loadUseProxy(context)) } var useProxy by SettingsStore::useProxy.asState()
var continueRun by SettingsStore::continueRun.asState()
var pendingRemove by remember { mutableStateOf<AppEntry?>(null) } var pendingRemove by remember { mutableStateOf<AppEntry?>(null) }
val entries = remember(bypassPackages) { val entries = remember(bypassPackages) {
@@ -62,6 +64,33 @@ fun ProxySettingsScreen(
.padding(horizontal = 0.dp, vertical = 12.dp), .padding(horizontal = 0.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(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( Column(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
@@ -77,7 +106,6 @@ fun ProxySettingsScreen(
checked = useProxy && canUseProxy, checked = useProxy && canUseProxy,
onCheckedChange = { onCheckedChange = {
useProxy = it useProxy = it
SettingsStore.saveUseProxy(context, it)
}, },
enabled = canUseProxy enabled = canUseProxy
) )
@@ -90,13 +118,7 @@ fun ProxySettingsScreen(
OutlinedTextField( OutlinedTextField(
value = proxy, value = proxy,
onValueChange = { onValueChange = {
proxy = it proxy = it.trim()
SettingsStore.saveProxy(context, it)
// 地址被清空时不允许再勾着;顺手把偏好也清掉,避免重新填写时自动勾上
if (it.isBlank() && useProxy) {
useProxy = false
SettingsStore.saveUseProxy(context, false)
}
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -106,7 +128,6 @@ fun ProxySettingsScreen(
supportingText = { supportingText = {
Text( Text(
text = when { text = when {
proxy.isBlank() -> "填写地址后才能勾选启用;只支持 http / https 代理"
!looksValid -> "格式示例:http://127.0.0.1:7890" !looksValid -> "格式示例:http://127.0.0.1:7890"
else -> "只支持 http / https 代理" else -> "只支持 http / https 代理"
} }
@@ -1,29 +1,65 @@
package net.bluemangoo.indpjsk.ui 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.layout.*
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.input.VisualTransformation 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 androidx.compose.ui.unit.dp
import net.bluemangoo.indpjsk.R import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.upload.*
import org.json.JSONObject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UploadSettingsScreen(onBack: () -> Unit) { fun UploadSettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current var entries by remember { mutableStateOf(UploadStore.load()) }
var code by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } var editing by remember { mutableStateOf<UploadEditTarget?>(null) }
var useUpload by remember { mutableStateOf(SettingsStore.loadUseUpload(context)) } var pendingDelete by remember { mutableStateOf<Int?>(null) }
var showCode by remember { mutableStateOf(false) }
val canUpload = code.isNotBlank() 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( Scaffold(
topBar = { topBar = {
@@ -36,6 +72,14 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
contentDescription = "返回" contentDescription = "返回"
) )
} }
},
actions = {
IconButton(onClick = { editing = UploadEditTarget(null, null) }) {
Icon(
painter = painterResource(R.drawable.add_24px),
contentDescription = "添加"
)
}
} }
) )
} }
@@ -43,53 +87,760 @@ fun UploadSettingsScreen(onBack: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
OutlinedTextField( if (entries.isEmpty()) {
value = code,
onValueChange = {
code = it.trim()
SettingsStore.saveUploadCode(context, code)
if (code.isBlank() && useUpload) {
useUpload = false
SettingsStore.saveUseUpload(context, false)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Haruki 上传码") },
placeholder = { Text("从 Haruki 工具箱 - 数据上传 - iOS 获取") },
visualTransformation = if (showCode) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
TextButton(onClick = { showCode = !showCode }) {
Text(if (showCode) "隐藏" else "显示")
}
}
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = useUpload && canUpload,
onCheckedChange = {
useUpload = it
SettingsStore.saveUseUpload(context, it)
},
enabled = canUpload
)
Text( Text(
text = "上传到 Haruki", text = "还没有上传配置",
style = MaterialTheme.typography.bodyMedium 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,101 +1,105 @@
package net.bluemangoo.indpjsk.upload package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.App
import net.bluemangoo.indpjsk.data.CaptureResult import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.vpn.VpnLog import net.bluemangoo.indpjsk.vpn.VpnLog
import org.json.JSONObject
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.RandomAccessFile import java.io.RandomAccessFile
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.*
import kotlin.random.Random 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 { 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 CHUNK_SIZE = 1024 * 1024
private const val SCRIPT_VERSION = "1.0.1"
suspend fun upload( suspend fun tryUpload(result: CaptureResult) {
uploadCode: String, 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, originalUrl: String,
file: File, file: File,
) = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" } 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() val uploadId = randomUploadId()
RandomAccessFile(file, "r").use { raf -> RandomAccessFile(file, "r").use { raf ->
val size = raf.length() val size = raf.length()
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt() val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
val buf = ByteArray(CHUNK_SIZE) val buf = ByteArray(CHUNK_SIZE)
VpnLog.info("${file.name} 上传开始") VpnLog.info("[${profile.id}] ${file.name} 上传开始 (${size} bytes)")
for (index in 0 until total) { for (index in 0 until total) {
val remaining = size - raf.filePointer val remaining = size - raf.filePointer
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt() val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
if (len > 0) raf.readFully(buf, 0, len) 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") if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
} }
VpnLog.info("${file.name} 上传完成")
} }
} true
suspend fun tryUpload(result: CaptureResult) {
val context = App.appContext
// 复选框没勾就不传(没填上传码时复选框本身不可用)
if (!SettingsStore.loadUseUpload(context)) return
val uploadCode = SettingsStore.loadUploadCode(context).trim()
if (uploadCode.isEmpty()) {
VpnLog.warn("未填写上传码,跳过上传")
return
}
VpnLog.info("${result.profile.name}${result.step.name} 开始上传")
upload(uploadCode, result.url, result.file)
VpnLog.success("${result.profile.name}${result.step.name} 上传完成")
} }
private fun postChunk( private fun postChunk(
uploadCode: String, profile: UploadProfile,
url: String,
uploadId: String, uploadId: String,
index: Int, index: Int,
total: Int, total: Int,
originalUrl: String, originalUrl: String,
data: ByteArray, data: ByteArray,
file: File,
length: Int, length: Int,
): Int { ): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
try { try {
conn.requestMethod = "POST" conn.requestMethod = "POST"
conn.doOutput = true conn.doOutput = true
conn.instanceFollowRedirects = false conn.instanceFollowRedirects = true
conn.connectTimeout = 15_000 conn.connectTimeout = 15_000
conn.readTimeout = 60_000 conn.readTimeout = 60_000
conn.setRequestProperty("Content-Type", "application/octet-stream") 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-Upload-Id", uploadId)
conn.setRequestProperty("X-Chunk-Index", index.toString()) conn.setRequestProperty("X-Chunk-Index", index.toString())
conn.setRequestProperty("X-Total-Chunks", total.toString()) conn.setRequestProperty("X-Total-Chunks", total.toString())
conn.setRequestProperty("X-Original-Url", originalUrl) conn.setRequestProperty("X-Original-Url", originalUrl)
conn.setFixedLengthStreamingMode(length) conn.setFixedLengthStreamingMode(length)
profile.preRequest(conn, originalUrl, file)
conn.outputStream.use { it.write(data, 0, length) } conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode val code = conn.responseCode
@@ -106,7 +110,7 @@ object Uploader {
?.use { it.readText() } ?.use { it.readText() }
?.take(300) ?.take(300)
}.getOrNull().orEmpty() }.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body") throw IOException("${profile.id} 分片 ${index + 1}/$total 上传失败: HTTP $code $body")
} }
return code return code
} finally { } finally {
@@ -114,7 +118,6 @@ object Uploader {
} }
} }
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String { private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz" val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) { return buildString(9) {
@@ -122,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
@@ -101,12 +101,12 @@ class CaptureVpnService : VpnService() {
return return
} }
val proxy: String? = if (SettingsStore.loadUseProxy(this)) { val proxy: String? = if (SettingsStore.useProxy) {
SettingsStore.loadProxy(this).trim().takeIf { it.isNotEmpty() } SettingsStore.upstreamProxy.trim().takeIf { it.isNotEmpty() }
} else { } else {
null null
} }
val bypassPackages = SettingsStore.loadBypassPackages(this) val bypassPackages = SettingsStore.bypassPackages
if (proxy != null) { if (proxy != null) {
VpnLog.info("上游代理: $proxy") VpnLog.info("上游代理: $proxy")
} else { } else {
@@ -123,9 +123,13 @@ class CaptureVpnService : VpnService() {
val latch = CountDownLatch(1) val latch = CountDownLatch(1)
allDone = latch allDone = latch
// 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除)
val continueRun = SettingsStore.continueRun
VpnLog.info("==== 开始抓包 ====") VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}") VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}") VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}")
if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)")
VpnBridge.stepListener = { stepId, url -> VpnBridge.stepListener = { stepId, url ->
val step = enabledSteps.firstOrNull { it.id == stepId } val step = enabledSteps.firstOrNull { it.id == stepId }
@@ -134,7 +138,7 @@ class CaptureVpnService : VpnService() {
} else { } else {
handleStepResult(profile, step, url) handleStepResult(profile, step, url)
finishedSteps.add(step.id) finishedSteps.add(step.id)
if (finishedSteps.size >= enabledSteps.size) { if (!continueRun && finishedSteps.size >= enabledSteps.size) {
latch.countDown() latch.countDown()
} }
} }
@@ -191,6 +195,7 @@ class CaptureVpnService : VpnService() {
put("hosts", JSONArray(step.hosts)) put("hosts", JSONArray(step.hosts))
put("path", step.path) put("path", step.path)
put("savePath", captureFile(filesDir, profile, step).absolutePath) put("savePath", captureFile(filesDir, profile, step).absolutePath)
put("repeat", continueRun)
}) })
} }
}.toString() }.toString()
@@ -202,7 +207,9 @@ class CaptureVpnService : VpnService() {
latch.await() latch.await()
if (stopRequested) { if (continueRun) {
VpnLog.warn("持续运行结束(用户停止),已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stopRequested) {
VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步") VpnLog.warn("用户停止,已抓到 ${stepResults.size}/${enabledSteps.size} 步")
} else if (stepResults.size < enabledSteps.size) { } else if (stepResults.size < enabledSteps.size) {
errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步" errorMsg = "只抓到 ${stepResults.size}/${enabledSteps.size} 步"
@@ -31,11 +31,13 @@ object VpnBridge {
@JvmStatic @JvmStatic
external fun shutdownSession() external fun shutdownSession()
@Suppress("unused")
@JvmStatic @JvmStatic
fun nativeStepResult(stepId: String, url: String?) { fun nativeStepResult(stepId: String, url: String?) {
stepListener?.invoke(stepId, url) stepListener?.invoke(stepId, url)
} }
@Suppress("unused")
@JvmStatic @JvmStatic
fun nativeLog(level: String, msg: String) { fun nativeLog(level: String, msg: String) {
when (level) { when (level) {
@@ -15,12 +15,6 @@ data class LogEntry(
val message: String val message: String
) )
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog { object VpnLog {
private const val MAX = 800 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>
+1 -1
View File
@@ -20,7 +20,6 @@ fn init_jvm(env: &mut Env) {
return; return;
} }
if let Ok(vm) = env.get_java_vm() { 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")) { if let Ok(class) = env.find_class(jni_str!("net/bluemangoo/indpjsk/vpn/VpnBridge")) {
match env.new_global_ref(class) { match env.new_global_ref(class) {
Ok(g) => { Ok(g) => {
@@ -186,6 +185,7 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot(
id: step.id, id: step.id,
path: re, path: re,
save_path: step.save_path, save_path: step.save_path,
repeat: step.repeat,
}); });
} }
if hosts.is_empty() { if hosts.is_empty() {
+11 -1
View File
@@ -374,12 +374,17 @@ pub struct StepSpec {
pub path: String, pub path: String,
#[serde(rename = "savePath")] #[serde(rename = "savePath")]
pub save_path: String, pub save_path: String,
/// 持续运行:命中后不摘除 waiter,同类型的包可以反复抓
#[serde(default)]
pub repeat: bool,
} }
#[derive(Clone)]
pub struct Waiter { pub struct Waiter {
pub id: String, pub id: String,
pub path: Regex, pub path: Regex,
pub save_path: String, pub save_path: String,
pub repeat: bool,
} }
pub type Waiters = Arc<StdMutex<Vec<Waiter>>>; pub type Waiters = Arc<StdMutex<Vec<Waiter>>>;
@@ -387,7 +392,12 @@ pub type Waiters = Arc<StdMutex<Vec<Waiter>>>;
fn take_matching_waiter(waiters: &StdMutex<Vec<Waiter>>, path_and_query: &str) -> Option<Waiter> { fn take_matching_waiter(waiters: &StdMutex<Vec<Waiter>>, path_and_query: &str) -> Option<Waiter> {
let mut list = waiters.lock().unwrap(); let mut list = waiters.lock().unwrap();
let idx = list.iter().position(|w| w.path.is_match(path_and_query))?; let idx = list.iter().position(|w| w.path.is_match(path_and_query))?;
Some(list.remove(idx)) if list[idx].repeat {
// 持续运行:保留 waiter,让后续同类型的请求继续命中
Some(list[idx].clone())
} else {
Some(list.remove(idx))
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]