diff --git a/README.md b/README.md index ef0be65..e81f31d 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传 ## 使用 - 安装并打开应用 -- 如果需要上传,请在 [Haruki 工具箱](https://haruki.seiunx.com/upload-data) 中的 `iOS 模块` 方式里复制上传码,粘贴到右上角菜单的「上传设置」界面,并勾选「上传到 Haruki」 +- 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。 - 在右上角选择需要的配置 - 可以取消勾选不需要的抓包步骤 - 点击开始,如果跳出建立 VPN 的提示,请允许 -- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束;每一步抓到后就会立刻执行该步的后处理(勾选了上传就会马上上传,不用等所有步骤) +- 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束 +- 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」 ## 触发位置 @@ -20,3 +21,11 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传 ## 上游代理 你可能会需要上游代理,有时它是一个运行在本地的应用,此时为了防止冲突,需要将对应的应用加入绕过列表。 + +在「代理设置」里填写代理地址后,还要勾选「使用上游代理」才会生效;地址为空时无法勾选(等同于直连)。 + +「持续运行」勾选后抓到包不会自动退出。 + +## 自定义上传配置 + +我们提供了三个Header的占位符:`$original_url` `$file_name` `$user_id` diff --git a/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt index fc108ed..13bb6f3 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/MainActivity.kt @@ -44,7 +44,7 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { - SettingsStore.loadSelectedProfileId(this) + SettingsStore.selectedProfileId ?.let { PredefinedProfiles.byId(it) } ?.let { CaptureStateHolder.selectProfile(it) } @@ -53,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 { @@ -87,7 +87,7 @@ class MainActivity : ComponentActivity() { bypassPackages = bypassPackages, onBypassChange = { newSet -> bypassPackages = newSet - SettingsStore.saveBypassPackages(this, newSet) + SettingsStore.bypassPackages = newSet }, onSelectApps = { screen = AppScreen.APP_SELECTION }, onBack = { screen = AppScreen.MAIN }, @@ -101,7 +101,7 @@ class MainActivity : ComponentActivity() { } else { bypassPackages + entry.packageName } - SettingsStore.saveBypassPackages(this, bypassPackages) + SettingsStore.bypassPackages = bypassPackages }, onBack = { screen = AppScreen.PROXY }, ) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt index 509a7d0..5b42f05 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/SettingsStore.kt @@ -1,65 +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_USE_UPLOAD = "use_upload" - private const val KEY_PROXY = "upstream_proxy" - private const val KEY_USE_PROXY = "use_proxy" - private const val KEY_BYPASS = "bypass_packages" - private const val KEY_PROFILE = "selected_profile" + private inline fun 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() + var upstreamProxy by pref() + var useProxy by pref() + var bypassPackages by pref>() + var selectedProfileId by pref(default = null) + var continueRun by pref() - 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 = - prefs(context).getStringSet(KEY_BYPASS, emptySet())?.toSet() ?: emptySet() - - fun saveBypassPackages(context: Context, packages: Set) { - 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( + private val clazz: KClass<*>, + private val customDefault: T? = null, + private val customKey: String? = null, + private val prefs: () -> SharedPreferences +) : ReadWriteProperty { + + private var internalState: MutableState? = 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() as T + else -> throw IllegalArgumentException("Unsupported type: ${clazz.simpleName}") + } + } + + @Suppress("UNCHECKED_CAST") + private fun getOrCreateState(property: KProperty<*>): MutableState { + 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)?.toSet() ?: emptySet()) 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)) + } + } + } + + 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 { + resolvedKey = customKey ?: property.name + getOrCreateState(property) + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt index 111c6a8..47a9e04 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/StepSelectionStore.kt @@ -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" diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt index 1beb450..b36f4ac 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureScreen.kt @@ -141,7 +141,7 @@ fun CaptureScreen( DropdownMenuItem( text = { Text(profile.name) }, onClick = { - viewModel.selectProfile(context, profile) + viewModel.selectProfile(profile) profileExpanded = false } ) diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt index e9d4083..a396c43 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt @@ -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) { diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt index 161945d..4a990be 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/ProxySettingsScreen.kt @@ -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,8 +29,9 @@ fun ProxySettingsScreen( onBack: () -> Unit, ) { val context = LocalContext.current - var proxy by remember { mutableStateOf(SettingsStore.loadProxy(context)) } - var useProxy by remember { mutableStateOf(SettingsStore.loadUseProxy(context)) } + var proxy by SettingsStore::upstreamProxy.asState() + var useProxy by SettingsStore::useProxy.asState() + var continueRun by SettingsStore::continueRun.asState() var pendingRemove by remember { mutableStateOf(null) } val entries = remember(bypassPackages) { @@ -62,6 +64,33 @@ 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) @@ -77,7 +106,6 @@ fun ProxySettingsScreen( checked = useProxy && canUseProxy, onCheckedChange = { useProxy = it - SettingsStore.saveUseProxy(context, it) }, enabled = canUseProxy ) @@ -90,13 +118,7 @@ fun ProxySettingsScreen( OutlinedTextField( value = proxy, onValueChange = { - proxy = it - SettingsStore.saveProxy(context, it) - // 地址被清空时不允许再勾着;顺手把偏好也清掉,避免重新填写时自动勾上 - if (it.isBlank() && useProxy) { - useProxy = false - SettingsStore.saveUseProxy(context, false) - } + proxy = it.trim() }, modifier = Modifier.fillMaxWidth(), singleLine = true, @@ -106,7 +128,6 @@ fun ProxySettingsScreen( supportingText = { Text( text = when { - proxy.isBlank() -> "填写地址后才能勾选启用;只支持 http / https 代理" !looksValid -> "格式示例:http://127.0.0.1:7890" else -> "只支持 http / https 代理" } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt index ad04fa0..db7e89e 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt @@ -1,29 +1,65 @@ 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.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation +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.data.SettingsStore +import net.bluemangoo.indpjsk.upload.* +import org.json.JSONObject @OptIn(ExperimentalMaterial3Api::class) @Composable fun UploadSettingsScreen(onBack: () -> Unit) { - val context = LocalContext.current - var code by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) } - var useUpload by remember { mutableStateOf(SettingsStore.loadUseUpload(context)) } - var showCode by remember { mutableStateOf(false) } + var entries by remember { mutableStateOf(UploadStore.load()) } + var editing by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } - 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( topBar = { @@ -36,6 +72,14 @@ fun UploadSettingsScreen(onBack: () -> Unit) { 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( modifier = Modifier .padding(padding) + .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - OutlinedTextField( - 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 - ) + if (entries.isEmpty()) { Text( - text = "上传到 Haruki", - style = MaterialTheme.typography.bodyMedium + 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, + 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(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>, + onChange: (List>) -> 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() } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt new file mode 100644 index 0000000..5ddfad2 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt @@ -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 { + val raw = SettingsStore.uploadProfilesJson + return runCatching { decode(raw) } + .onFailure { VpnLog.error("上传配置读取失败: ${it.message}") } + .getOrDefault(emptyList()) + } + + fun save(entries: List) { + SettingsStore.uploadProfilesJson = encode(entries) + } + + fun encode(entries: List): 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 { + val root = JSONObject(raw) + val arr = root.optJSONArray(KEY_PROFILES) ?: JSONArray() + val out = mutableListOf() + 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 { + if (obj == null) return emptyMap() + val map = linkedMapOf() + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = obj.optString(key) + } + return map + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt index 92b48a0..8bfa962 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt @@ -1,101 +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 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} 上传完成") + 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 @@ -106,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 { @@ -114,7 +118,6 @@ object Uploader { } } - /** 9 位随机字母数字,同一文件所有分片共用一个 */ private fun randomUploadId(): String { val chars = "0123456789abcdefghijklmnopqrstuvwxyz" 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 +} + +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 + 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 + get() { + val payload = parseJwt() ?: return emptyMap() + val map = mutableMapOf() + 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 = emptyMap() +} + +class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map) : + 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 = 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 } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/util/State.kt b/app/src/main/java/net/bluemangoo/indpjsk/util/State.kt new file mode 100644 index 0000000..382af0f --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/util/State.kt @@ -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 KMutableProperty0.asState(): MutableState { + return remember { + object : MutableState { + 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 } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bluemangoo/indpjsk/util/String.kt b/app/src/main/java/net/bluemangoo/indpjsk/util/String.kt new file mode 100644 index 0000000..b93c077 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/util/String.kt @@ -0,0 +1 @@ +package net.bluemangoo.indpjsk.util diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt index ed4f1bc..676aa8a 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/CaptureVpnService.kt @@ -101,12 +101,12 @@ class CaptureVpnService : VpnService() { return } - val proxy: String? = if (SettingsStore.loadUseProxy(this)) { - SettingsStore.loadProxy(this).trim().takeIf { it.isNotEmpty() } + val proxy: String? = if (SettingsStore.useProxy) { + SettingsStore.upstreamProxy.trim().takeIf { it.isNotEmpty() } } else { null } - val bypassPackages = SettingsStore.loadBypassPackages(this) + val bypassPackages = SettingsStore.bypassPackages if (proxy != null) { VpnLog.info("上游代理: $proxy") } else { @@ -123,9 +123,13 @@ class CaptureVpnService : VpnService() { val latch = CountDownLatch(1) allDone = latch + // 持续运行:抓到后不自动停,并且同一类型的包可以反复抓(Rust 侧 waiter 不摘除) + val continueRun = SettingsStore.continueRun + VpnLog.info("==== 开始抓包 ====") VpnLog.info("配置: ${profile.name}") VpnLog.info("步骤(并行): ${enabledSteps.joinToString(" / ") { it.name }}") + if (continueRun) VpnLog.info("持续运行: 开启(抓到后不会自动停止)") VpnBridge.stepListener = { stepId, url -> val step = enabledSteps.firstOrNull { it.id == stepId } @@ -134,7 +138,7 @@ class CaptureVpnService : VpnService() { } else { handleStepResult(profile, step, url) finishedSteps.add(step.id) - if (finishedSteps.size >= enabledSteps.size) { + if (!continueRun && finishedSteps.size >= enabledSteps.size) { latch.countDown() } } @@ -191,6 +195,7 @@ class CaptureVpnService : VpnService() { put("hosts", JSONArray(step.hosts)) put("path", step.path) put("savePath", captureFile(filesDir, profile, step).absolutePath) + put("repeat", continueRun) }) } }.toString() @@ -202,7 +207,9 @@ class CaptureVpnService : VpnService() { latch.await() - if (stopRequested) { + 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} 步" diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt index 989f478..0744aa3 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnBridge.kt @@ -31,11 +31,13 @@ object VpnBridge { @JvmStatic external fun shutdownSession() + @Suppress("unused") @JvmStatic fun nativeStepResult(stepId: String, url: String?) { stepListener?.invoke(stepId, url) } + @Suppress("unused") @JvmStatic fun nativeLog(level: String, msg: String) { when (level) { diff --git a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt index ac4916d..061479e 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/vpn/VpnLog.kt @@ -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 diff --git a/app/src/main/res/drawable/add_24px.xml b/app/src/main/res/drawable/add_24px.xml new file mode 100644 index 0000000..2fcde96 --- /dev/null +++ b/app/src/main/res/drawable/add_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit_24px.xml b/app/src/main/res/drawable/edit_24px.xml new file mode 100644 index 0000000..db9b31f --- /dev/null +++ b/app/src/main/res/drawable/edit_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/core_vpn/src/lib.rs b/core_vpn/src/lib.rs index 6451323..be61516 100644 --- a/core_vpn/src/lib.rs +++ b/core_vpn/src/lib.rs @@ -20,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) => { @@ -186,6 +185,7 @@ pub extern "system" fn Java_net_bluemangoo_indpjsk_vpn_VpnBridge_boot( id: step.id, path: re, save_path: step.save_path, + repeat: step.repeat, }); } if hosts.is_empty() { diff --git a/core_vpn/src/mitm.rs b/core_vpn/src/mitm.rs index ac9d4a9..3ccd7d9 100644 --- a/core_vpn/src/mitm.rs +++ b/core_vpn/src/mitm.rs @@ -374,12 +374,17 @@ pub struct StepSpec { 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>>; @@ -387,7 +392,12 @@ pub type Waiters = Arc>>; fn take_matching_waiter(waiters: &StdMutex>, path_and_query: &str) -> Option { let mut list = waiters.lock().unwrap(); 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)]