1.0 能用

This commit is contained in:
2026-10-24 14:48:59 +08:00
commit eea818f5f4
48 changed files with 4802 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/build
/release
/src/main/jniLibs
+46
View File
@@ -0,0 +1,46 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "net.bluemangoo.indpjsk"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "net.bluemangoo.indpjsk"
minSdk = 28
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.19.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+26
View File
@@ -0,0 +1,26 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class net.bluemangoo.indpjsk.vpn.VpnBridge { *; }
-keepclasseswithmembernames class * {
native <methods>;
}
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.IndPjsk">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.IndPjsk">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".vpn.CaptureVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE" tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,83 @@
package net.bluemangoo.indpjsk
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import net.bluemangoo.indpjsk.ui.CaptureScreen
import net.bluemangoo.indpjsk.ui.CaptureViewModel
import net.bluemangoo.indpjsk.ui.theme.IndPjskTheme
import java.io.File
class MainActivity : ComponentActivity() {
private val viewModel: CaptureViewModel by viewModels()
private val vpnPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.startCapture(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
IndPjskTheme {
CaptureScreen(
viewModel = viewModel,
onStartCapture = { requestVpnAndStart() },
onStopCapture = { viewModel.stopCapture(this) },
onOpenFile = { openCapturedFile(it) }
)
}
}
}
private fun requestVpnAndStart() {
val prepare: Intent? = VpnService.prepare(this)
if (prepare != null) {
vpnPermissionLauncher.launch(prepare)
} else {
viewModel.startCapture(this)
}
}
private fun openCapturedFile(file: File) {
if (!file.exists()) return
val authority = "$packageName.fileprovider"
val uri: Uri = FileProvider.getUriForFile(this, authority, file)
val mime = mimeTypeFor(file.extension)
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
startActivity(Intent.createChooser(viewIntent, "打开捕获文件"))
} catch (_: ActivityNotFoundException) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "分享捕获文件"))
}
}
private fun mimeTypeFor(ext: String?): String = when (ext?.lowercase()) {
"json" -> "application/json"
"txt", "log" -> "text/plain"
"xml" -> "application/xml"
"html", "htm" -> "text/html"
else -> "application/octet-stream"
}
}
@@ -0,0 +1,34 @@
package net.bluemangoo.indpjsk.data
import java.io.File
data class CaptureProfile(
val id: String,
val name: String,
val steps: List<CaptureProfileStep>
)
data class CaptureProfileStep(
val id: String,
val name: String,
/** 该步骤可能命中的上游域名;TW/KR 的 cdn / https 两个域名都会出现 */
val hosts: List<String>,
/** 匹配 path + query 的正则(Rust 侧 Regex::is_match) */
val path: String,
val fileName: String,
/**
* 该步骤抓成功后异步执行(不阻塞下一步的监听)。
* 没有填上传码时会走这里;参数是本次抓包的结果。
*/
val postAction: suspend (CaptureResult) -> Unit = {},
)
/** 一步抓包完成后的结果,交给 postAction 用 */
data class CaptureResult(
val profile: CaptureProfile,
val step: CaptureProfileStep,
/** 命中的原始完整 URL(https://host/path?query,MySekai 的 flag 已改写为 True) */
val url: String,
/** 抓到的响应体文件 */
val file: File,
)
@@ -0,0 +1,76 @@
package net.bluemangoo.indpjsk.data
object PredefinedProfiles {
/**
* 两个数据接口的正则(Host 已去掉,Rust 侧是拿 path + query 去匹配的):
*
* 1. Suite(个人信息 / 卡牌 / 仓库主数据)
* Path 模板: /api/suite/user/{uid}
*
* 2. MySekai(烤森房间 / 地图全量数据)
* Path 模板: /api/user/{uid}/mysekai?isForceAllReloadOnlyMysekai=True
*/
private const val SUITE_PATH = "/api/suite/user/(\\d+)(\\?isLogin=true)?\$"
private const val MYSEKAI_PATH =
"/api/user/(\\d+)/mysekai\\?isForceAllReloadOnlyMysekai=True"
private fun suiteStep(hosts: List<String>) = CaptureProfileStep(
id = "suite",
name = "Suite",
hosts = hosts,
path = SUITE_PATH,
fileName = "suite"
)
private fun mysekaiStep(hosts: List<String>) = CaptureProfileStep(
id = "mysekai",
name = "烤森",
hosts = hosts,
path = MYSEKAI_PATH,
fileName = "mysekai"
)
private val CN_HOSTS = listOf("mkcn-prod-public-60001-1.dailygn.com")
private val JP_HOSTS = listOf("production-game-api.sekai.colorfulpalette.org")
private val EN_HOSTS = listOf("n-production-game-api.sekai-en.com")
private val TW_HOSTS = listOf(
"mk-zian-obt-cdn.bytedgame.com",
"mk-zian-obt-https.bytedgame.com"
)
private val KR_HOSTS = listOf(
"mkkorea-obt-prod01-cdn.bytedgame.com",
"mkkorea-obt-prod01-https.bytedgame.com"
)
val all: List<CaptureProfile> = listOf(
// 国服不需要烤森
CaptureProfile(
id = "cn",
name = "国服",
steps = listOf(suiteStep(CN_HOSTS))
),
CaptureProfile(
id = "jp",
name = "日服",
steps = listOf(suiteStep(JP_HOSTS), mysekaiStep(JP_HOSTS))
),
CaptureProfile(
id = "en",
name = "国际服",
steps = listOf(suiteStep(EN_HOSTS), mysekaiStep(EN_HOSTS))
),
CaptureProfile(
id = "tw",
name = "台服",
steps = listOf(suiteStep(TW_HOSTS), mysekaiStep(TW_HOSTS))
),
CaptureProfile(
id = "kr",
name = "韩服",
steps = listOf(suiteStep(KR_HOSTS), mysekaiStep(KR_HOSTS))
),
)
fun byId(id: String): CaptureProfile? = all.firstOrNull { it.id == id }
}
@@ -0,0 +1,23 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import androidx.core.content.edit
/** 一些零碎的全局设置,暂时只有上传码 */
object SettingsStore {
private const val PREFS = "settings"
private const val KEY_UPLOAD_CODE = "upload_code"
fun loadUploadCode(context: Context): String =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_UPLOAD_CODE, "")
.orEmpty()
fun saveUploadCode(context: Context, code: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit {
putString(KEY_UPLOAD_CODE, code)
}
}
}
@@ -0,0 +1,54 @@
package net.bluemangoo.indpjsk.data
import android.content.Context
import org.json.JSONObject
import androidx.core.content.edit
/**
* 每个 profile 下每个步骤「要不要抓」的持久化。
*
* 就一个 JSON 字符串塞进 SharedPreferences,键是 "profileId/stepId",值 Boolean,
* 没存过的默认 true(全抓)。
*/
object StepSelectionStore {
private const val PREFS = "step_selection"
private const val KEY_JSON = "json"
fun key(profileId: String, stepId: String) = "$profileId/$stepId"
fun loadAll(context: Context): MutableMap<String, Boolean> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_JSON, null) ?: return mutableMapOf()
return try {
val json = JSONObject(raw)
val out = mutableMapOf<String, Boolean>()
json.keys().forEach { k -> out[k] = json.optBoolean(k, true) }
out
} catch (_: Throwable) {
mutableMapOf()
}
}
fun isEnabled(all: Map<String, Boolean>, profileId: String, stepId: String): Boolean =
all[key(profileId, stepId)] ?: true
fun saveAll(context: Context, all: Map<String, Boolean>) {
val json = JSONObject()
all.forEach { (k, v) -> json.put(k, v) }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit { putString(KEY_JSON, json.toString()) }
}
fun setEnabled(
context: Context,
profileId: String,
stepId: String,
enabled: Boolean
): MutableMap<String, Boolean> {
val all = loadAll(context)
all[key(profileId, stepId)] = enabled
saveAll(context, all)
return all
}
}
@@ -0,0 +1,66 @@
package net.bluemangoo.indpjsk.state
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import java.io.File
enum class CaptureStatus { IDLE, RUNNING, CAPTURED }
data class CaptureState(
val profile: CaptureProfile,
val status: CaptureStatus = CaptureStatus.IDLE,
val lastCapturedFile: File? = null,
val errorMessage: String? = null
)
object CaptureStateHolder {
private val _state = MutableStateFlow(
CaptureState(profile = PredefinedProfiles.all.first())
)
val state: StateFlow<CaptureState> = _state.asStateFlow()
@Synchronized
fun selectProfile(profile: CaptureProfile) {
if (_state.value.status == CaptureStatus.RUNNING) return
_state.value = CaptureState(profile = profile)
}
@Synchronized
fun setRunning() {
_state.value = _state.value.copy(
status = CaptureStatus.RUNNING,
lastCapturedFile = null,
errorMessage = null
)
}
@Synchronized
fun setCaptured(file: File) {
_state.value = _state.value.copy(
status = CaptureStatus.CAPTURED,
lastCapturedFile = file,
errorMessage = null
)
}
@Synchronized
fun setIdle() {
_state.value = _state.value.copy(
status = CaptureStatus.IDLE,
lastCapturedFile = null,
errorMessage = null
)
}
@Synchronized
fun setError(message: String) {
_state.value = _state.value.copy(
status = CaptureStatus.IDLE,
errorMessage = message
)
}
}
@@ -0,0 +1,331 @@
package net.bluemangoo.indpjsk.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import net.bluemangoo.indpjsk.R
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.LogEntry
import net.bluemangoo.indpjsk.vpn.LogLevel
import net.bluemangoo.indpjsk.vpn.VpnLog
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptureScreen(
viewModel: CaptureViewModel,
onStartCapture: () -> Unit,
onStopCapture: () -> Unit,
onOpenFile: (File) -> Unit
) {
val state by viewModel.uiState.collectAsState()
val logs by VpnLog.logs.collectAsState()
var dropdownExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
var enabledMap by remember { mutableStateOf(StepSelectionStore.loadAll(context) as Map<String, Boolean>) }
var uploadCode by remember { mutableStateOf(SettingsStore.loadUploadCode(context)) }
val isRunning = state.status == CaptureStatus.RUNNING
val isCaptured = state.status == CaptureStatus.CAPTURED
val selected = state.selectedProfile
Scaffold(
topBar = {
TopAppBar(
title = { Text("咖喱烧烤无ROOT抓包") },
actions = {
IconButton(onClick = { VpnLog.clear() }) {
Icon(
painter = painterResource(id = R.drawable.delete_24px),
contentDescription = "清空日志"
)
}
Box {
TextButton(
onClick = { dropdownExpanded = true },
enabled = !isRunning
) {
Text(
text = selected?.name ?: "无配置",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.arrow_drop_down_24px),
contentDescription = null
)
}
DropdownMenu(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
state.profiles.forEach { profile ->
DropdownMenuItem(
text = { Text(profile.name) },
onClick = {
viewModel.selectProfile(profile)
dropdownExpanded = false
}
)
}
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = uploadCode,
onValueChange = {
uploadCode = it.trim()
SettingsStore.saveUploadCode(context, uploadCode)
},
modifier = Modifier.fillMaxWidth(),
enabled = !isRunning,
singleLine = true,
label = { Text("上传码(从 Haruki 工具箱 - 数据上传 - iOS 获取)") },
textStyle = MaterialTheme.typography.bodyMedium,
visualTransformation = PasswordVisualTransformation()
)
if (selected != null) {
StepSelector(
profile = selected,
enabledMap = enabledMap,
editable = !isRunning,
onToggle = { step, checked ->
enabledMap = StepSelectionStore.setEnabled(
context, selected.id, step.id, checked
)
}
)
}
LogPanel(
logs = logs,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
StatusLine(status = state.status, error = state.errorMessage)
Button(
onClick = if (isRunning) onStopCapture else onStartCapture,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = if (isRunning) {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
} else ButtonDefaults.buttonColors()
) {
Icon(
painter = painterResource(
id = if (isRunning) R.drawable.stop_24px else R.drawable.play_arrow_24px
),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(if (isRunning) "停止监听" else "启动抓包")
}
OutlinedButton(
onClick = { state.lastCapturedFile?.let(onOpenFile) },
enabled = isCaptured && state.lastCapturedFile != null,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.file_open_24px),
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text("打开捕获文件")
}
}
}
}
@Composable
private fun StepSelector(
profile: CaptureProfile,
enabledMap: Map<String, Boolean>,
editable: Boolean,
onToggle: (CaptureProfileStep, Boolean) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = "抓包步骤",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
profile.steps.forEach { step ->
val checked = StepSelectionStore.isEnabled(enabledMap, profile.id, step.id)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { onToggle(step, it) },
enabled = editable
)
Column(modifier = Modifier.weight(1f)) {
Text(step.name, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
@Composable
private fun LogPanel(logs: List<LogEntry>, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.lastIndex)
}
Box(
modifier = modifier.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
)
) {
if (logs.isEmpty()) {
Text(
text = "暂无日志,点击下方按钮开始抓包",
modifier = Modifier.align(Alignment.Center),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(logs) { entry -> LogRow(entry) }
}
}
}
}
@Composable
private fun LogRow(entry: LogEntry) {
val color = when (entry.level) {
LogLevel.ERROR -> MaterialTheme.colorScheme.error
LogLevel.WARN -> Color(0xFFB26A00)
LogLevel.SUCCESS -> Color(0xFF2E7D32)
LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant
}
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = entry.time,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.width(6.dp))
Text(
text = entry.message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = color
)
}
}
@Composable
private fun StatusLine(status: CaptureStatus, error: String?) {
val text = when (status) {
CaptureStatus.IDLE -> if (error != null) "失败:$error" else "就绪"
CaptureStatus.RUNNING -> "正在监听目标流量…"
CaptureStatus.CAPTURED -> "抓包成功,可打开文件"
}
val color = when (status) {
CaptureStatus.RUNNING -> MaterialTheme.colorScheme.primary
CaptureStatus.CAPTURED -> Color(0xFF2E7D32)
CaptureStatus.IDLE ->
if (error != null) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
text = text,
color = color,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
HorizontalDivider()
}
@@ -0,0 +1,70 @@
package net.bluemangoo.indpjsk.ui
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.state.CaptureStatus
import net.bluemangoo.indpjsk.vpn.CaptureVpnService
import net.bluemangoo.indpjsk.vpn.VpnLog
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.io.File
data class CaptureUiState(
val profiles: List<CaptureProfile> = emptyList(),
val selectedProfile: CaptureProfile? = null,
val status: CaptureStatus = CaptureStatus.IDLE,
val lastCapturedFile: File? = null,
val errorMessage: String? = null
)
class CaptureViewModel : ViewModel() {
val uiState: StateFlow<CaptureUiState> = CaptureStateHolder.state
.map { s ->
CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = s.profile,
status = s.status,
lastCapturedFile = s.lastCapturedFile,
errorMessage = s.errorMessage
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = CaptureUiState(
profiles = PredefinedProfiles.all,
selectedProfile = PredefinedProfiles.all.firstOrNull()
)
)
fun selectProfile(profile: CaptureProfile) {
CaptureStateHolder.selectProfile(profile)
}
fun startCapture(context: Context) {
val profile = uiState.value.selectedProfile ?: return
VpnLog.clear()
CaptureStateHolder.setRunning()
val intent = Intent(context, CaptureVpnService::class.java).apply {
action = CaptureVpnService.ACTION_START
putExtra(CaptureVpnService.EXTRA_PROFILE_ID, profile.id)
}
context.startService(intent)
}
fun stopCapture(context: Context) {
val intent = Intent(context, CaptureVpnService::class.java).apply {
action = CaptureVpnService.ACTION_STOP
}
context.startService(intent)
}
}
@@ -0,0 +1,11 @@
package net.bluemangoo.indpjsk.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,53 @@
package net.bluemangoo.indpjsk.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun IndPjskTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package net.bluemangoo.indpjsk.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,107 @@
package net.bluemangoo.indpjsk.upload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.bluemangoo.indpjsk.vpn.VpnLog
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import kotlin.random.Random
/**
* 把抓到的响应体上传到工具箱:
*
* POST https://toolbox-api-direct.haruki.seiunx.com/ios/script/{upload_code}/upload
* Content-Type: application/octet-stream (原始二进制,每片最大 1MB)
* X-Script-Version: 1.0.1
* X-Upload-Code: {upload_code}
* X-Upload-Id: 9 位随机字母数字(同一文件的所有分片共用)
* X-Chunk-Index: "0", "1", ...
* X-Total-Chunks: 分片总数(ceil(size / 1MB))
* X-Original-Url: 抓到的原始游戏 URL(后端用它识别区服 / UID / 数据类型)
*
* 分片串行发送,每一片都必须返回 HTTP 200。
*/
object Uploader {
private const val BASE = "https://toolbox-api-direct.haruki.seiunx.com/ios/script"
private const val CHUNK_SIZE = 1024 * 1024
private const val SCRIPT_VERSION = "1.0.1"
suspend fun upload(
uploadCode: String,
originalUrl: String,
file: File,
) = withContext(Dispatchers.IO) {
require(file.exists()) { "文件不存在: ${file.absolutePath}" }
val uploadId = randomUploadId()
RandomAccessFile(file, "r").use { raf ->
val size = raf.length()
val total = if (size == 0L) 1 else ((size + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
val buf = ByteArray(CHUNK_SIZE)
VpnLog.info("${file.name} 上传开始")
for (index in 0 until total) {
val remaining = size - raf.filePointer
val len = if (remaining <= 0L) 0 else minOf(CHUNK_SIZE.toLong(), remaining).toInt()
if (len > 0) raf.readFully(buf, 0, len)
val code = postChunk(uploadCode, uploadId, index, total, originalUrl, buf, len)
if (code != 200) throw IOException("${file.name} 上传失败: HTTP $code")
}
VpnLog.info("${file.name} 上传完成")
}
}
private fun postChunk(
uploadCode: String,
uploadId: String,
index: Int,
total: Int,
originalUrl: String,
data: ByteArray,
length: Int,
): Int {
val conn = URL("$BASE/$uploadCode/upload").openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.instanceFollowRedirects = false
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.setRequestProperty("Content-Type", "application/octet-stream")
conn.setRequestProperty("X-Script-Version", SCRIPT_VERSION)
conn.setRequestProperty("X-Upload-Code", uploadCode)
conn.setRequestProperty("X-Upload-Id", uploadId)
conn.setRequestProperty("X-Chunk-Index", index.toString())
conn.setRequestProperty("X-Total-Chunks", total.toString())
conn.setRequestProperty("X-Original-Url", originalUrl)
conn.setFixedLengthStreamingMode(length)
conn.outputStream.use { it.write(data, 0, length) }
val code = conn.responseCode
if (code != 200) {
val body = runCatching {
(conn.errorStream ?: conn.inputStream)
?.bufferedReader()
?.use { it.readText() }
?.take(300)
}.getOrNull().orEmpty()
throw IOException("分片 ${index + 1}/$total 上传失败: HTTP $code $body")
}
return code
} finally {
conn.disconnect()
}
}
/** 9 位随机字母数字,同一文件所有分片共用一个 */
private fun randomUploadId(): String {
val chars = "0123456789abcdefghijklmnopqrstuvwxyz"
return buildString(9) {
repeat(9) { append(chars[Random.nextInt(chars.length)]) }
}
}
}
@@ -0,0 +1,305 @@
package net.bluemangoo.indpjsk.vpn
import android.annotation.SuppressLint
import android.content.Intent
import android.net.VpnService
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import net.bluemangoo.indpjsk.data.CaptureProfile
import net.bluemangoo.indpjsk.data.CaptureProfileStep
import net.bluemangoo.indpjsk.data.CaptureResult
import net.bluemangoo.indpjsk.data.PredefinedProfiles
import net.bluemangoo.indpjsk.data.SettingsStore
import net.bluemangoo.indpjsk.data.StepSelectionStore
import net.bluemangoo.indpjsk.state.CaptureStateHolder
import net.bluemangoo.indpjsk.upload.Uploader
import java.io.File
/**
* 多步骤抓包:
* establish() 只做一次,同一个 tun fd 按顺序喂给每一步,中间不拆 VPN。
* 每一步抓到后异步跑它的 postAction(不阻塞下一步的监听)。
*
* 停止机制:
* ACTION_STOP → requestStop() + close(activePfd),当前步骤的 runCapture 返回 null,
* 循环发现 stopRequested 后终止后续步骤。
*
* fd 所有权:
* 全生命周期在 Android 侧持有。传给 Rust 的是 pfd.fd(int 缓存值),
* Rust 内部用 BorrowedAsyncDevice 借用,不做任何 close。
* Android 在 finally 里统一 close(activePfd)。
*/
@SuppressLint("VpnServicePolicy")
class CaptureVpnService : VpnService() {
companion object {
const val ACTION_START = "net.bluemangoo.indpjsk.START"
const val ACTION_STOP = "net.bluemangoo.indpjsk.STOP"
const val EXTRA_PROFILE_ID = "extra_profile_id"
private const val VPN_ADDRESS = "10.7.0.2"
private const val VPN_PREFIX = 32
/**
* 后处理(上传)用的 scope:进程级,故意不随 Service 销毁取消。
* 否则 stopSelf() → onDestroy 会把还在上传的分片掐掉(Job was cancelled)。
*/
private val postScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** 捕获文件统一放在 filesDir/captures/<profileId>/ 下(file_paths.xml 的 FileProvider 路径) */
fun captureFile(
filesDir: File,
profile: CaptureProfile,
step: CaptureProfileStep
): File = File(File(File(filesDir, "captures"), profile.id), step.fileName)
}
/** 工作线程持有;供 ACTION_STOP 从主线程关闭以中断 Rust */
@Volatile
private var activePfd: ParcelFileDescriptor? = null
@Volatile
private var captureThread: Thread? = null
/** 区分"用户主动停止"与"抓到包后自然结束/失败" */
@Volatile
private var stopRequested = false
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val id = intent.getStringExtra(EXTRA_PROFILE_ID) ?: return START_NOT_STICKY
val profile = PredefinedProfiles.byId(id) ?: return START_NOT_STICKY
startCapture(profile)
}
ACTION_STOP -> {
stopRequested = true
VpnLog.warn("手动停止…")
VpnBridge.requestStop()
closeActivePfd()
// 立即把 UI 从 RUNNING 拉回来;capture 线程退出时还会再写一次
CaptureStateHolder.setIdle()
stopSelf()
}
else -> return START_NOT_STICKY
}
return START_NOT_STICKY
}
private fun closeActivePfd() {
val pfd = activePfd
activePfd = null
try {
pfd?.close()
VpnBridge.requestStop()
} catch (t: Throwable) {
VpnLog.warn("关闭代理失败: ${t.message}")
}
}
private fun startCapture(profile: CaptureProfile) {
if (captureThread?.isAlive == true) {
VpnLog.warn("抓包已在运行,忽略重复启动")
return
}
// 按 profile 里的顺序,过滤掉没勾选的步骤(默认全勾)
val savedSelection = StepSelectionStore.loadAll(this)
val enabledSteps = profile.steps.filter { step ->
StepSelectionStore.isEnabled(savedSelection, profile.id, step.id)
}
if (enabledSteps.isEmpty()) {
VpnLog.error("没有勾选任何步骤")
CaptureStateHolder.setError("没有勾选任何步骤")
return
}
// 上传码在开始时快照,运行中改 UI 不影响本次
val uploadCode = SettingsStore.loadUploadCode(this).trim()
if (uploadCode.isNotEmpty() && uploadCode.length != 32) {
VpnLog.warn("上传码长度不是 32 位(当前 ${uploadCode.length} 位),上传大概率会失败")
}
stopRequested = false
VpnLog.info("==== 开始抓包 ====")
VpnLog.info("配置: ${profile.name}")
VpnLog.info("步骤: ${enabledSteps.joinToString(" -> ") { it.name }}")
captureThread = Thread({
var pfd: ParcelFileDescriptor?
var lastFile: File? = null
var completed = 0
var errorMsg: String? = null
try {
if (!VpnBridge.isAvailable()) {
throw IllegalStateException("Rust 动态库 core_vpn 未加载")
}
// 2) 建立 VPN:仅 /32 目标路由 + 排除自身(防止 Rust 转发流量回环)
val builder = Builder()
.setSession("Capture-${profile.id}")
.addAddress(VPN_ADDRESS, VPN_PREFIX)
.addDisallowedApplication(packageName)// 1.0.0.0/8 (排除 0.0.0.0/8 本地识别地址)
.addRoute("1.0.0.0", 8)
.addRoute("2.0.0.0", 7)
.addRoute("4.0.0.0", 6)
.addRoute("8.0.0.0", 7)
.addRoute("11.0.0.0", 8)
.addRoute("12.0.0.0", 6)
.addRoute("16.0.0.0", 4)
.addRoute("32.0.0.0", 3)
.addRoute("64.0.0.0", 5)
.addRoute("96.0.0.0", 10)
.addRoute("100.128.0.0", 9)
.addRoute("101.0.0.0", 8)
.addRoute("102.0.0.0", 7)
.addRoute("104.0.0.0", 5)
.addRoute("112.0.0.0", 4)
.addRoute("128.0.0.0", 3)
.addRoute("160.0.0.0", 5)
.addRoute("168.0.0.0", 6)
.addRoute("169.0.0.0", 9)
.addRoute("169.128.0.0", 10)
.addRoute("169.192.0.0", 11)
.addRoute("169.224.0.0", 12)
.addRoute("169.240.0.0", 13)
.addRoute("169.248.0.0", 14)
.addRoute("169.252.0.0", 15)
.addRoute("169.255.0.0", 16)
.addRoute("170.0.0.0", 7)
.addRoute("172.0.0.0", 12)
.addRoute("172.32.0.0", 11)
.addRoute("172.64.0.0", 10)
.addRoute("172.128.0.0", 9)
.addRoute("173.0.0.0", 8)
.addRoute("174.0.0.0", 7)
.addRoute("176.0.0.0", 4)
.addRoute("192.0.0.0", 9)
.addRoute("192.128.0.0", 11)
.addRoute("192.160.0.0", 13)
.addRoute("192.169.0.0", 16)
.addRoute("192.170.0.0", 15)
.addRoute("192.172.0.0", 14)
.addRoute("192.176.0.0", 12)
.addRoute("192.192.0.0", 10)
.addRoute("193.0.0.0", 8)
.addRoute("194.0.0.0", 7)
.addRoute("196.0.0.0", 6)
.addRoute("200.0.0.0", 5)
.addRoute("208.0.0.0", 4)
.addRoute("::", 0)
.addDnsServer("8.8.8.8")
.addDnsServer("114.114.114.114")
pfd = builder.establish()
?: throw IllegalStateException("VpnService.establish() 返回 null")
activePfd = pfd
val fd = pfd.fd
VpnLog.info("网络代理已连接")
for ((index, step) in enabledSteps.withIndex()) {
if (stopRequested) {
VpnLog.warn("用户停止,终止后续步骤")
break
}
val label = "[${index + 1}/${enabledSteps.size}] ${step.name}"
val target = captureFile(filesDir, profile, step)
target.parentFile?.mkdirs()
if (target.exists()) target.delete()
// 阻塞调用 Rust,直到抓到本步骤的目标请求(或停止 / 出错)
val capturedUrl = VpnBridge.runCapture(
fd,
step.hosts.joinToString(","),
step.path,
target.absolutePath
)
if (capturedUrl == null) {
if (stopRequested) {
VpnLog.warn("$label 被用户停止")
break
}
errorMsg = "$label 未抓到目标请求"
VpnLog.error(errorMsg)
break
}
if (!target.exists() || target.length() == 0L) {
errorMsg = "$label 返回了 URL 但文件为空"
VpnLog.error(errorMsg)
break
}
lastFile = target
completed++
VpnLog.success("$label 抓取成功")
// 抓到后异步做后处理(上传 / 该步骤自己的 postAction),不阻塞下一步监听
val result = CaptureResult(profile, step, capturedUrl, target)
postScope.launch {
try {
if (uploadCode.isNotEmpty()) {
VpnLog.info("$label 开始上传")
Uploader.upload(uploadCode, capturedUrl, target)
VpnLog.success("${profile.name}${step.name} 上传完成")
} else {
step.postAction(result)
}
} catch (c: CancellationException) {
VpnLog.warn("$label 后处理被取消: ${c.message}")
throw c
} catch (t: Throwable) {
VpnLog.error("$label 后处理失败: ${t.message}")
}
}
}
} catch (t: Throwable) {
errorMsg = t.message ?: "抓包异常"
VpnLog.error("抓包异常: ${t.message}")
} finally {
// 关闭 fd(若 ACTION_STOP 已关,这次 close 是幂等的/会抛可忽略异常)
closeActivePfd()
VpnLog.info("==== 抓包结束($completed/${enabledSteps.size} 步完成)====")
if (errorMsg != null) {
CaptureStateHolder.setError(errorMsg)
} else if (completed == enabledSteps.size && lastFile != null) {
CaptureStateHolder.setCaptured(lastFile)
} else {
CaptureStateHolder.setIdle()
}
stopSelf()
}
}, "capture-blocking-worker").apply { start() }
}
override fun onRevoke() {
stopRequested = true
VpnLog.warn("VPN 权限被撤销")
closeActivePfd()
super.onRevoke()
}
override fun onDestroy() {
closeActivePfd()
captureThread = null
super.onDestroy()
}
}
@@ -0,0 +1,62 @@
package net.bluemangoo.indpjsk.vpn
import android.util.Log
/**
* Rust core_vpn 的 JNI 桥。
*
* 契约(由 Rust lib.rs 实现):
* runCapture(jint tunFd, jstring targetHosts, jstring path, jstring savePath) -> String?
* - 阻塞直到抓包结束
* - tunFd 的所有权【仍在 Kotlin 侧】,Rust 内部用 BorrowedAsyncDevice 借用,不得 close()
* - targetHosts 是逗号分隔的域名白名单(一个步骤可能对应多个上游域名)
* - 返回非 null = 抓到目标请求,值是命中的原始完整 URL(https://host/path?query),文件已写入 savePath
* - 返回 null = 失败 / 被 requestStop 打断 / 未匹配
*
* requestStop() -> void
* - 立即返回,仅设置原子停止标志,使阻塞中的 runCapture 尽快返回 false
*
* nativeLog(String)(Kotlin -> Rust 回调的落地端)
* - Rust 通过 JNIEnv.call_static_method(".../VpnBridge", "nativeLog", ...) 调用
* - 用于把 TLS 握手 / 匹配结果等关键日志写进 UI 日志框
*/
object VpnBridge {
private const val TAG = "VpnBridge"
private var loaded = false
init {
loaded = try {
System.loadLibrary("core_vpn")
true
} catch (t: Throwable) {
Log.e(TAG, "loadLibrary core_vpn failed", t)
false
}
}
fun isAvailable(): Boolean = loaded
@JvmStatic
external fun runCapture(
tunFd: Int,
targetHosts: String,
targetPath: String,
savePath: String
): String?
@JvmStatic
external fun requestStop()
/** 供 Rust JNI 回调写入日志框;Rust 侧通过 call_static_method 调用本方法 */
@Suppress("unused")
@JvmStatic
fun nativeLog(level: String, msg: String) {
when (level) {
"error" -> VpnLog.error(msg)
"warn" -> VpnLog.warn(msg)
"info" -> VpnLog.info(msg)
else -> VpnLog.info(msg)
}
}
}
@@ -0,0 +1,49 @@
package net.bluemangoo.indpjsk.vpn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.time.LocalTime
import java.time.format.DateTimeFormatter
enum class LogLevel { INFO, WARN, ERROR, SUCCESS }
data class LogEntry(
val time: String,
val level: LogLevel,
val message: String
)
/**
* 全局日志容器:
* - Android 侧各处直接调用 info/warn/error/success
* - Rust 侧通过 JNI 静态方法 VpnBridge.nativeLog(String) 回调进来
* - UI 用 LazyColumn 消费 StateFlow,最多保留 MAX 条
*/
object VpnLog {
private const val MAX = 800
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
private val _logs = MutableStateFlow<List<LogEntry>>(emptyList())
val logs: StateFlow<List<LogEntry>> = _logs.asStateFlow()
private fun append(level: LogLevel, msg: String) {
val entry = LogEntry(
time = LocalTime.now().format(timeFmt),
level = level,
message = msg
)
_logs.update { (it + entry).takeLast(MAX) }
}
fun info(msg: String) = append(LogLevel.INFO, msg)
fun warn(msg: String) = append(LogLevel.WARN, msg)
fun error(msg: String) = append(LogLevel.ERROR, msg)
fun success(msg: String) = append(LogLevel.SUCCESS, msg)
fun clear() {
_logs.value = emptyList()
}
}
@@ -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="M480,600L280,400L680,400L480,600Z"/>
</vector>
@@ -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="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
</vector>
@@ -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="M240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,560L720,560L720,360L520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L600,800L600,880L240,880ZM878,895L760,777L760,866L680,866L680,640L906,640L906,720L816,720L934,838L878,895ZM240,800L240,560L240,560L240,360L240,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800Z"/>
</vector>
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="#FF000000" android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"/>
</vector>
@@ -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="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
</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="M320,320L320,320L320,640L320,640L320,320ZM240,720L240,240L720,240L720,720L240,720ZM320,640L640,640L640,320L320,320L320,640Z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_android_black_24dp" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_android_black_24dp" />
</adaptive-icon>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#00E676</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">咖喱烧烤</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.IndPjsk" parent="android:Theme.Material.Light.NoActionBar"/>
</resources>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="captures" path="captures/" />
</paths>