diff --git a/manager/app/src/main/AndroidManifest.xml b/manager/app/src/main/AndroidManifest.xml index e5903da2..15841516 100644 --- a/manager/app/src/main/AndroidManifest.xml +++ b/manager/app/src/main/AndroidManifest.xml @@ -35,17 +35,20 @@ + + + diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt index b50699d3..eaea75c8 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt @@ -11,13 +11,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import androidx.navigation.NavBackStackEntry @@ -40,14 +37,13 @@ import com.sukisu.ultra.ui.viewmodel.HomeViewModel import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel import com.sukisu.ultra.ui.webui.initPlatform import com.sukisu.ultra.ui.screen.FlashIt +import com.sukisu.ultra.ui.component.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zako.zako.zako.zakoui.activity.component.BottomBar import zako.zako.zako.zakoui.activity.util.* -import java.util.zip.ZipInputStream -import java.io.IOException import androidx.core.content.edit import com.sukisu.ultra.ui.util.rootAvailable @@ -61,6 +57,9 @@ class MainActivity : ComponentActivity() { val showKpmInfo: Boolean = false ) + private var showConfirmationDialog = mutableStateOf(false) + private var pendingZipFiles = mutableStateOf>(emptyList()) + private lateinit var themeChangeObserver: ThemeChangeContentObserver // 添加标记避免重复初始化 @@ -102,7 +101,7 @@ class MainActivity : ComponentActivity() { intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) } else { @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_STREAM) + intent.getParcelableExtra(Intent.EXTRA_STREAM) } uri?.let { arrayListOf(it) } } @@ -140,10 +139,24 @@ class MainActivity : ComponentActivity() { val navigator = navController.rememberDestinationsNavigator() + InstallConfirmationDialog( + show = showConfirmationDialog.value, + zipFiles = pendingZipFiles.value, + onConfirm = { confirmedFiles -> + showConfirmationDialog.value = false + navigateToFlashScreen(confirmedFiles, navigator) + }, + onDismiss = { + showConfirmationDialog.value = false + pendingZipFiles.value = emptyList() + finish() + } + ) + LaunchedEffect(zipUri) { if (!zipUri.isNullOrEmpty()) { - // 检测 ZIP 文件类型并导航到相应界面 - detectZipTypeAndNavigate(zipUri, navigator) + // 检测 ZIP 文件类型并显示确认对话框 + detectZipTypeAndShowConfirmation(zipUri) } } @@ -223,104 +236,55 @@ class MainActivity : ComponentActivity() { } } - private enum class ZipType { - MODULE, - KERNEL, - UNKNOWN - } + private suspend fun detectZipTypeAndShowConfirmation(zipUris: ArrayList) { + try { + val zipFileInfos = ZipFileDetector.detectAndParseZipFiles(this, zipUris) - private fun detectZipType(uri: Uri): ZipType { - return try { - contentResolver.openInputStream(uri)?.use { inputStream -> - ZipInputStream(inputStream).use { zipStream -> - var hasModuleProp = false - var hasToolsFolder = false - var hasAnykernelSh = false - - var entry = zipStream.nextEntry - while (entry != null) { - val entryName = entry.name.lowercase() - - when { - entryName == "module.prop" || entryName.endsWith("/module.prop") -> { - hasModuleProp = true - } - entryName.startsWith("tools/") || entryName == "tools" -> { - hasToolsFolder = true - } - entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> { - hasAnykernelSh = true - } - } - - zipStream.closeEntry() - entry = zipStream.nextEntry - } - - when { - hasModuleProp -> ZipType.MODULE - hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL - else -> ZipType.UNKNOWN - } + withContext(Dispatchers.Main) { + if (zipFileInfos.isNotEmpty()) { + pendingZipFiles.value = zipFileInfos + showConfirmationDialog.value = true + } else { + finish() } - } ?: ZipType.UNKNOWN - } catch (e: IOException) { + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + finish() + } e.printStackTrace() - ZipType.UNKNOWN } } - private suspend fun detectZipTypeAndNavigate( - zipUris: ArrayList, + private fun navigateToFlashScreen( + zipFiles: List, navigator: com.ramcosta.composedestinations.navigation.DestinationsNavigator ) { - withContext(Dispatchers.IO) { - try { - val moduleUris = mutableListOf() - val kernelUris = mutableListOf() + lifecycleScope.launch { + val moduleUris = zipFiles.filter { it.type == ZipType.MODULE }.map { it.uri } + val kernelUris = zipFiles.filter { it.type == ZipType.KERNEL }.map { it.uri } - for (uri in zipUris) { - val zipType = detectZipType(uri) - when (zipType) { - ZipType.MODULE -> moduleUris.add(uri) - ZipType.KERNEL -> kernelUris.add(uri) - ZipType.UNKNOWN -> { - } - } - } - - // 根据检测结果导航 - withContext(Dispatchers.Main) { - when { - // 内核文件 - kernelUris.isNotEmpty() && moduleUris.isEmpty() -> { - if (kernelUris.size == 1 && rootAvailable()) { - navigator.navigate( - InstallScreenDestination( - preselectedKernelUri = kernelUris.first().toString() - ) - ) - } - setAutoExitAfterFlash() - } - // 模块文件 - moduleUris.isNotEmpty() -> { - navigator.navigate( - FlashScreenDestination( - FlashIt.FlashModules(ArrayList(moduleUris)) - ) + when { + // 内核文件 + kernelUris.isNotEmpty() && moduleUris.isEmpty() -> { + if (kernelUris.size == 1 && rootAvailable()) { + navigator.navigate( + InstallScreenDestination( + preselectedKernelUri = kernelUris.first().toString() ) - setAutoExitAfterFlash() - } - // 如果没有识别出任何类型的文件,则直接退出 - else -> { - (this@MainActivity as? ComponentActivity)?.finish() - } + ) } + setAutoExitAfterFlash() + } + // 模块文件 + moduleUris.isNotEmpty() -> { + navigator.navigate( + FlashScreenDestination( + FlashIt.FlashModules(ArrayList(moduleUris)) + ) + ) + setAutoExitAfterFlash() } - } catch (e: Exception) { - (this@MainActivity as? ComponentActivity)?.finish() - e.printStackTrace() } } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt new file mode 100644 index 00000000..6ae6a475 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/InstallConfirmationDialog.kt @@ -0,0 +1,441 @@ +package com.sukisu.ultra.ui.component + +import android.content.Context +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.GetApp +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sukisu.ultra.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.util.zip.ZipInputStream + +enum class ZipType { + MODULE, + KERNEL, + UNKNOWN +} + +data class ZipFileInfo( + val uri: Uri, + val type: ZipType, + val name: String = "", + val version: String = "", + val versionCode: String = "", + val author: String = "", + val description: String = "", + val kernelVersion: String = "", + val supported: String = "" +) + +object ZipFileDetector { + + fun detectZipType(context: Context, uri: Uri): ZipType { + return try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var hasModuleProp = false + var hasToolsFolder = false + var hasAnykernelSh = false + + var entry = zipStream.nextEntry + while (entry != null) { + val entryName = entry.name.lowercase() + + when { + entryName == "module.prop" || entryName.endsWith("/module.prop") -> { + hasModuleProp = true + } + entryName.startsWith("tools/") || entryName == "tools" -> { + hasToolsFolder = true + } + entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> { + hasAnykernelSh = true + } + } + + zipStream.closeEntry() + entry = zipStream.nextEntry + } + + when { + hasModuleProp -> ZipType.MODULE + hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL + else -> ZipType.UNKNOWN + } + } + } ?: ZipType.UNKNOWN + } catch (e: IOException) { + e.printStackTrace() + ZipType.UNKNOWN + } + } + + fun parseModuleInfo(context: Context, uri: Uri): ZipFileInfo { + var zipInfo = ZipFileInfo(uri = uri, type = ZipType.MODULE) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (entry.name.lowercase() == "module.prop" || entry.name.endsWith("/module.prop")) { + val reader = BufferedReader(InputStreamReader(zipStream)) + val props = mutableMapOf() + + var line = reader.readLine() + while (line != null) { + if (line.contains("=") && !line.startsWith("#")) { + val parts = line.split("=", limit = 2) + if (parts.size == 2) { + props[parts[0].trim()] = parts[1].trim() + } + } + line = reader.readLine() + } + + zipInfo = zipInfo.copy( + name = props["name"] ?: context.getString(R.string.unknown_module), + version = props["version"] ?: "", + versionCode = props["versionCode"] ?: "", + author = props["author"] ?: "", + description = props["description"] ?: "" + ) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return zipInfo + } + + fun parseKernelInfo(context: Context, uri: Uri): ZipFileInfo { + var zipInfo = ZipFileInfo(uri = uri, type = ZipType.KERNEL) + + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (entry.name.lowercase() == "anykernel.sh" || entry.name.endsWith("/anykernel.sh")) { + val reader = BufferedReader(InputStreamReader(zipStream)) + val props = mutableMapOf() + + var inPropertiesBlock = false + var line = reader.readLine() + while (line != null) { + if (line.contains("properties()")) { + inPropertiesBlock = true + } else if (inPropertiesBlock && line.contains("'; }")) { + inPropertiesBlock = false + } else if (inPropertiesBlock) { + val propertyLine = line.trim() + if (propertyLine.contains("=") && !propertyLine.startsWith("#")) { + val parts = propertyLine.split("=", limit = 2) + if (parts.size == 2) { + val key = parts[0].trim() + val value = parts[1].trim().removeSurrounding("'").removeSurrounding("\"") + when (key) { + "kernel.string" -> props["name"] = value + "supported.versions" -> props["supported"] = value + } + } + } + } + + // 解析普通变量定义 + if (line.contains("kernel.string=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.string=").trim().removeSurrounding("\"") + props["name"] = value + } + if (line.contains("supported.versions=") && !inPropertiesBlock) { + val value = line.substringAfter("supported.versions=").trim().removeSurrounding("\"") + props["supported"] = value + } + if (line.contains("kernel.version=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.version=").trim().removeSurrounding("\"") + props["version"] = value + } + if (line.contains("kernel.author=") && !inPropertiesBlock) { + val value = line.substringAfter("kernel.author=").trim().removeSurrounding("\"") + props["author"] = value + } + + line = reader.readLine() + } + + zipInfo = zipInfo.copy( + name = props["name"] ?: context.getString(R.string.unknown_kernel), + version = props["version"] ?: "", + author = props["author"] ?: "", + supported = props["supported"] ?: "", + kernelVersion = props["version"] ?: "" + ) + break + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + return zipInfo + } + + suspend fun detectAndParseZipFiles(context: Context, zipUris: List): List { + return withContext(Dispatchers.IO) { + val zipFileInfos = mutableListOf() + + for (uri in zipUris) { + val zipType = detectZipType(context, uri) + val zipInfo = when (zipType) { + ZipType.MODULE -> parseModuleInfo(context, uri) + ZipType.KERNEL -> parseKernelInfo(context, uri) + ZipType.UNKNOWN -> ZipFileInfo( + uri = uri, + type = ZipType.UNKNOWN, + name = context.getString(R.string.unknown_file) + ) + } + zipFileInfos.add(zipInfo) + } + + zipFileInfos.filter { it.type != ZipType.UNKNOWN } + } + } +} + +@Composable +fun InstallConfirmationDialog( + show: Boolean, + zipFiles: List, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit +) { + if (show && zipFiles.isNotEmpty()) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = if (zipFiles.any { it.type == ZipType.KERNEL }) + Icons.Default.Memory else Icons.Default.Extension, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = if (zipFiles.size == 1) { + context.getString(R.string.confirm_installation) + } else { + context.getString(R.string.confirm_multiple_installation, zipFiles.size) + }, + style = MaterialTheme.typography.headlineSmall + ) + } + }, + text = { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(zipFiles.size) { index -> + val zipFile = zipFiles[index] + InstallItemCard(zipFile = zipFile) + } + } + }, + confirmButton = { + Button( + onClick = { onConfirm(zipFiles) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.GetApp, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(context.getString(R.string.install_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + context.getString(android.R.string.cancel), + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + modifier = Modifier.widthIn(min = 320.dp, max = 560.dp) + ) + } +} + +@Composable +fun InstallItemCard(zipFile: ZipFileInfo) { + val context = LocalContext.current + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = when (zipFile.type) { + ZipType.MODULE -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ZipType.KERNEL -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = when (zipFile.type) { + ZipType.MODULE -> Icons.Default.Extension + ZipType.KERNEL -> Icons.Default.Memory + else -> Icons.AutoMirrored.Filled.Help + }, + contentDescription = null, + tint = when (zipFile.type) { + ZipType.MODULE -> MaterialTheme.colorScheme.primary + ZipType.KERNEL -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = zipFile.name.ifEmpty { + when (zipFile.type) { + ZipType.MODULE -> context.getString(R.string.unknown_module) + ZipType.KERNEL -> context.getString(R.string.unknown_kernel) + else -> context.getString(R.string.unknown_file) + } + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when (zipFile.type) { + ZipType.MODULE -> context.getString(R.string.module_package) + ZipType.KERNEL -> context.getString(R.string.kernel_package) + else -> context.getString(R.string.unknown_package) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 详细信息 + if (zipFile.version.isNotEmpty() || zipFile.author.isNotEmpty() || + zipFile.description.isNotEmpty() || zipFile.supported.isNotEmpty()) { + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + thickness = 0.5.dp + ) + Spacer(modifier = Modifier.height(8.dp)) + + // 版本信息 + if (zipFile.version.isNotEmpty()) { + InfoRow( + label = context.getString(R.string.version), + value = zipFile.version + if (zipFile.versionCode.isNotEmpty()) " (${zipFile.versionCode})" else "" + ) + } + + // 作者信息 + if (zipFile.author.isNotEmpty()) { + InfoRow( + label = context.getString(R.string.author), + value = zipFile.author + ) + } + + // 描述信息 (仅模块) + if (zipFile.description.isNotEmpty() && zipFile.type == ZipType.MODULE) { + InfoRow( + label = context.getString(R.string.description), + value = zipFile.description + ) + } + + // 支持设备 (仅内核) + if (zipFile.supported.isNotEmpty() && zipFile.type == ZipType.KERNEL) { + InfoRow( + label = context.getString(R.string.supported_devices), + value = zipFile.supported + ) + } + } + } + } +} + +@Composable +fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = "$label:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 60.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } +} \ No newline at end of file diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index f05f6cbf..87aa8db2 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -657,4 +657,17 @@ 您确定要清理运行环境吗?这将停止扫描服务并删除相关文件 运行环境清理成功 运行环境清理失败 + + 确认安装 + 确认安装(%d 个文件) + 确认安装 + 模块 + 内核 + 未知类型 + 未知内核 + 未知文件 + 版本 + 作者 + 描述 + 支持设备 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index e8cdabf7..2477f81c 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -665,4 +665,17 @@ Important Note:\n Are you sure you want to clean the runtime environment? This will stop the scanner service and remove related files. Runtime environment cleaned successfully Failed to clean runtime environment + + Confirm Installation + Confirm Installation (%d files) + Install + Module + Kernel + Unknown + Unknown Kernel + Unknown File + Version + Author + Description + Supported Devices \ No newline at end of file