From 2582f9ba3e38b48952fbe1bc9abb21033bcac667 Mon Sep 17 00:00:00 2001 From: Bluemangoo Date: Thu, 29 Oct 2026 16:36:43 +0800 Subject: [PATCH] mixin --- README.md | 3 +- app/build.gradle.kts | 4 +- .../indpjsk/data/CaptureProfileStep.kt | 1 + .../bluemangoo/indpjsk/data/InstalledApps.kt | 5 - .../bluemangoo/indpjsk/data/MixinProfiles.kt | 168 ++++++++++++++++++ .../indpjsk/data/PredefinedProfiles.kt | 24 ++- .../bluemangoo/indpjsk/ui/CaptureViewModel.kt | 42 +++-- .../indpjsk/ui/UploadSettingsScreen.kt | 69 ++++++- .../bluemangoo/indpjsk/upload/UploadStore.kt | 74 ++++++++ .../net/bluemangoo/indpjsk/upload/Uploader.kt | 51 ++++-- upload-config.d.ts | 36 ++++ 11 files changed, 428 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt create mode 100644 upload-config.d.ts diff --git a/README.md b/README.md index e81f31d..a9d54ae 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,7 @@ Rootless 开箱即用的 Project Sekai 用户数据抓包工具,支持上传 - 安装并打开应用 - 如果需要上传,在右上角菜单的「上传设置」里添加上传配置。 -- 在右上角选择需要的配置 -- 可以取消勾选不需要的抓包步骤 +- 选择需要的配置,可以取消勾选不需要的抓包步骤 - 点击开始,如果跳出建立 VPN 的提示,请允许 - 启动游戏,进入游戏触发需要抓的包,应用会自动抓取数据并自动结束 - 如果需要长时间反复抓同类型的包,在「代理设置」顶部勾选「持续运行」 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d5ca69..c40b50f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { applicationId = "net.bluemangoo.indpjsk" minSdk = appMinSdk targetSdk = 37 - versionCode = 4 - versionName = "1.3" + versionCode = 5 + versionName = "1.4" } diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt index 2157709..fb14e24 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/CaptureProfileStep.kt @@ -15,6 +15,7 @@ data class CaptureProfileStep( val path: String, val fileName: String, var postAction: suspend (CaptureResult) -> Unit = {}, + val uploadType: String? = null, ) data class CaptureResult( diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt index 939ac31..aaaa439 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/InstalledApps.kt @@ -1,7 +1,6 @@ package net.bluemangoo.indpjsk.data import android.content.Context -import android.content.pm.ApplicationInfo import android.graphics.drawable.Drawable data class AppEntry( @@ -16,10 +15,6 @@ object InstalledApps { val pm = context.packageManager return pm.getInstalledApplications(0) .asSequence() - .filter { info -> - (info.flags and ApplicationInfo.FLAG_SYSTEM) == 0 || - (info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0 - } .mapNotNull { info -> runCatching { AppEntry( diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt new file mode 100644 index 0000000..9c9c2a1 --- /dev/null +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/MixinProfiles.kt @@ -0,0 +1,168 @@ +package net.bluemangoo.indpjsk.data + +import net.bluemangoo.indpjsk.upload.UploadMixin +import net.bluemangoo.indpjsk.upload.UploadProfileCustom +import net.bluemangoo.indpjsk.upload.UploadStore +import net.bluemangoo.indpjsk.upload.Uploader + +object MixinProfiles { + + fun merged(): List { + val accs = collect() + if (accs.isEmpty()) return PredefinedProfiles.all + + val builtIns = PredefinedProfiles.all + val builtInNames = builtIns.map { it.name }.toSet() + val usedProfileIds = builtIns.map { it.id }.toMutableSet() + + val mergedBuiltIns = builtIns.map { base -> + val acc = accs[base.name] ?: return@map base + mergeInto(base, acc) + } + + val mixinOnly = accs.values + .filter { it.name !in builtInNames } + .mapNotNull { acc -> + toProfile(acc, uniqueKey(usedProfileIds, acc.name, sanitize = true)) + } + + return mergedBuiltIns + mixinOnly + } + + fun validate(mixin: UploadMixin): String? { + for ((name, steps) in mixin.profiles) { + if (steps.isEmpty()) { + return "配置「${name}」没有 steps" + } + for ((name1, hosts, target) in steps) { + if (target !in PredefinedProfiles.stepTargets && + runCatching { Regex(target) }.isFailure + ) { + return "步骤「${name1}」的 target 既不是内置类型,也不是合法的路径正则" + } + if (hosts.isEmpty()) { + return "步骤「${name1}」的 hosts 不能为空" + } + } + } + return null + } + + private fun collect(): LinkedHashMap { + val profiles = linkedMapOf() + + for ((_, profile) in UploadStore.load()) { + val custom = profile as? UploadProfileCustom ?: continue + val mixin = custom.mixin ?: continue + for ((name, steps) in mixin.profiles) { + val key = canonicalName(name) + if (key.isBlank()) continue + val profileAcc = profiles.getOrPut(key) { ProfileAcc(key) } + for ((name1, hosts, target1) in steps) { + val target = resolveTarget(target1) ?: continue + val stepKey = name1.trim().lowercase() + if (stepKey.isEmpty()) continue + val stepAcc = profileAcc.steps.getOrPut(stepKey) { + StepAcc(name1.trim(), target) + } + for (host in expandHosts(hosts)) { + if (stepAcc.hosts.none { it.equals(host, ignoreCase = true) }) { + stepAcc.hosts.add(host) + } + } + stepAcc.uploadProfileIds.add(custom.id) + } + } + } + return profiles + } + + private fun mergeInto(base: CaptureProfile, acc: ProfileAcc): CaptureProfile { + val builtInStepKeys = base.steps.map { it.name.trim().lowercase() }.toSet() + val usedFileNames = base.steps.map { it.fileName }.toMutableSet() + val usedIds = base.steps.map { it.id }.toMutableSet() + + val steps = base.steps.map { step -> + val extra = acc.steps[step.name.trim().lowercase()] ?: return@map step + step.copy(hosts = (step.hosts + extra.hosts).distinctBy { it.lowercase() }) + } + acc.steps.values + .filter { it.name.trim().lowercase() !in builtInStepKeys } + .mapNotNull { buildStep(it, usedFileNames, usedIds) } + + return base.copy(steps = steps) + } + + private fun toProfile(acc: ProfileAcc, id: String): CaptureProfile? { + val usedFileNames = mutableSetOf() + val usedIds = mutableSetOf() + val steps = acc.steps.values.mapNotNull { buildStep(it, usedFileNames, usedIds) } + if (steps.isEmpty()) return null + return CaptureProfile(id = id, name = acc.name, steps = steps) + } + + private fun buildStep( + step: StepAcc, + usedFileNames: MutableSet, + usedIds: MutableSet, + ): CaptureProfileStep? { + if (step.hosts.isEmpty()) return null + val uploadIds = step.uploadProfileIds.toSet() + return CaptureProfileStep( + id = uniqueKey(usedIds, step.name, sanitize = false), + name = step.name, + hosts = step.hosts.toList(), + path = step.target.path, + fileName = uniqueKey(usedFileNames, step.name, sanitize = true), + postAction = { result -> Uploader.tryUpload(result, uploadIds) }, + uploadType = step.target.uploadType, + ) + } + + private fun resolveTarget(raw: String): PredefinedProfiles.StepTarget? { + PredefinedProfiles.stepTargets[raw]?.let { return it } + if (raw.isBlank()) return null + return runCatching { Regex(raw) } + .map { PredefinedProfiles.StepTarget(path = raw, uploadType = raw) } + .getOrNull() + } + + private fun expandHosts(hosts: List): List = + hosts.flatMap { host -> + PredefinedProfiles.hostAliases[host.trim().lowercase()] ?: listOf(host) + }.map { it.trim() }.filter { it.isNotEmpty() } + + private fun canonicalName(name: String): String = + PredefinedProfiles.all + .firstOrNull { + it.name.equals(name, ignoreCase = true) || it.id.equals(name, ignoreCase = true) + } + ?.name + ?: name + + private fun uniqueKey(used: MutableSet, base: String, sanitize: Boolean): String { + val clean = if (sanitize) { + base.filter { it.isLetterOrDigit() || it == '_' || it == '-' }.ifEmpty { "step" } + } else { + base.ifBlank { "step" } + } + var candidate = clean + var index = 2 + while (!used.add(candidate)) { + candidate = "${clean}_$index" + index++ + } + return candidate + } + + private class ProfileAcc(val name: String) { + val steps = linkedMapOf() + } + + private class StepAcc( + val name: String, + val target: PredefinedProfiles.StepTarget, + ) { + val hosts = mutableListOf() + val uploadProfileIds = linkedSetOf() + } +} diff --git a/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt index 94914b4..1a0c15d 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/data/PredefinedProfiles.kt @@ -6,6 +6,25 @@ object PredefinedProfiles { private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?$" private const val MYSEKAI_PATH = "/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True" + private const val MYSEKAI_BIRTHDAY_PATH = + "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery" + + data class StepTarget(val path: String, val uploadType: String) + + val stepTargets: Map = mapOf( + "suite" to StepTarget(SUITE_PATH, "suite"), + "mysekai" to StepTarget(MYSEKAI_PATH, "mysekai"), + "birthday" to StepTarget(MYSEKAI_BIRTHDAY_PATH, "birthday"), + ) + + val hostAliases: Map> + get() = mapOf( + "cn" to CN_HOSTS, + "jp" to JP_HOSTS, + "en" to EN_HOSTS, + "tw" to TW_HOSTS, + "kr" to KR_HOSTS + ) private fun suiteStep(hosts: List) = CaptureProfileStep( id = "suite", @@ -33,7 +52,7 @@ object PredefinedProfiles { id = "mysekai_birthday", name = "烤森生日双叶", hosts = hosts, - path = "/api/user/(\\d+)/mysekai/birthday-party/(\\d+)/delivery", + path = MYSEKAI_BIRTHDAY_PATH, fileName = "mysekai_birthday", postAction = { result -> Uploader.tryUpload(result) @@ -108,5 +127,6 @@ object PredefinedProfiles { ), ) - fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id } + fun byId(id: String): CaptureProfile? = + MixinProfiles.merged().firstOrNull { it.id == id } } 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 a396c43..a3417ca 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/CaptureViewModel.kt @@ -5,15 +5,17 @@ import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import net.bluemangoo.indpjsk.data.CaptureProfile +import net.bluemangoo.indpjsk.data.MixinProfiles import net.bluemangoo.indpjsk.data.PredefinedProfiles import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.state.CaptureStateHolder import net.bluemangoo.indpjsk.state.CaptureStatus +import net.bluemangoo.indpjsk.upload.UploadStore import net.bluemangoo.indpjsk.vpn.CaptureVpnService import net.bluemangoo.indpjsk.vpn.VpnLog import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import java.io.File @@ -27,24 +29,28 @@ data class CaptureUiState( class CaptureViewModel : ViewModel() { - val uiState: StateFlow = CaptureStateHolder.state - .map { s -> - CaptureUiState( - profiles = PredefinedProfiles.all, - selectedProfile = s.profile, - status = s.status, - lastCapturedFile = s.lastCapturedFile, - errorMessage = s.errorMessage - ) - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = CaptureUiState( - profiles = PredefinedProfiles.all, - selectedProfile = PredefinedProfiles.all.firstOrNull() - ) + private fun allProfiles(): List = MixinProfiles.merged() + + val uiState: StateFlow = combine( + CaptureStateHolder.state, + UploadStore.revision, + ) { s, _ -> + val profiles = allProfiles() + CaptureUiState( + profiles = profiles, + selectedProfile = profiles.firstOrNull { it.id == s.profile.id } ?: s.profile, + status = s.status, + lastCapturedFile = s.lastCapturedFile, + errorMessage = s.errorMessage ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CaptureUiState( + profiles = allProfiles(), + selectedProfile = PredefinedProfiles.all.firstOrNull() + ) + ) fun selectProfile(profile: CaptureProfile) { CaptureStateHolder.selectProfile(profile) 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 db7e89e..782f858 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/ui/UploadSettingsScreen.kt @@ -28,6 +28,7 @@ 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.MixinProfiles import net.bluemangoo.indpjsk.upload.* import org.json.JSONObject @@ -216,10 +217,6 @@ private fun UploadTableRow( } } -/** - * 添加 / 编辑共用的界面。 - * 只有构造函数需要的字段需要用户填写;额外信息(extraInfo)只在下方展示。 - */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun UploadEditScreen( @@ -246,7 +243,7 @@ private fun UploadEditScreen( } var urlMode by remember(initial) { mutableStateOf( - when (val url = (original as? UploadProfileCustom)?.uploadUrl) { + when ((original as? UploadProfileCustom)?.uploadUrl) { is UploadUrlEach -> UploadStore.URL_MODE_EACH is UploadUrlGeneral -> UploadStore.URL_MODE_GENERAL null -> UploadStore.URL_MODE_GENERAL @@ -270,6 +267,13 @@ private fun UploadEditScreen( (original as? UploadProfileCustom)?.customHeaders?.map { it.key to it.value } ?: emptyList() ) } + var mixinText by remember(initial) { + mutableStateOf( + (original as? UploadProfileCustom)?.mixin + ?.let { UploadStore.encodeMixin(it).toString() } + ?: "" + ) + } var typeExpanded by remember { mutableStateOf(false) } var urlModeExpanded by remember { mutableStateOf(false) } @@ -291,7 +295,13 @@ private fun UploadEditScreen( else -> null } - val canSave = idError == null && codeError == null + + val parsedMixin = runCatching { UploadStore.parseMixin(mixinText) } + val mixinError = parsedMixin.exceptionOrNull() + ?.let { "Mixin JSON 解析失败:${it.message}" } + ?: parsedMixin.getOrNull()?.let { MixinProfiles.validate(it) } + + val canSave = idError == null && codeError == null && mixinError == null fun buildProfile(): UploadProfile = when (type) { UploadProfileType.HARUKI -> UploadProfileHaruki(trimmedId, uploadCode.trim()) @@ -312,6 +322,7 @@ private fun UploadEditScreen( .map { it.first.trim() to it.second.trim() } .filter { it.first.isNotEmpty() } .toMap(), + mixin = parsedMixin.getOrNull(), ) } @@ -340,7 +351,6 @@ private fun UploadEditScreen( .padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // ID 和类型放在同一行 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -481,6 +491,12 @@ private fun UploadEditScreen( HeadersEditor(headers = headers, onChange = { headers = it }) + MixinEditor( + value = mixinText, + onValueChange = { mixinText = it.prependIndent().replace("\n", " ") }, + error = mixinError, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton( onClick = { @@ -550,7 +566,14 @@ private fun UploadEditScreen( modifier = Modifier .fillMaxWidth() .heightIn(min = 160.dp), - placeholder = { Text("{ ... }") }, + placeholder = { + Text( + text = "{\"type\":\"CUSTOM\",\"id\":\"my-upload\"," + + "\"uploadUrl\":{\"mode\":\"general\",\"url\":\"https://example.com/upload\"}," + + "\"headers\":{}}", + style = MaterialTheme.typography.bodySmall + ) + }, textStyle = MaterialTheme.typography.bodySmall ) importError?.let { @@ -585,6 +608,9 @@ private fun UploadEditScreen( } } headers = imported.customHeaders.map { it.key to it.value } + mixinText = imported.mixin + ?.let { UploadStore.encodeMixin(it).toString() } + ?: "" importDialog = false } @@ -702,6 +728,31 @@ private fun HeadersEditor( } } +@Composable +private fun MixinEditor( + value: String, + onValueChange: (String) -> Unit, + error: String?, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + FormRow("Mixin") { + CompactTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + isError = error != null, + ) + } + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } +} + @Composable private fun FormRow( label: String, @@ -803,7 +854,7 @@ private fun CompactDropdown( } } -private val helpLinkRegex = Regex("""\[([^\]]+)]\(([^)]+)\)""") +private val helpLinkRegex = Regex("""\[([^]]+)]\(([^)]+)\)""") @Composable private fun HelpText(text: String) { diff --git a/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt index 5ddfad2..2226c75 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/UploadStore.kt @@ -1,5 +1,8 @@ package net.bluemangoo.indpjsk.upload +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import net.bluemangoo.indpjsk.data.SettingsStore import net.bluemangoo.indpjsk.vpn.VpnLog import org.json.JSONArray @@ -14,6 +17,9 @@ object UploadStore { private const val VERSION = 1 + private val _revision = MutableStateFlow(0) + val revision: StateFlow = _revision.asStateFlow() + private const val KEY_VERSION = "version" private const val KEY_PROFILES = "profiles" private const val KEY_ENABLED = "enabled" @@ -25,6 +31,11 @@ object UploadStore { private const val KEY_MODE = "mode" private const val KEY_URL = "url" private const val KEY_HEADERS = "headers" + private const val KEY_MIXIN = "mixin" + private const val KEY_NAME = "name" + private const val KEY_STEPS = "steps" + private const val KEY_HOSTS = "hosts" + private const val KEY_TARGET = "target" private const val KEY_SUITE = "suite" private const val KEY_MYSEKAI = "mysekai" @@ -42,6 +53,7 @@ object UploadStore { fun save(entries: List) { SettingsStore.uploadProfilesJson = encode(entries) + _revision.value += 1 } fun encode(entries: List): String { @@ -85,6 +97,7 @@ object UploadStore { is UploadProfileCustom -> { put(KEY_UPLOAD_URL, encodeUrl(profile.uploadUrl)) put(KEY_HEADERS, JSONObject(profile.customHeaders as Map<*, *>)) + profile.mixin?.let { put(KEY_MIXIN, encodeMixin(it)) } } } } @@ -101,10 +114,71 @@ object UploadStore { id = id, uploadUrl = decodeUrl(obj.optJSONObject(KEY_UPLOAD_URL)), customHeaders = decodeHeaders(obj.optJSONObject(KEY_HEADERS)), + mixin = decodeMixin(obj.optJSONObject(KEY_MIXIN)), ) } } + fun parseMixin(text: String): UploadMixin? { + if (text.isBlank()) return null + return decodeMixin(JSONObject(text)) + } + + fun encodeMixin(mixin: UploadMixin): JSONObject = JSONObject().apply { + put(KEY_PROFILES, JSONArray().apply { + mixin.profiles.forEach { profile -> + put(JSONObject().apply { + put(KEY_NAME, profile.name) + put(KEY_STEPS, JSONArray().apply { + profile.steps.forEach { step -> + put(JSONObject().apply { + put(KEY_NAME, step.name) + put(KEY_HOSTS, JSONArray(step.hosts)) + put(KEY_TARGET, step.target) + }) + } + }) + }) + } + }) + } + + fun decodeMixin(obj: JSONObject?): UploadMixin? { + if (obj == null) return null + val profileArray = obj.optJSONArray(KEY_PROFILES) ?: return null + val profiles = mutableListOf() + for (i in 0 until profileArray.length()) { + val profileObj = profileArray.optJSONObject(i) ?: continue + val name = profileObj.optString(KEY_NAME).trim() + if (name.isEmpty()) continue + + val steps = mutableListOf() + val stepArray = profileObj.optJSONArray(KEY_STEPS) ?: JSONArray() + for (j in 0 until stepArray.length()) { + val stepObj = stepArray.optJSONObject(j) ?: continue + val stepName = stepObj.optString(KEY_NAME).trim() + if (stepName.isEmpty()) continue + + val hosts = mutableListOf() + val hostArray = stepObj.optJSONArray(KEY_HOSTS) ?: JSONArray() + for (k in 0 until hostArray.length()) { + val host = hostArray.optString(k).trim() + if (host.isNotEmpty()) hosts.add(host) + } + + steps.add( + UploadMixinStep( + name = stepName, + hosts = hosts, + target = stepObj.optString(KEY_TARGET).trim(), + ) + ) + } + profiles.add(UploadMixinProfile(name, steps)) + } + return UploadMixin(profiles) + } + private fun encodeUrl(url: UploadUrl): JSONObject = when (url) { is UploadUrlGeneral -> JSONObject().apply { put(KEY_MODE, URL_MODE_GENERAL) 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 8bfa962..9e16ccd 100644 --- a/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt +++ b/app/src/main/java/net/bluemangoo/indpjsk/upload/Uploader.kt @@ -17,14 +17,16 @@ import kotlin.random.Random object Uploader { private const val CHUNK_SIZE = 1024 * 1024 - suspend fun tryUpload(result: CaptureResult) { - val entries = UploadStore.load().filter { it.enabled } + suspend fun tryUpload(result: CaptureResult, onlyProfileIds: Set? = null) { + val entries = UploadStore.load() + .filter { it.enabled } + .filter { onlyProfileIds == null || it.profile.id in onlyProfileIds } if (entries.isEmpty()) return VpnLog.info("${result.step.name} 开始上传(${entries.size} 个配置,串行)") for ((_, profile) in entries) { try { - val uploaded = uploadChunked(profile, result.url, result.file) + val uploaded = uploadChunked(profile, result.url, result.file, result.step.uploadType) if (uploaded) { VpnLog.success("[${profile.id}] ${result.step.name} 上传完成") } else { @@ -43,18 +45,20 @@ object Uploader { profile: UploadProfile, originalUrl: String, file: File, + type: String? = null, ): Boolean = withContext(Dispatchers.IO) { require(file.exists()) { "文件不存在: ${file.absolutePath}" } - val type = when (file.name) { + val uploadType = type ?: when (file.name) { "suite" -> "suite" "mysekai" -> "mysekai" "mysekai_birthday" -> "birthday" - else -> { - VpnLog.error("${profile.id} 未知的文件类型 ${file.name}") - return@withContext false - } + else -> null } - val url = profile.getUploadUrl(type)?.takeIf { it.isNotBlank() } + if (uploadType == null) { + VpnLog.error("${profile.id} 未知的文件类型 ${file.name}") + return@withContext false + } + val url = profile.getUploadUrl(uploadType)?.takeIf { it.isNotBlank() } ?: return@withContext false val uploadId = randomUploadId() @@ -160,6 +164,27 @@ enum class UploadProfileType(val displayName: String) { CUSTOM("自定义") } +/** mixin:在自定义上传配置里顺带定义抓包配置。 */ +data class UploadMixin( + val profiles: List, +) + +data class UploadMixinProfile( + val name: String, + val steps: List, +) + +/** + * name: 步骤显示名(同一 profile 内同名步骤会合并) + * hosts: 域名列表,可以填 `cn` 这样的预设别名 + * target: `suite` / `mysekai` / `birthday` 快捷方式,或直接填请求路径正则 + */ +data class UploadMixinStep( + val name: String, + val hosts: List, + val target: String, +) + sealed interface UploadProfile { val id: String val type: UploadProfileType @@ -274,8 +299,12 @@ class UploadProfile8823(override val id: String) : UploadProfile { override val extraInfo: Map = emptyMap() } -class UploadProfileCustom(override val id: String, val uploadUrl: UploadUrl, val customHeaders: Map) : - UploadProfile { +class UploadProfileCustom( + override val id: String, + val uploadUrl: UploadUrl, + val customHeaders: Map, + val mixin: UploadMixin? = null, +) : UploadProfile { override val type: UploadProfileType = UploadProfileType.CUSTOM override val helpText: String? = null diff --git a/upload-config.d.ts b/upload-config.d.ts new file mode 100644 index 0000000..29dca8b --- /dev/null +++ b/upload-config.d.ts @@ -0,0 +1,36 @@ +// noinspection JSUnusedGlobalSymbols + +interface UploadUrlGeneral { + mode: "general"; + url: string; +} + +interface UploadUrlEach { + mode: "each"; + suite: string | null; + mysekai: string | null; + birthday: string | null; +} + +interface UploadMixinStep { + name: string; + hosts: ("cn" | "jp" | "en" | "tw" | "kr" | string)[]; + target: "suite" | "mysekai" | "birthday" | "" | string; +} + +interface UploadMixinProfile { + name: string; + steps: UploadMixinStep[]; +} + +interface UploadMixin { + profiles: UploadMixinProfile[]; +} + +export interface UploadProfileCustom { + type: "CUSTOM"; + id: string; + uploadUrl: UploadUrlGeneral | UploadUrlEach; + headers: Record; + mixin?: UploadMixin; +}