1.0 能用
This commit is contained in:
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
Reference in New Issue
Block a user