refactor: remove ServerControlScreen composable and associated utility functions from MainActivity
Build / build (push) Successful in 5m32s

This commit is contained in:
2026-06-26 11:05:48 +07:00
parent c2bd81fc75
commit 1a26f98b02
@@ -330,752 +330,7 @@ fun removeFile(targetDir: File, fileName: String): Boolean {
} }
@SuppressLint("ImplicitSamInstance")
@Composable
fun ServerControlScreen(appDataPath: String, dataDir: File, appVersion: AppVersion, authManager: AuthManager, modifier: Modifier = Modifier) {
val context = LocalContext.current
val isRunning = GolangServerService.isRunning
var showResetDialog by remember { mutableStateOf(false) }
var showUpdateDialog by remember { mutableStateOf(false) }
var showLogs by remember { mutableStateOf(false) }
Box(
modifier = modifier.fillMaxSize()
) {
// Background image
Image(
painter = painterResource(id = R.drawable.background),
contentDescription = "Background",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
// Title
Text(
text = "Firefly GO for Android",
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 24.dp),
color = Color.White,
style = TextStyle(
shadow = Shadow(
color = Color.Black,
offset = Offset(2f, 2f),
blurRadius = 4f
)
)
)
// Server status with icon
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = if (isRunning) Icons.Default.PlayCircleFilled else Icons.Default.StopCircle,
contentDescription = null,
tint = if (isRunning) Color(0xFF4CAF50) else Color.Gray,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isRunning) "Server is running" else "Server is stopped",
fontSize = 30.sp,
color = Color.White,
fontWeight = FontWeight.Medium,
style = TextStyle(
shadow = Shadow(
color = Color.Black,
offset = Offset(1f, 1f),
blurRadius = 2f
)
)
)
}
Spacer(modifier = Modifier.height(200.dp))
// Toggle button
Button(
onClick = {
try {
if (!isRunning) {
val intent = Intent(context, GolangServerService::class.java)
intent.putExtra("appDataPath", appDataPath)
ContextCompat.startForegroundService(context, intent)
} else {
context.stopService(Intent(context, GolangServerService::class.java))
}
} catch (e: Exception) {
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = if (isRunning) Color(0xFFB71C1C) else Color(0xFF2196F3),
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth(0.8f)
.height(50.dp)
) {
Icon(
imageVector = if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isRunning) "Stop Server" else "Start Server",
fontSize = 20.sp
)
}
Spacer(modifier = Modifier.height(4.dp))
// Widget icons row
Row(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
// Check Update widget
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(80.dp)
.clickable { showUpdateDialog = true }
.background(
Color.White.copy(alpha = 0.8f),
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
Icon(
imageVector = Icons.Default.CloudDownload,
contentDescription = "Check Update",
tint = Color.Gray,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Update",
fontSize = 12.sp,
color = Color.Black,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
}
// Reset Data widget
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(80.dp)
.clickable { showResetDialog = true }
.background(
Color.White.copy(alpha = 0.8f),
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset Data",
tint = Color.Gray,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Reset",
fontSize = 12.sp,
color = Color.Black,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
}
// Logcat (Lynx) widget
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(80.dp)
.clickable {
showLogs = true // mở popup log
}
.background(
Color.White.copy(alpha = 0.8f),
RoundedCornerShape(8.dp)
)
.padding(12.dp)
) {
Icon(
imageVector = Icons.Default.BugReport,
contentDescription = "Open Logcat",
tint = Color.Gray,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Logs",
fontSize = 12.sp,
color = Color.Black,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(75.dp))
}
}
if (showLogs) {
LogPopup(onDismiss = { showLogs = false })
}
// Reset Data Confirmation Dialog
if (showResetDialog) {
AlertDialog(
onDismissRequest = { showResetDialog = false },
title = {
Text(text = "Reset Data")
},
text = {
Text(text = "Do you want reset all data? This action can not rollback.")
},
confirmButton = {
TextButton(
onClick = {
showResetDialog = false
try {
copyRawToFile(context, dataDir, true)
Toast.makeText(context, "Data has been reset successfully", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, "Reset failed: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
) {
Text("Yes", color = Color(0xFFFF0606))
}
},
dismissButton = {
TextButton(
onClick = { showResetDialog = false }
) {
Text("No")
}
}
)
}
// Auto Update Dialog
if (showUpdateDialog) {
AutoUpdateDialog(
onDismiss = { showUpdateDialog = false },
appVersion,
dataDir
)
}
}
fun parseGoLogLine(line: String): String? {
val regex = Regex(""".*GoLog\s*:?\s*(.*)""")
val match = regex.find(line)
val content = match?.groupValues?.getOrNull(1)?.trim()
return if (content.isNullOrBlank()) null else content
}
fun parseAnsi(text: String, defaultColor: Color): AnnotatedString {
val regex = Regex("\u001B\\[(\\d+)(;\\d+)*m")
val builder = buildAnnotatedString {
var lastIndex = 0
var currentColor = defaultColor
for (match in regex.findAll(text)) {
val start = match.range.first
// 1. Thêm phần text TRƯỚC mã ANSI với màu HIỆN TẠI
val before = text.substring(lastIndex, start)
if (before.isNotEmpty()) {
withStyle(SpanStyle(color = currentColor)) {
append(before)
}
}
// 2. Lấy mã code (ví dụ 31, 36, hoặc 0)
val code = try {
match.groupValues[1].toInt()
} catch (e: NumberFormatException) {
0
}
currentColor = when (code) {
0 -> defaultColor
30 -> Color.Black
31 -> Color.Red
32 -> Color(0xFF00C853) // Green
33 -> Color(0xFFFFD600) // Yellow
34 -> Color(0xFF2962FF) // Blue
35 -> Color(0xFFD500F9) // Magenta
36 -> Color(0xFF00B8D4) // Cyan
37 -> Color.White
else -> currentColor
}
lastIndex = match.range.last + 1
}
if (lastIndex < text.length) {
val remain = text.substring(lastIndex)
if (remain.isNotEmpty()) {
withStyle(SpanStyle(color = currentColor)) {
append(remain)
}
}
}
}
return builder
}
@Composable
fun LogPopup(
onDismiss: () -> Unit
) {
var logs by remember { mutableStateOf(listOf<String>()) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("logcat -s GoLog")
val reader = BufferedReader(InputStreamReader(process.inputStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
val clean = parseGoLogLine(line!!)
if (!clean.isNullOrBlank()) {
logs = (logs + clean).takeLast(200)
}
}
} catch (e: Exception) {
logs = logs + "Error reading logcat: ${e.message}"
}
}
}
val listState = rememberLazyListState()
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
val defaultTextColor = LocalContentColor.current
Dialog(onDismissRequest = { onDismiss() }) {
Surface(
shape = RoundedCornerShape(12.dp),
tonalElevation = 8.dp,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.7f)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "GoLog Output",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(state = listState, modifier = Modifier.weight(1f)) {
items(logs.size) { index ->
Text(
text = parseAnsi(logs[index], defaultTextColor),
fontSize = 12.sp,
// 2. DÙNG FONT MONOSPACE
fontFamily = FontFamily.Monospace,
// 3. (Tuỳ chọn) Giảm chiều cao dòng để logo liền mạch
lineHeight = 14.sp,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { onDismiss() },
modifier = Modifier.align(Alignment.End)
) {
Text("Close")
}
}
}
}
}
@Composable
fun AutoUpdateDialog(
onDismiss: () -> Unit,
appVersion: AppVersion,
dataDir: File,
isFirstOpen: Boolean = false
) {
val context = LocalContext.current
val autoUpdaterManager = AutoUpdaterManager(context)
var update by remember { mutableStateOf<UpdateFeatures?>(null) }
var progress by remember { mutableStateOf(0) }
var showDialog by remember { mutableStateOf(false) }
var isDownloading by remember { mutableStateOf(false) }
var downloadComplete by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val progressAnimation by animateFloatAsState(
targetValue = progress / 100f,
animationSpec = tween(300, easing = FastOutSlowInEasing),
label = "progress"
)
val scaleAnimation by animateFloatAsState(
targetValue = if (showDialog) 1f else 0.8f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "scale"
)
// Check for update
LaunchedEffect(Unit) {
val result = withContext(Dispatchers.IO) {
autoUpdaterManager.checkForUpdate(
JSONfileURL = "https://git.kain.io.vn/Firefly-Shelter/FireflyGo_Andoid/raw/branch/master/app/src/main/res/raw/app_version_json.json"
)
}
val hasUpdate = result != null && appVersion.latestVersion != result.latestversion
update = if (hasUpdate) result else null
showDialog = if (isFirstOpen) {
hasUpdate
} else {
result != null
}
}
// Download progress
LaunchedEffect(progress) {
if (progress >= 100 && isDownloading) {
downloadComplete = true
}
}
if (showDialog) {
Dialog(
onDismissRequest = {
if (!isDownloading) showDialog = false
onDismiss()
},
properties = DialogProperties(
dismissOnBackPress = !isDownloading,
dismissOnClickOutside = !isDownloading
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.scale(scaleAnimation)
.animateContentSize(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Header icon
Box(
modifier = Modifier
.size(72.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (update != null) Icons.Rounded.SystemUpdate
else Icons.Rounded.CheckCircle,
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(16.dp))
// Title
Text(
text = if (update != null) "Update Available" else "No Update Available",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
if (update != null) {
VersionInfoSection(update!!)
ChangelogSection(update!!)
DownloadProgressSection(
isDownloading = isDownloading,
downloadComplete = downloadComplete,
progress = progressAnimation
)
ActionButtons(
isDownloading = isDownloading,
downloadComplete = downloadComplete,
onDownloadClick = {
isDownloading = true
coroutineScope.launch {
withContext(Dispatchers.IO) {
autoUpdaterManager.downloadapk(
context,
update!!.apk_url,
"FireflyGO_${update!!.latestversion}.apk"
) { prog -> progress = prog }
}
}
},
onDismiss = { showDialog = false; onDismiss() }
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Your app is up to date",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showDialog = false; onDismiss() },
modifier = Modifier.wrapContentWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 12.dp)
) {
Text(
text = "OK",
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
}
}
// Version info card
@Composable
fun VersionInfoSection(update: UpdateFeatures) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Latest Version",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.primary
) {
Text(
text = "v${update.latestversion}",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Medium
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Changelog section
@Composable
fun ChangelogSection(update: UpdateFeatures) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Rounded.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "What's New",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = update.changelog,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 20.sp
)
}
}
// Progress section
@Composable
fun DownloadProgressSection(
isDownloading: Boolean,
downloadComplete: Boolean,
progress: Float
) {
if (!isDownloading && !downloadComplete) return
Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (downloadComplete) "Installation Ready" else "Downloading...",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
Text(
text = "${(progress*100).toInt()}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = if (downloadComplete) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
// Action buttons
@Composable
fun ActionButtons(
isDownloading: Boolean,
downloadComplete: Boolean,
onDownloadClick: () -> Unit,
onDismiss: () -> Unit
) {
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (!isDownloading && !downloadComplete) Arrangement.spacedBy(12.dp) else Arrangement.Center
) {
if (!downloadComplete) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
enabled = !isDownloading,
shape = RoundedCornerShape(12.dp)
) {
Text(text = "Later", style = MaterialTheme.typography.labelLarge)
}
}
Button(
onClick = onDownloadClick,
modifier = if (downloadComplete) Modifier.widthIn(min = 160.dp) else Modifier.weight(1f),
enabled = !isDownloading || downloadComplete,
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (downloadComplete) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary
)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
when {
downloadComplete -> {
Icon(Icons.Rounded.InstallMobile, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Install Now", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Medium)
}
isDownloading -> {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
}
else -> {
Icon(
imageVector = Icons.Rounded.Download,
contentDescription = "Download",
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
}
@Composable @Composable
fun Modifier.bounceClick( fun Modifier.bounceClick(