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
@@ -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
)
*/
)