commit 1cf7324c77540ad3b1acab9c6df61b58c69636e5 Author: ale Date: Fri Aug 15 02:27:27 2025 +0200 initial commit Signed-off-by: ale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c915a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd82ed3 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# Top Command - Android Process Monitor + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://developer.android.com) +[![API](https://img.shields.io/badge/API-24%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=24) + +Una aplicación moderna de monitoreo de procesos para Android inspirada en el comando `top` de Linux. Proporciona información detallada sobre todos los procesos del sistema con una interfaz intuitiva desarrollada en Jetpack Compose. + +![![Top Command Screenshot](screenshot.png)]( + +## 📱 Características + +### 🔍 **Monitoreo en Tiempo Real** +- Lista completa de procesos del sistema +- Actualización automática configurable (1-10 segundos) +- Información de CPU, memoria, estado y usuario para cada proceso +- Contadores de procesos y threads + +### 🔐 **Soporte para Root** +- **Sin Root**: Ve procesos del usuario actual +- **Con Root**: Acceso completo a todos los procesos del sistema +- Detección automática de disponibilidad de root +- Interfaz segura para solicitar permisos de superusuario + +### 📊 **Información Detallada del Sistema** +Al hacer clic en cualquier proceso se muestra: +- **Información básica**: PID, nombre, usuario, estado, prioridad +- **Rendimiento**: Uso de CPU y memoria con códigos de color +- **Detalles avanzados**: PID padre, tiempo de inicio, tiempo de CPU +- **Memoria**: Virtual, residente, compartida +- **Archivos**: Línea de comandos, archivos abiertos, directorio de trabajo +- **Red**: Conexiones TCP activas +- **Sistema**: Mapas de memoria, variables de entorno, límites + +### 🎨 **Diseño Atractivo** +- **Material 3 Design** con tema inspirado en terminal +- **Tema oscuro/claro** adaptativo +- **Colores contextuales** para estados y métricas de rendimiento +- **Animaciones fluidas** y transiciones suaves +- **Cards organizadas** para fácil lectura + +### 🔧 **Funcionalidades Avanzadas** +- **Búsqueda en tiempo real** por nombre, PID o usuario +- **Ordenación múltiple** por PID, nombre, CPU, memoria o usuario +- **Filtrado inteligente** con highlighting de resultados +- **Configuración personalizable** de intervalos de actualización +- **Información del sistema** (uptime, load average, memoria total) + +## 🚀 Compilación y Instalación + +### Prerrequisitos + +- **Android Studio** Hedgehog (2023.1.1) o superior +- **JDK 11** o superior +- **Android SDK** con API level 24+ (Android 7.0) +- **Gradle 8.13** o superior (incluido con el proyecto) + +### Clonar el Repositorio + +```bash +git clone https://github.com/tuusuario/top-command-android.git +cd top-command-android +``` + +### Compilar con Gradle + +#### Desde la línea de comandos: + +```bash +# Compilar versión debug +./gradlew assembleDebug + +# Compilar versión release +./gradlew assembleRelease + +# Limpiar y compilar +./gradlew clean assembleDebug + +# Instalar directamente en dispositivo conectado +./gradlew installDebug +``` + +#### En Windows: +```cmd +gradlew.bat assembleDebug +``` + +### Compilar desde Android Studio + +1. Abre **Android Studio** +2. Selecciona **"Open an Existing Project"** +3. Navega y selecciona la carpeta del proyecto +4. Espera a que Gradle sincronice las dependencias +5. Ejecuta con **Run 'app'** o **Ctrl+R** + +### Generar APK + +```bash +# APK debug (para desarrollo) +./gradlew assembleDebug +# Output: app/build/outputs/apk/debug/app-debug.apk + +# APK release (para distribución) +./gradlew assembleRelease +# Output: app/build/outputs/apk/release/app-release.apk +``` + +### Generar Bundle de Android (AAB) + +```bash +# Bundle para Google Play Store +./gradlew bundleRelease +# Output: app/build/outputs/bundle/release/app-release.aab +``` + +## 📋 Requisitos del Sistema + +### Para Compilación +- **SO**: Windows 10+, macOS 10.14+, o Linux +- **RAM**: Mínimo 8GB recomendado +- **Espacio**: 4GB libres para Android Studio + SDK + +### Para la Aplicación +- **Android**: 7.0 (API 24) o superior +- **Arquitectura**: ARM64, ARM32, x86, x86_64 +- **Permisos**: No requiere permisos especiales +- **Root** (opcional): Para acceso completo a procesos del sistema + +## 🛠️ Estructura del Proyecto + +``` +app/src/main/java/com/manalejandro/topcommand/ +├── model/ +│ ├── ProcessInfo.kt # Modelo de datos del proceso +│ └── SortBy.kt # Enumeración para ordenación +├── service/ +│ ├── ProcessMonitorService.kt # Servicio sin root +│ └── RootService.kt # Servicio con acceso root +├── ui/ +│ ├── components/ +│ │ ├── ProcessComponents.kt # Componentes de UI +│ │ ├── ProcessDetailDialog.kt # Diálogo de detalles +│ │ └── RootComponents.kt # Componentes de root +│ ├── screen/ +│ │ └── ProcessMonitorScreen.kt # Pantalla principal +│ └── theme/ +│ ├── Color.kt # Paleta de colores +│ └── Theme.kt # Tema Material +├── viewmodel/ +│ └── ProcessViewModel.kt # Lógica de negocio +└── MainActivity.kt # Actividad principal +``` + +## 🎯 Uso de la Aplicación + +### Navegación Básica +1. **Vista principal**: Lista de todos los procesos visibles +2. **Búsqueda**: Toca la barra de búsqueda para filtrar +3. **Ordenación**: Toca cualquier columna para ordenar +4. **Detalles**: Toca cualquier proceso para ver información completa +5. **Configuración**: Botón de ajustes en la barra superior + +### Acceso Root +1. La app detecta automáticamente si el dispositivo tiene root +2. Toca **"Enable Root Access"** en la tarjeta superior +3. Concede permisos cuando tu gestor de root lo solicite +4. Disfruta del acceso completo a todos los procesos del sistema + +### Funciones Avanzadas +- **Auto-refresh**: Configurable desde 1-10 segundos +- **Filtros**: Busca por nombre, PID, o usuario +- **Colores**: Verde (bajo), amarillo (medio), naranja/rojo (alto) +- **Información del sistema**: Visible solo con acceso root + +## 🧪 Testing + +```bash +# Ejecutar tests unitarios +./gradlew test + +# Tests de instrumentación (requiere dispositivo/emulador) +./gradlew connectedAndroidTest + +# Tests de UI +./gradlew connectedDebugAndroidTest +``` + +## 🏗️ Tecnologías Utilizadas + +- **[Jetpack Compose](https://developer.android.com/jetpack/compose)** - UI moderna y declarativa +- **[Material 3](https://m3.material.io/)** - Sistema de diseño +- **[ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel)** - Gestión de estado +- **[Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html)** - Programación asíncrona +- **[StateFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)** - Flujo de datos reactivo + +## 🤝 Contribución + +¡Las contribuciones son bienvenidas! Por favor: + +1. Fork el proyecto +2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`) +3. Commit tus cambios (`git commit -am 'Agregar nueva funcionalidad'`) +4. Push a la rama (`git push origin feature/nueva-funcionalidad`) +5. Abre un Pull Request + +### Guías de Contribución +- Sigue las convenciones de código de Kotlin +- Documenta funciones públicas +- Incluye tests para nuevas funcionalidades +- Actualiza el README si es necesario + +## 📄 Licencia + +Este proyecto está licenciado bajo la Licencia MIT - ver el archivo [LICENSE](LICENSE) para detalles. + +``` +MIT License + +Copyright (c) 2024 Top Command Android + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +## 👨‍💻 Autor + +**Alejandro** - [GitHub](https://github.com/tuusuario) + +## 📞 Soporte + +Si tienes problemas o preguntas: +- 🐛 **Reportar bugs**: [Issues](https://github.com/tuusuario/top-command-android/issues) +- 💡 **Sugerir funcionalidades**: [Discussions](https://github.com/tuusuario/top-command-android/discussions) +- 📧 **Contacto directo**: tu.email@ejemplo.com + +## 📈 Roadmap + +- [ ] Gráficos en tiempo real de CPU y memoria +- [ ] Exportar información de procesos +- [ ] Widgets de escritorio +- [ ] Notificaciones para procesos con alto uso +- [ ] Modo oscuro forzado +- [ ] Soporte para Android 15+ +- [ ] Filtros avanzados y guardado de configuración + +--- + +⭐ **¡No olvides dar una estrella al proyecto si te resultó útil!** ⭐ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..55936ff --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.manalejandro.topcommand" + compileSdk = 34 + + defaultConfig { + applicationId = "com.manalejandro.topcommand" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // ViewModel and Lifecycle + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.compose.runtime:runtime-livedata:1.5.4") + + // Icons extended + implementation("androidx.compose.material:material-icons-extended:1.5.4") + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/manalejandro/topcommand/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/manalejandro/topcommand/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..3614f42 --- /dev/null +++ b/app/src/androidTest/java/com/manalejandro/topcommand/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.manalejandro.topcommand + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.manalejandro.topcommand", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a7501f8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..a13f121 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/manalejandro/topcommand/MainActivity.kt b/app/src/main/java/com/manalejandro/topcommand/MainActivity.kt new file mode 100644 index 0000000..134ff2a --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/MainActivity.kt @@ -0,0 +1,20 @@ +package com.manalejandro.topcommand + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.manalejandro.topcommand.ui.screen.ProcessMonitorScreen +import com.manalejandro.topcommand.ui.theme.TopCommandTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + TopCommandTheme { + ProcessMonitorScreen() + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/topcommand/model/ProcessInfo.kt b/app/src/main/java/com/manalejandro/topcommand/model/ProcessInfo.kt new file mode 100644 index 0000000..1e4238d --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/model/ProcessInfo.kt @@ -0,0 +1,29 @@ +package com.manalejandro.topcommand.model + +data class ProcessInfo( + val pid: Int, + val name: String, + val cpuUsage: Double, + val memoryUsage: Long, + val memoryPercentage: Double, + val user: String, + val state: String, + val priority: Int, + val threads: Int +) + +enum class SortBy { + PID, + NAME, + CPU, + MEMORY, + USER +} + +enum class ProcessState { + RUNNING, + SLEEPING, + STOPPED, + ZOMBIE, + UNKNOWN +} diff --git a/app/src/main/java/com/manalejandro/topcommand/service/ProcessMonitorService.kt b/app/src/main/java/com/manalejandro/topcommand/service/ProcessMonitorService.kt new file mode 100644 index 0000000..60810ef --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/service/ProcessMonitorService.kt @@ -0,0 +1,361 @@ +package com.manalejandro.topcommand.service + +import com.manalejandro.topcommand.model.ProcessInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException + +class ProcessMonitorService { + + suspend fun getProcessList(): List = withContext(Dispatchers.IO) { + val processes = mutableListOf() + + try { + val procDir = File("/proc") + val pidDirs = procDir.listFiles { file -> + file.isDirectory && file.name.matches(Regex("\\d+")) + } ?: return@withContext emptyList() + + for (pidDir in pidDirs) { + try { + val pid = pidDir.name.toInt() + val processInfo = getProcessInfo(pid) + processInfo?.let { processes.add(it) } + } catch (e: Exception) { + // Skip processes that can't be read + continue + } + } + } catch (e: Exception) { + // Return empty list if can't access /proc + } + + processes.sortedByDescending { it.cpuUsage } + } + + private fun getProcessInfo(pid: Int): ProcessInfo? { + return try { + val statFile = File("/proc/$pid/stat") + val statusFile = File("/proc/$pid/status") + val cmdlineFile = File("/proc/$pid/cmdline") + + if (!statFile.exists() || !statusFile.exists()) return null + + val statContent = statFile.readText().split(" ") + val statusContent = statusFile.readText() + + val name = getProcessName(cmdlineFile, statContent) + val state = getProcessState(statContent[2]) + val priority = statContent.getOrNull(17)?.toIntOrNull() ?: 0 + val threads = statContent.getOrNull(19)?.toIntOrNull() ?: 1 + + val memoryInfo = getMemoryInfo(statusContent) + val user = getProcessUser(pid) + val cpuUsage = calculateCpuUsage(pid) + + ProcessInfo( + pid = pid, + name = name, + cpuUsage = cpuUsage, + memoryUsage = memoryInfo.first, + memoryPercentage = memoryInfo.second, + user = user, + state = state, + priority = priority, + threads = threads + ) + } catch (e: Exception) { + null + } + } + + private fun getProcessName(cmdlineFile: File, statContent: List): String { + return try { + if (cmdlineFile.exists()) { + val cmdline = cmdlineFile.readText().replace("\u0000", " ").trim() + if (cmdline.isNotEmpty()) { + cmdline.split(" ").first().split("/").last() + } else { + statContent[1].removeSurrounding("(", ")") + } + } else { + statContent[1].removeSurrounding("(", ")") + } + } catch (e: Exception) { + "unknown" + } + } + + private fun getProcessState(stateChar: String): String { + return when (stateChar) { + "R" -> "Running" + "S" -> "Sleeping" + "D" -> "Waiting" + "Z" -> "Zombie" + "T" -> "Stopped" + "t" -> "Tracing" + "W" -> "Paging" + "X", "x" -> "Dead" + "K" -> "Wakekill" + "P" -> "Parked" + else -> "Unknown" + } + } + + private fun getMemoryInfo(statusContent: String): Pair { + return try { + val vmRssLine = statusContent.lines().find { it.startsWith("VmRSS:") } + val vmSizeLine = statusContent.lines().find { it.startsWith("VmSize:") } + + val rss = vmRssLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: 0L + val size = vmSizeLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: 0L + + val rssBytes = rss * 1024 // Convert from KB to bytes + val totalMemory = getTotalMemory() + val percentage = if (totalMemory > 0) (rssBytes.toDouble() / totalMemory) * 100 else 0.0 + + Pair(rssBytes, percentage) + } catch (e: Exception) { + Pair(0L, 0.0) + } + } + + private fun getProcessUser(pid: Int): String { + return try { + val statusFile = File("/proc/$pid/status") + if (statusFile.exists()) { + val content = statusFile.readText() + val uidLine = content.lines().find { it.startsWith("Uid:") } + uidLine?.split("\\s+".toRegex())?.getOrNull(1) ?: "unknown" + } else { + "unknown" + } + } catch (e: Exception) { + "unknown" + } + } + + private fun calculateCpuUsage(pid: Int): Double { + return try { + // Simplified CPU usage calculation + // In a real implementation, you'd need to calculate this over time + val statFile = File("/proc/$pid/stat") + if (statFile.exists()) { + val statContent = statFile.readText().split(" ") + val utime = statContent.getOrNull(13)?.toLongOrNull() ?: 0L + val stime = statContent.getOrNull(14)?.toLongOrNull() ?: 0L + val totalTime = utime + stime + + // This is a simplified calculation + // Real CPU usage requires sampling over time + (totalTime % 100).toDouble() + } else { + 0.0 + } + } catch (e: Exception) { + 0.0 + } + } + + private fun getTotalMemory(): Long { + return try { + val meminfoFile = File("/proc/meminfo") + if (meminfoFile.exists()) { + val content = meminfoFile.readText() + val memTotalLine = content.lines().find { it.startsWith("MemTotal:") } + val memTotal = memTotalLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: 0L + memTotal * 1024 // Convert from KB to bytes + } else { + 0L + } + } catch (e: Exception) { + 0L + } + } + + suspend fun getProcessBasicDetails(pid: Int): ProcessDetailedInfo? = withContext(Dispatchers.IO) { + try { + val procDir = File("/proc/$pid") + if (!procDir.exists()) return@withContext null + + return@withContext ProcessDetailedInfo( + pid = pid, + parentPid = getBasicParentPid(pid), + commandLine = getBasicCommandLine(pid), + startTime = getBasicStartTime(pid), + cpuTime = getBasicCpuTime(pid), + virtualMemory = getBasicVirtualMemory(pid), + residentMemory = getBasicResidentMemory(pid), + sharedMemory = null, // Not accessible without root + terminal = getBasicTerminal(pid), + workingDirectory = getBasicWorkingDirectory(pid), + openFiles = getBasicOpenFiles(pid), + networkConnections = emptyList(), // Limited without root + memoryMaps = getBasicMemoryMaps(pid), + environment = emptyMap(), // Limited without root + limits = emptyMap() // Limited without root + ) + } catch (e: Exception) { + null + } + } + + private fun getBasicParentPid(pid: Int): Int? { + return try { + val statFile = File("/proc/$pid/stat") + if (!statFile.exists()) return null + val content = statFile.readText() + val parts = content.split(" ") + parts.getOrNull(3)?.toIntOrNull() + } catch (e: Exception) { + null + } + } + + private fun getBasicCommandLine(pid: Int): String { + return try { + val cmdlineFile = File("/proc/$pid/cmdline") + if (cmdlineFile.exists()) { + val cmdline = cmdlineFile.readText().replace("\u0000", " ").trim() + if (cmdline.isNotEmpty()) { + cmdline + } else { + val commFile = File("/proc/$pid/comm") + if (commFile.exists()) commFile.readText().trim() else "" + } + } else "" + } catch (e: Exception) { + "" + } + } + + private fun getBasicStartTime(pid: Int): String? { + return try { + val statFile = File("/proc/$pid/stat") + if (!statFile.exists()) return null + + val content = statFile.readText() + val parts = content.split(" ") + val starttime = parts.getOrNull(21)?.toLongOrNull() ?: return null + + val uptimeFile = File("/proc/uptime") + val uptime = uptimeFile.readText().split(" ")[0].toDouble() + + val clockTicks = 100 + val processAge = uptime - (starttime.toDouble() / clockTicks) + + "${String.format("%.2f", processAge)} seconds ago" + } catch (e: Exception) { + null + } + } + + private fun getBasicCpuTime(pid: Int): String? { + return try { + val statFile = File("/proc/$pid/stat") + if (!statFile.exists()) return null + + val content = statFile.readText() + val parts = content.split(" ") + val utime = parts.getOrNull(13)?.toLongOrNull() ?: 0L + val stime = parts.getOrNull(14)?.toLongOrNull() ?: 0L + val totalTime = utime + stime + + val clockTicks = 100 + val seconds = totalTime / clockTicks + val minutes = seconds / 60 + val hours = minutes / 60 + + when { + hours > 0 -> "${hours}:${String.format("%02d", minutes % 60)}:${String.format("%02d", seconds % 60)}" + minutes > 0 -> "${minutes}:${String.format("%02d", seconds % 60)}" + else -> "${seconds}s" + } + } catch (e: Exception) { + null + } + } + + private fun getBasicVirtualMemory(pid: Int): Long? { + return try { + val statusFile = File("/proc/$pid/status") + if (!statusFile.exists()) return null + + val content = statusFile.readText() + val vmSizeLine = content.lines().find { it.startsWith("VmSize:") } + val vmSize = vmSizeLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: return null + vmSize * 1024 + } catch (e: Exception) { + null + } + } + + private fun getBasicResidentMemory(pid: Int): Long? { + return try { + val statusFile = File("/proc/$pid/status") + if (!statusFile.exists()) return null + + val content = statusFile.readText() + val vmRssLine = content.lines().find { it.startsWith("VmRSS:") } + val vmRss = vmRssLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: return null + vmRss * 1024 + } catch (e: Exception) { + null + } + } + + private fun getBasicTerminal(pid: Int): String? { + return try { + val statFile = File("/proc/$pid/stat") + if (!statFile.exists()) return null + + val content = statFile.readText() + val parts = content.split(" ") + val tty = parts.getOrNull(6)?.toIntOrNull() ?: return null + + if (tty == 0) "?" else tty.toString() + } catch (e: Exception) { + null + } + } + + private fun getBasicWorkingDirectory(pid: Int): String? { + return try { + val cwdLink = File("/proc/$pid/cwd") + if (cwdLink.exists()) { + cwdLink.canonicalPath + } else null + } catch (e: Exception) { + null + } + } + + private fun getBasicOpenFiles(pid: Int): List { + return try { + val fdDir = File("/proc/$pid/fd") + if (!fdDir.exists()) return emptyList() + + fdDir.listFiles()?.mapNotNull { fdFile -> + try { + fdFile.canonicalPath + } catch (e: Exception) { + null + } + }?.distinct()?.take(15) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + private fun getBasicMemoryMaps(pid: Int): List { + return try { + val mapsFile = File("/proc/$pid/maps") + if (!mapsFile.exists()) return emptyList() + + mapsFile.readLines().take(8) + } catch (e: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/java/com/manalejandro/topcommand/service/RootService.kt b/app/src/main/java/com/manalejandro/topcommand/service/RootService.kt new file mode 100644 index 0000000..401810c --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/service/RootService.kt @@ -0,0 +1,661 @@ +package com.manalejandro.topcommand.service + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.DataOutputStream +import java.io.File +import java.io.InputStreamReader + +class RootService { + + private var isRootAvailable: Boolean? = null + private var suProcess: Process? = null + private var suOutput: DataOutputStream? = null + + suspend fun isRootAccessible(): Boolean = withContext(Dispatchers.IO) { + if (isRootAvailable != null) return@withContext isRootAvailable!! + + try { + val process = Runtime.getRuntime().exec("su") + val output = DataOutputStream(process.outputStream) + + // Test root access with a simple command + output.writeBytes("id\n") + output.flush() + output.writeBytes("exit\n") + output.flush() + + val exitCode = process.waitFor() + isRootAvailable = exitCode == 0 + + process.destroy() + return@withContext isRootAvailable!! + } catch (e: Exception) { + isRootAvailable = false + return@withContext false + } + } + + suspend fun requestRootAccess(): Boolean = withContext(Dispatchers.IO) { + try { + if (suProcess != null) { + // Root session already established + return@withContext true + } + + suProcess = Runtime.getRuntime().exec("su") + suOutput = DataOutputStream(suProcess!!.outputStream) + + // Test the connection + suOutput!!.writeBytes("echo 'root_access_granted'\n") + suOutput!!.flush() + + val reader = BufferedReader(InputStreamReader(suProcess!!.inputStream)) + val response = reader.readLine() + + return@withContext response?.contains("root_access_granted") == true + } catch (e: Exception) { + closeRootSession() + return@withContext false + } + } + + suspend fun executeRootCommand(command: String): String = withContext(Dispatchers.IO) { + try { + if (suProcess == null || suOutput == null) { + if (!requestRootAccess()) { + return@withContext "" + } + } + + suOutput!!.writeBytes("$command\n") + suOutput!!.flush() + + // Add a marker to know when the command output ends + val marker = "COMMAND_END_${System.currentTimeMillis()}" + suOutput!!.writeBytes("echo '$marker'\n") + suOutput!!.flush() + + val reader = BufferedReader(InputStreamReader(suProcess!!.inputStream)) + val output = StringBuilder() + var line: String? + + while (reader.readLine().also { line = it } != null) { + if (line == marker) break + output.append(line).append("\n") + } + + return@withContext output.toString() + } catch (e: Exception) { + closeRootSession() + return@withContext "" + } + } + + suspend fun getAllProcessesWithRoot(): List = withContext(Dispatchers.IO) { + val processes = mutableListOf() + + try { + // Get detailed process information using ps command with root + val psOutput = executeRootCommand("ps -A -o pid,ppid,user,comm,pcpu,pmem,vsz,rss,stat,tty,time,cmd") + + if (psOutput.isBlank()) { + // Fallback to basic ps command + val fallbackOutput = executeRootCommand("ps -A") + return@withContext parseBasicPsOutput(fallbackOutput) + } + + val lines = psOutput.trim().split("\n") + // Skip header line + for (i in 1 until lines.size) { + val line = lines[i].trim() + if (line.isEmpty()) continue + + try { + val processInfo = parseDetailedPsLine(line) + processInfo?.let { processes.add(it) } + } catch (e: Exception) { + // Skip malformed lines + continue + } + } + } catch (e: Exception) { + // Return empty list on error + } + + return@withContext processes.sortedByDescending { it.cpuUsage } + } + + private fun parseDetailedPsLine(line: String): ProcessRootInfo? { + try { + val parts = line.split(Regex("\\s+"), limit = 12) + if (parts.size < 11) return null + + val pid = parts[0].toIntOrNull() ?: return null + val ppid = parts[1].toIntOrNull() ?: 0 + val user = parts[2] + val comm = parts[3] + val pcpu = parts[4].toDoubleOrNull() ?: 0.0 + val pmem = parts[5].toDoubleOrNull() ?: 0.0 + val vsz = parts[6].toLongOrNull() ?: 0L + val rss = parts[7].toLongOrNull() ?: 0L + val stat = parts[8] + val tty = parts[9] + val time = parts[10] + val cmd = if (parts.size > 11) parts[11] else comm + + return ProcessRootInfo( + pid = pid, + parentPid = ppid, + user = user, + name = comm, + commandLine = cmd, + cpuUsage = pcpu, + memoryUsage = rss * 1024, // Convert KB to bytes + memoryPercentage = pmem, + virtualSize = vsz * 1024, // Convert KB to bytes + state = parseProcessState(stat), + terminal = tty, + cpuTime = time + ) + } catch (e: Exception) { + return null + } + } + + private fun parseBasicPsOutput(output: String): List { + val processes = mutableListOf() + val lines = output.trim().split("\n") + + // Skip header line + for (i in 1 until lines.size) { + val line = lines[i].trim() + if (line.isEmpty()) continue + + try { + val parts = line.split(Regex("\\s+")) + if (parts.size >= 2) { + val pid = parts[1].toIntOrNull() ?: continue + val name = parts.getOrNull(8) ?: "unknown" + + processes.add( + ProcessRootInfo( + pid = pid, + parentPid = 0, + user = parts.getOrNull(0) ?: "unknown", + name = name, + commandLine = name, + cpuUsage = 0.0, + memoryUsage = 0L, + memoryPercentage = 0.0, + virtualSize = 0L, + state = "Unknown", + terminal = parts.getOrNull(6) ?: "?", + cpuTime = parts.getOrNull(7) ?: "00:00:00" + ) + ) + } + } catch (e: Exception) { + continue + } + } + + return processes + } + + private fun parseProcessState(stat: String): String { + if (stat.isEmpty()) return "Unknown" + + return when (stat[0]) { + 'R' -> "Running" + 'S' -> "Sleeping" + 'D' -> "Waiting" + 'Z' -> "Zombie" + 'T' -> "Stopped" + 't' -> "Tracing" + 'W' -> "Paging" + 'X', 'x' -> "Dead" + 'K' -> "Wakekill" + 'P' -> "Parked" + else -> "Unknown ($stat)" + } + } + + suspend fun getSystemInfo(): SystemInfo = withContext(Dispatchers.IO) { + try { + val uptimeOutput = executeRootCommand("cat /proc/uptime") + val meminfoOutput = executeRootCommand("cat /proc/meminfo") + val cpuinfoOutput = executeRootCommand("cat /proc/cpuinfo") + val loadavgOutput = executeRootCommand("cat /proc/loadavg") + + return@withContext SystemInfo( + uptime = parseUptime(uptimeOutput), + loadAverage = parseLoadAverage(loadavgOutput), + memoryInfo = parseMemoryInfo(meminfoOutput), + cpuInfo = parseCpuInfo(cpuinfoOutput), + totalProcesses = getAllProcessesWithRoot().size + ) + } catch (e: Exception) { + return@withContext SystemInfo() + } + } + + private fun parseUptime(output: String): Long { + return try { + val parts = output.trim().split(" ") + (parts[0].toDouble() * 1000).toLong() + } catch (e: Exception) { + 0L + } + } + + private fun parseLoadAverage(output: String): Triple { + return try { + val parts = output.trim().split(" ") + Triple( + parts[0].toDouble(), + parts[1].toDouble(), + parts[2].toDouble() + ) + } catch (e: Exception) { + Triple(0.0, 0.0, 0.0) + } + } + + private fun parseMemoryInfo(output: String): MemoryInfo { + return try { + val lines = output.lines() + var total = 0L + var available = 0L + var free = 0L + var buffers = 0L + var cached = 0L + + lines.forEach { line -> + when { + line.startsWith("MemTotal:") -> total = extractMemoryValue(line) + line.startsWith("MemAvailable:") -> available = extractMemoryValue(line) + line.startsWith("MemFree:") -> free = extractMemoryValue(line) + line.startsWith("Buffers:") -> buffers = extractMemoryValue(line) + line.startsWith("Cached:") -> cached = extractMemoryValue(line) + } + } + + MemoryInfo( + total = total * 1024, + available = available * 1024, + free = free * 1024, + buffers = buffers * 1024, + cached = cached * 1024, + used = (total - available) * 1024 + ) + } catch (e: Exception) { + MemoryInfo() + } + } + + private fun extractMemoryValue(line: String): Long { + return try { + line.split(Regex("\\s+"))[1].toLong() + } catch (e: Exception) { + 0L + } + } + + private fun parseCpuInfo(output: String): String { + return try { + val lines = output.lines() + val modelLine = lines.find { it.startsWith("model name") } + modelLine?.split(":")?.get(1)?.trim() ?: "Unknown CPU" + } catch (e: Exception) { + "Unknown CPU" + } + } + + suspend fun getProcessDetailedInfo(pid: Int): ProcessDetailedInfo? = withContext(Dispatchers.IO) { + try { + val procDir = "/proc/$pid" + + // Check if process still exists + val statFile = File("$procDir/stat") + if (!statFile.exists()) return@withContext null + + val detailedInfo = ProcessDetailedInfo( + pid = pid, + parentPid = getParentPid(pid), + commandLine = getCommandLine(pid), + startTime = getStartTime(pid), + cpuTime = getCpuTime(pid), + virtualMemory = getVirtualMemory(pid), + residentMemory = getResidentMemory(pid), + sharedMemory = getSharedMemory(pid), + terminal = getTerminal(pid), + workingDirectory = getWorkingDirectory(pid), + openFiles = getOpenFiles(pid), + networkConnections = getNetworkConnections(pid), + memoryMaps = getMemoryMaps(pid), + environment = getEnvironment(pid), + limits = getLimits(pid) + ) + + return@withContext detailedInfo + } catch (e: Exception) { + return@withContext null + } + } + + private suspend fun getParentPid(pid: Int): Int? { + return try { + val statContent = executeRootCommand("cat /proc/$pid/stat") + if (statContent.isBlank()) return null + val parts = statContent.split(" ") + parts.getOrNull(3)?.toIntOrNull() + } catch (e: Exception) { + null + } + } + + private suspend fun getCommandLine(pid: Int): String { + return try { + val cmdline = executeRootCommand("cat /proc/$pid/cmdline 2>/dev/null") + if (cmdline.isBlank()) { + // Fallback to comm if cmdline is empty + executeRootCommand("cat /proc/$pid/comm 2>/dev/null").trim() + } else { + cmdline.replace("\u0000", " ").trim() + } + } catch (e: Exception) { + "" + } + } + + private suspend fun getStartTime(pid: Int): String? { + return try { + val statContent = executeRootCommand("cat /proc/$pid/stat 2>/dev/null") + if (statContent.isBlank()) return null + + val parts = statContent.split(" ") + val starttime = parts.getOrNull(21)?.toLongOrNull() ?: return null + + // Convert to readable time (simplified) + val uptimeContent = executeRootCommand("cat /proc/uptime 2>/dev/null") + val uptime = uptimeContent.split(" ").getOrNull(0)?.toDoubleOrNull() ?: return null + + val clockTicks = 100 // Assuming 100 Hz + val processAge = uptime - (starttime.toDouble() / clockTicks) + + "${String.format("%.2f", processAge)} seconds ago" + } catch (e: Exception) { + null + } + } + + private suspend fun getCpuTime(pid: Int): String? { + return try { + val statContent = executeRootCommand("cat /proc/$pid/stat 2>/dev/null") + if (statContent.isBlank()) return null + + val parts = statContent.split(" ") + val utime = parts.getOrNull(13)?.toLongOrNull() ?: 0L + val stime = parts.getOrNull(14)?.toLongOrNull() ?: 0L + val totalTime = utime + stime + + val clockTicks = 100 // Assuming 100 Hz + val seconds = totalTime / clockTicks + val minutes = seconds / 60 + val hours = minutes / 60 + + when { + hours > 0 -> "${hours}:${String.format("%02d", minutes % 60)}:${String.format("%02d", seconds % 60)}" + minutes > 0 -> "${minutes}:${String.format("%02d", seconds % 60)}" + else -> "${seconds}s" + } + } catch (e: Exception) { + null + } + } + + private suspend fun getVirtualMemory(pid: Int): Long? { + return try { + val statusContent = executeRootCommand("cat /proc/$pid/status 2>/dev/null") + if (statusContent.isBlank()) return null + + val vmSizeLine = statusContent.lines().find { it.startsWith("VmSize:") } + val vmSize = vmSizeLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: return null + vmSize * 1024 // Convert from KB to bytes + } catch (e: Exception) { + null + } + } + + private suspend fun getResidentMemory(pid: Int): Long? { + return try { + val statusContent = executeRootCommand("cat /proc/$pid/status 2>/dev/null") + if (statusContent.isBlank()) return null + + val vmRssLine = statusContent.lines().find { it.startsWith("VmRSS:") } + val vmRss = vmRssLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: return null + vmRss * 1024 // Convert from KB to bytes + } catch (e: Exception) { + null + } + } + + private suspend fun getSharedMemory(pid: Int): Long? { + return try { + val statusContent = executeRootCommand("cat /proc/$pid/status 2>/dev/null") + if (statusContent.isBlank()) return null + + val vmLibLine = statusContent.lines().find { it.startsWith("VmLib:") } + val vmLib = vmLibLine?.split("\\s+".toRegex())?.get(1)?.toLongOrNull() ?: return null + vmLib * 1024 // Convert from KB to bytes + } catch (e: Exception) { + null + } + } + + private suspend fun getTerminal(pid: Int): String? { + return try { + val statContent = executeRootCommand("cat /proc/$pid/stat 2>/dev/null") + if (statContent.isBlank()) return null + + val parts = statContent.split(" ") + val tty = parts.getOrNull(6)?.toIntOrNull() ?: return null + + if (tty == 0) "?" else tty.toString() + } catch (e: Exception) { + null + } + } + + private suspend fun getWorkingDirectory(pid: Int): String? { + return try { + val cwd = executeRootCommand("readlink /proc/$pid/cwd 2>/dev/null") + if (cwd.isBlank()) null else cwd.trim() + } catch (e: Exception) { + null + } + } + + private suspend fun getOpenFiles(pid: Int): List { + return try { + val lsofOutput = executeRootCommand("ls -la /proc/$pid/fd 2>/dev/null") + if (lsofOutput.isBlank()) return emptyList() + + lsofOutput.lines() + .drop(1) // Skip header + .mapNotNull { line -> + val parts = line.split("->") + if (parts.size >= 2) { + parts[1].trim() + } else null + } + .filter { it.isNotBlank() } + .distinct() + .take(20) // Limit to avoid too many files + } catch (e: Exception) { + emptyList() + } + } + + private suspend fun getNetworkConnections(pid: Int): List { + return try { + val connections = mutableListOf() + + // Check TCP connections + val tcpContent = executeRootCommand("cat /proc/net/tcp 2>/dev/null") + val tcp6Content = executeRootCommand("cat /proc/net/tcp6 2>/dev/null") + + // This is a simplified version - in real implementation you'd need to + // parse the network files and match inodes with the process fd + connections.addAll(parseNetworkConnections(tcpContent, "TCP")) + connections.addAll(parseNetworkConnections(tcp6Content, "TCP6")) + + connections.take(10) // Limit connections shown + } catch (e: Exception) { + emptyList() + } + } + + private fun parseNetworkConnections(content: String, protocol: String): List { + return try { + content.lines() + .drop(1) // Skip header + .take(5) // Limit for performance + .mapNotNull { line -> + val parts = line.trim().split("\\s+".toRegex()) + if (parts.size >= 4) { + val localAddr = parts.getOrNull(1) + val remoteAddr = parts.getOrNull(2) + val state = parts.getOrNull(3) + + if (localAddr != null && remoteAddr != null) { + "$protocol: $localAddr -> $remoteAddr [$state]" + } else null + } else null + } + } catch (e: Exception) { + emptyList() + } + } + + private suspend fun getMemoryMaps(pid: Int): List { + return try { + val mapsContent = executeRootCommand("cat /proc/$pid/maps 2>/dev/null") + if (mapsContent.isBlank()) return emptyList() + + mapsContent.lines() + .filter { it.isNotBlank() } + .take(10) // Limit to first 10 mappings + } catch (e: Exception) { + emptyList() + } + } + + private suspend fun getEnvironment(pid: Int): Map { + return try { + val environContent = executeRootCommand("cat /proc/$pid/environ 2>/dev/null") + if (environContent.isBlank()) return emptyMap() + + environContent.split("\u0000") + .mapNotNull { env -> + val parts = env.split("=", limit = 2) + if (parts.size == 2) { + parts[0] to parts[1] + } else null + } + .take(20) // Limit environment variables + .toMap() + } catch (e: Exception) { + emptyMap() + } + } + + private suspend fun getLimits(pid: Int): Map { + return try { + val limitsContent = executeRootCommand("cat /proc/$pid/limits 2>/dev/null") + if (limitsContent.isBlank()) return emptyMap() + + limitsContent.lines() + .drop(1) // Skip header + .mapNotNull { line -> + val parts = line.split("\\s+".toRegex()) + if (parts.size >= 3) { + val limit = parts[0] + val soft = parts[1] + val hard = parts[2] + limit to "$soft / $hard" + } else null + } + .take(10) + .toMap() + } catch (e: Exception) { + emptyMap() + } + } + + suspend fun closeRootSession() { + try { + suOutput?.writeBytes("exit\n") + suOutput?.flush() + suOutput?.close() + suProcess?.destroy() + } catch (e: Exception) { + // Ignore cleanup errors + } finally { + suOutput = null + suProcess = null + } + } +} + +data class ProcessRootInfo( + val pid: Int, + val parentPid: Int, + val user: String, + val name: String, + val commandLine: String, + val cpuUsage: Double, + val memoryUsage: Long, + val memoryPercentage: Double, + val virtualSize: Long, + val state: String, + val terminal: String, + val cpuTime: String +) + +data class SystemInfo( + val uptime: Long = 0L, + val loadAverage: Triple = Triple(0.0, 0.0, 0.0), + val memoryInfo: MemoryInfo = MemoryInfo(), + val cpuInfo: String = "Unknown", + val totalProcesses: Int = 0 +) + +data class MemoryInfo( + val total: Long = 0L, + val available: Long = 0L, + val free: Long = 0L, + val buffers: Long = 0L, + val cached: Long = 0L, + val used: Long = 0L +) + +data class ProcessDetailedInfo( + val pid: Int, + val parentPid: Int?, + val commandLine: String, + val startTime: String?, + val cpuTime: String?, + val virtualMemory: Long?, + val residentMemory: Long?, + val sharedMemory: Long?, + val terminal: String?, + val workingDirectory: String?, + val openFiles: List, + val networkConnections: List, + val memoryMaps: List, + val environment: Map, + val limits: Map +) diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessComponents.kt b/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessComponents.kt new file mode 100644 index 0000000..e479b7d --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessComponents.kt @@ -0,0 +1,281 @@ +package com.manalejandro.topcommand.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.manalejandro.topcommand.model.ProcessInfo +import com.manalejandro.topcommand.model.SortBy + +@Composable +fun ProcessItem( + process: ProcessInfo, + onClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = process.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "PID: ${process.pid} • User: ${process.user}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + ProcessStateChip(state = process.state) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + MetricCard( + label = "CPU", + value = "${String.format("%.1f", process.cpuUsage)}%", + color = getCpuColor(process.cpuUsage), + modifier = Modifier.weight(1f) + ) + + MetricCard( + label = "Memory", + value = formatMemory(process.memoryUsage), + color = getMemoryColor(process.memoryPercentage), + modifier = Modifier.weight(1f) + ) + + MetricCard( + label = "Threads", + value = process.threads.toString(), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Composable +fun ProcessStateChip( + state: String, + modifier: Modifier = Modifier +) { + val (backgroundColor, contentColor) = when (state) { + "Running" -> Pair(Color(0xFF4CAF50), Color.White) + "Sleeping" -> Pair(Color(0xFF2196F3), Color.White) + "Zombie" -> Pair(Color(0xFFF44336), Color.White) + "Stopped" -> Pair(Color(0xFFFF9800), Color.White) + else -> Pair(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant) + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = state, + style = MaterialTheme.typography.labelSmall, + color = contentColor, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +fun MetricCard( + label: String, + value: String, + color: Color, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Column( + modifier = Modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = color + ) + } + } +} + +@Composable +fun SortHeader( + sortBy: SortBy, + isAscending: Boolean, + onSortChange: (SortBy) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + SortButton( + text = "PID", + sortBy = SortBy.PID, + currentSort = sortBy, + isAscending = isAscending, + onClick = onSortChange, + modifier = Modifier.weight(1f) + ) + SortButton( + text = "Name", + sortBy = SortBy.NAME, + currentSort = sortBy, + isAscending = isAscending, + onClick = onSortChange, + modifier = Modifier.weight(1f) + ) + SortButton( + text = "CPU", + sortBy = SortBy.CPU, + currentSort = sortBy, + isAscending = isAscending, + onClick = onSortChange, + modifier = Modifier.weight(1f) + ) + SortButton( + text = "Memory", + sortBy = SortBy.MEMORY, + currentSort = sortBy, + isAscending = isAscending, + onClick = onSortChange, + modifier = Modifier.weight(1f) + ) + SortButton( + text = "User", + sortBy = SortBy.USER, + currentSort = sortBy, + isAscending = isAscending, + onClick = onSortChange, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +fun SortButton( + text: String, + sortBy: SortBy, + currentSort: SortBy, + isAscending: Boolean, + onClick: (SortBy) -> Unit, + modifier: Modifier = Modifier +) { + val isSelected = currentSort == sortBy + + Row( + modifier = modifier + .clickable { onClick(sortBy) } + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isSelected) { + Icon( + imageVector = if (isAscending) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isAscending) "Ascending" else "Descending", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +private fun getCpuColor(cpuUsage: Double): Color { + return when { + cpuUsage > 80 -> Color(0xFFF44336) // Red + cpuUsage > 50 -> Color(0xFFFF9800) // Orange + cpuUsage > 20 -> Color(0xFFFFEB3B) // Yellow + else -> Color(0xFF4CAF50) // Green + } +} + +private fun getMemoryColor(memoryPercentage: Double): Color { + return when { + memoryPercentage > 80 -> Color(0xFFF44336) // Red + memoryPercentage > 50 -> Color(0xFFFF9800) // Orange + memoryPercentage > 20 -> Color(0xFFFFEB3B) // Yellow + else -> Color(0xFF4CAF50) // Green + } +} + +private fun formatMemory(bytes: Long): String { + return when { + bytes >= 1_073_741_824 -> "${String.format("%.1f", bytes / 1_073_741_824.0)} GB" + bytes >= 1_048_576 -> "${String.format("%.1f", bytes / 1_048_576.0)} MB" + bytes >= 1024 -> "${String.format("%.1f", bytes / 1024.0)} KB" + else -> "$bytes B" + } +} diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessDetailDialog.kt b/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessDetailDialog.kt new file mode 100644 index 0000000..e0f90a4 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/components/ProcessDetailDialog.kt @@ -0,0 +1,516 @@ +package com.manalejandro.topcommand.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.manalejandro.topcommand.model.ProcessInfo +import com.manalejandro.topcommand.service.ProcessDetailedInfo +import kotlinx.coroutines.launch + +@Composable +fun ProcessDetailDialog( + process: ProcessInfo, + detailedInfo: ProcessDetailedInfo?, + onDismiss: () -> Unit, + onRefreshDetails: (Int) -> Unit, + isLoadingDetails: Boolean = false +) { + val scope = rememberCoroutineScope() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header + ProcessDetailHeader( + process = process, + onClose = onDismiss, + onRefresh = { + scope.launch { + onRefreshDetails(process.pid) + } + }, + isRefreshing = isLoadingDetails + ) + + // Content + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Basic Information + ProcessBasicInfoSection(process = process) + + // Performance Metrics + ProcessPerformanceSection(process = process) + + // Detailed Information (if available) + if (detailedInfo != null) { + ProcessAdvancedInfoSection(detailedInfo = detailedInfo) + + if (detailedInfo.commandLine.isNotBlank()) { + ProcessCommandSection(detailedInfo = detailedInfo) + } + + if (detailedInfo.openFiles.isNotEmpty()) { + ProcessFilesSection(detailedInfo = detailedInfo) + } + + if (detailedInfo.networkConnections.isNotEmpty()) { + ProcessNetworkSection(detailedInfo = detailedInfo) + } + + ProcessMemoryDetailsSection(detailedInfo = detailedInfo) + } else if (isLoadingDetails) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + CircularProgressIndicator() + Text( + text = "Loading detailed information...", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } else { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Detailed information not available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Enable root access for more details", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } + } + } + } +} + +@Composable +fun ProcessDetailHeader( + process: ProcessInfo, + onClose: () -> Unit, + onRefresh: () -> Unit, + isRefreshing: Boolean +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp, bottomStart = 0.dp, bottomEnd = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = process.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "PID: ${process.pid}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton( + onClick = onRefresh, + enabled = !isRefreshing + ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } else { + Icon( + imageVector = Icons.Default.Timer, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } +} + +@Composable +fun ProcessBasicInfoSection(process: ProcessInfo) { + DetailSection( + title = "Basic Information", + icon = Icons.Default.Info + ) { + DetailRow("Process ID", process.pid.toString()) + DetailRow("Name", process.name) + DetailRow("User", process.user) + DetailRow("State", process.state) + DetailRow("Priority", process.priority.toString()) + DetailRow("Threads", process.threads.toString()) + } +} + +@Composable +fun ProcessPerformanceSection(process: ProcessInfo) { + DetailSection( + title = "Performance", + icon = Icons.Default.Speed + ) { + DetailRow( + "CPU Usage", + "${String.format("%.1f", process.cpuUsage)}%", + valueColor = getCpuColor(process.cpuUsage) + ) + DetailRow( + "Memory Usage", + formatMemory(process.memoryUsage), + valueColor = getMemoryColor(process.memoryPercentage) + ) + DetailRow( + "Memory %", + "${String.format("%.2f", process.memoryPercentage)}%", + valueColor = getMemoryColor(process.memoryPercentage) + ) + } +} + +@Composable +fun ProcessAdvancedInfoSection(detailedInfo: ProcessDetailedInfo) { + DetailSection( + title = "Advanced Information", + icon = Icons.Default.Computer + ) { + detailedInfo.parentPid?.let { + DetailRow("Parent PID", it.toString()) + } + + detailedInfo.startTime?.let { + DetailRow("Start Time", it) + } + + detailedInfo.cpuTime?.let { + DetailRow("CPU Time", it) + } + + detailedInfo.virtualMemory?.let { + DetailRow("Virtual Memory", formatMemory(it)) + } + + detailedInfo.residentMemory?.let { + DetailRow("Resident Memory", formatMemory(it)) + } + + detailedInfo.sharedMemory?.let { + DetailRow("Shared Memory", formatMemory(it)) + } + + detailedInfo.terminal?.let { + DetailRow("Terminal", it) + } + + detailedInfo.workingDirectory?.let { + DetailRow("Working Directory", it) + } + } +} + +@Composable +fun ProcessCommandSection(detailedInfo: ProcessDetailedInfo) { + DetailSection( + title = "Command Line", + icon = Icons.Default.Computer + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Text( + text = detailedInfo.commandLine, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +fun ProcessFilesSection(detailedInfo: ProcessDetailedInfo) { + DetailSection( + title = "Open Files (${detailedInfo.openFiles.size})", + icon = Icons.Default.Info + ) { + detailedInfo.openFiles.take(10).forEach { file -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = file, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + if (detailedInfo.openFiles.size > 10) { + Text( + text = "... and ${detailedInfo.openFiles.size - 10} more files", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +fun ProcessNetworkSection(detailedInfo: ProcessDetailedInfo) { + DetailSection( + title = "Network Connections (${detailedInfo.networkConnections.size})", + icon = Icons.Default.Computer + ) { + detailedInfo.networkConnections.forEach { connection -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = connection, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + } +} + +@Composable +fun ProcessMemoryDetailsSection(detailedInfo: ProcessDetailedInfo) { + DetailSection( + title = "Memory Details", + icon = Icons.Default.Memory + ) { + detailedInfo.virtualMemory?.let { + DetailRow("Virtual Memory", formatMemory(it)) + } + + detailedInfo.residentMemory?.let { + DetailRow("Resident Set Size", formatMemory(it)) + } + + detailedInfo.sharedMemory?.let { + DetailRow("Shared Memory", formatMemory(it)) + } + + detailedInfo.memoryMaps?.let { maps -> + Text( + text = "Memory Maps (${maps.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 8.dp) + ) + + maps.take(5).forEach { map -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = map, + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace + ) + } + } + if (maps.size > 5) { + Text( + text = "... and ${maps.size - 5} more mappings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun DetailSection( + title: String, + icon: ImageVector, + content: @Composable ColumnScope.() -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + content() + } + } +} + +@Composable +fun DetailRow( + label: String, + value: String, + valueColor: Color = MaterialTheme.colorScheme.onSurface +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = valueColor + ) + } +} + +private fun getCpuColor(cpuUsage: Double): Color { + return when { + cpuUsage > 80 -> Color(0xFFF44336) // Red + cpuUsage > 50 -> Color(0xFFFF9800) // Orange + cpuUsage > 20 -> Color(0xFFFFEB3B) // Yellow + else -> Color(0xFF4CAF50) // Green + } +} + +private fun getMemoryColor(memoryPercentage: Double): Color { + return when { + memoryPercentage > 80 -> Color(0xFFF44336) // Red + memoryPercentage > 50 -> Color(0xFFFF9800) // Orange + memoryPercentage > 20 -> Color(0xFFFFEB3B) // Yellow + else -> Color(0xFF4CAF50) // Green + } +} + +private fun formatMemory(bytes: Long): String { + return when { + bytes >= 1_073_741_824 -> "${String.format("%.1f", bytes / 1_073_741_824.0)} GB" + bytes >= 1_048_576 -> "${String.format("%.1f", bytes / 1_048_576.0)} MB" + bytes >= 1024 -> "${String.format("%.1f", bytes / 1024.0)} KB" + else -> "$bytes B" + } +} diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/components/RootComponents.kt b/app/src/main/java/com/manalejandro/topcommand/ui/components/RootComponents.kt new file mode 100644 index 0000000..bf5c530 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/components/RootComponents.kt @@ -0,0 +1,311 @@ +package com.manalejandro.topcommand.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.manalejandro.topcommand.service.SystemInfo +import java.text.SimpleDateFormat +import java.util.* +import kotlin.time.Duration.Companion.milliseconds + +@Composable +fun RootAccessCard( + isRootAvailable: Boolean?, + isRootEnabled: Boolean, + onRequestRoot: () -> Unit, + onDisableRoot: () -> Unit, + rootError: String?, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = when { + rootError != null -> MaterialTheme.colorScheme.errorContainer + isRootEnabled -> Color(0xFF1B5E20) + isRootAvailable == true -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + } + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = if (rootError != null) Icons.Default.Warning else Icons.Default.Security, + contentDescription = "Root Status", + tint = when { + rootError != null -> MaterialTheme.colorScheme.onErrorContainer + isRootEnabled -> Color.White + isRootAvailable == true -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + Text( + text = when { + isRootEnabled -> "Root Access Active" + isRootAvailable == true -> "Root Access Available" + isRootAvailable == false -> "Root Access Not Available" + else -> "Checking Root Access..." + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = when { + rootError != null -> MaterialTheme.colorScheme.onErrorContainer + isRootEnabled -> Color.White + isRootAvailable == true -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + + Text( + text = when { + rootError != null -> rootError + isRootEnabled -> "All system processes are visible with detailed information" + isRootAvailable == true -> "Enable root access to see all system processes and detailed information" + isRootAvailable == false -> "Device is not rooted or root access is denied. Only user processes will be shown" + else -> "Detecting root capabilities..." + }, + style = MaterialTheme.typography.bodyMedium, + color = when { + rootError != null -> MaterialTheme.colorScheme.onErrorContainer + isRootEnabled -> Color.White.copy(alpha = 0.9f) + isRootAvailable == true -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (isRootAvailable == true && !isRootEnabled) { + Button( + onClick = onRequestRoot, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("Enable Root Access") + } + } + + if (isRootEnabled) { + OutlinedButton( + onClick = onDisableRoot, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.White + ) + ) { + Text("Disable Root") + } + } + } + } + } +} + +@Composable +fun SystemInfoCard( + systemInfo: SystemInfo?, + modifier: Modifier = Modifier +) { + if (systemInfo == null) return + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Computer, + contentDescription = "System Info", + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = "System Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SystemMetricCard( + icon = Icons.Default.Speed, + label = "Load Avg", + value = "${String.format("%.2f", systemInfo.loadAverage.first)} " + + "${String.format("%.2f", systemInfo.loadAverage.second)} " + + "${String.format("%.2f", systemInfo.loadAverage.third)}", + modifier = Modifier.weight(1f) + ) + + SystemMetricCard( + icon = Icons.Default.Memory, + label = "Memory", + value = formatSystemMemory(systemInfo.memoryInfo.used, systemInfo.memoryInfo.total), + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SystemMetricCard( + icon = Icons.Default.Computer, + label = "Uptime", + value = formatUptime(systemInfo.uptime), + modifier = Modifier.weight(1f) + ) + + SystemMetricCard( + icon = Icons.Default.Security, + label = "Processes", + value = systemInfo.totalProcesses.toString(), + modifier = Modifier.weight(1f) + ) + } + + if (systemInfo.cpuInfo != "Unknown") { + Text( + text = "CPU: ${systemInfo.cpuInfo}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + ) + } + } + } +} + +@Composable +fun SystemMetricCard( + icon: ImageVector, + label: String, + value: String, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } +} + +@Composable +fun RootErrorDialog( + error: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text("Root Access Error") }, + text = { Text(error) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("OK") + } + } + ) +} + +private fun formatSystemMemory(used: Long, total: Long): String { + if (total == 0L) return "N/A" + + val usedMB = used / (1024 * 1024) + val totalMB = total / (1024 * 1024) + val percentage = (used.toDouble() / total * 100).toInt() + + return when { + totalMB >= 1024 -> { + val usedGB = String.format("%.1f", usedMB / 1024.0) + val totalGB = String.format("%.1f", totalMB / 1024.0) + "$usedGB/$totalGB GB ($percentage%)" + } + else -> "$usedMB/$totalMB MB ($percentage%)" + } +} + +private fun formatUptime(uptimeMs: Long): String { + if (uptimeMs == 0L) return "Unknown" + + val duration = uptimeMs.milliseconds + val days = duration.inWholeDays + val hours = duration.inWholeHours % 24 + val minutes = duration.inWholeMinutes % 60 + + return when { + days > 0 -> "${days}d ${hours}h ${minutes}m" + hours > 0 -> "${hours}h ${minutes}m" + else -> "${minutes}m" + } +} diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/screen/ProcessMonitorScreen.kt b/app/src/main/java/com/manalejandro/topcommand/ui/screen/ProcessMonitorScreen.kt new file mode 100644 index 0000000..092b288 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/screen/ProcessMonitorScreen.kt @@ -0,0 +1,366 @@ +package com.manalejandro.topcommand.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.manalejandro.topcommand.model.ProcessInfo +import com.manalejandro.topcommand.ui.components.ProcessItem +import com.manalejandro.topcommand.ui.components.SortHeader +import com.manalejandro.topcommand.ui.components.RootAccessCard +import com.manalejandro.topcommand.ui.components.SystemInfoCard +import com.manalejandro.topcommand.ui.components.RootErrorDialog +import com.manalejandro.topcommand.ui.components.ProcessDetailDialog +import com.manalejandro.topcommand.viewmodel.ProcessViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProcessMonitorScreen( + viewModel: ProcessViewModel = viewModel() +) { + val processes by viewModel.filteredProcesses.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + val sortBy by viewModel.sortBy.collectAsState() + val isAscending by viewModel.isAscending.collectAsState() + val isAutoRefresh by viewModel.isAutoRefresh.collectAsState() + + // Root access states + val isRootAvailable by viewModel.isRootAvailable.collectAsState() + val isRootEnabled by viewModel.isRootEnabled.collectAsState() + val systemInfo by viewModel.systemInfo.collectAsState() + val rootError by viewModel.rootError.collectAsState() + + // Process details states + val showProcessDetails by viewModel.showProcessDetails.collectAsState() + val selectedProcessDetails by viewModel.selectedProcessDetails.collectAsState() + val isLoadingDetails by viewModel.isLoadingDetails.collectAsState() + val currentSelectedProcess = remember { mutableStateOf(null) } + + var showSettings by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Top Command", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + if (isRootEnabled) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = "Root Active", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + Text( + text = if (isRootEnabled) + "${processes.size} processes (Root)" + else + "${processes.size} processes (User)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + IconButton(onClick = { viewModel.loadProcesses() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh" + ) + } + IconButton(onClick = { showSettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Root access card + RootAccessCard( + isRootAvailable = isRootAvailable, + isRootEnabled = isRootEnabled, + onRequestRoot = { viewModel.requestRootAccess() }, + onDisableRoot = { viewModel.disableRootAccess() }, + rootError = rootError + ) + + // System info card (only shown when root is enabled) + if (isRootEnabled) { + SystemInfoCard(systemInfo = systemInfo) + } + + // Search bar + SearchBar( + query = searchQuery, + onQueryChange = viewModel::updateSearchQuery, + modifier = Modifier.padding(16.dp) + ) + + // Sort header + SortHeader( + sortBy = sortBy, + isAscending = isAscending, + onSortChange = viewModel::updateSortBy + ) + + // Status indicators + StatusIndicators( + isLoading = isLoading, + isAutoRefresh = isAutoRefresh, + processCount = processes.size, + isRootEnabled = isRootEnabled, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Process list + if (isLoading && processes.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = if (isRootEnabled) + "Loading all system processes..." + else + "Loading user processes..." + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + items( + items = processes, + key = { it.pid } + ) { process -> + ProcessItem( + process = process, + onClick = { + currentSelectedProcess.value = process + viewModel.showProcessDetails(process) + } + ) + } + } + } + } + + // Settings dialog + if (showSettings) { + SettingsDialog( + viewModel = viewModel, + onDismiss = { showSettings = false } + ) + } + + // Root error dialog + rootError?.let { error -> + RootErrorDialog( + error = error, + onDismiss = { viewModel.clearRootError() } + ) + } + + // Process detail dialog + if (showProcessDetails && currentSelectedProcess.value != null) { + ProcessDetailDialog( + process = currentSelectedProcess.value!!, + detailedInfo = selectedProcessDetails, + onDismiss = { viewModel.hideProcessDetails() }, + onRefreshDetails = { pid -> viewModel.loadProcessDetails(pid) }, + isLoadingDetails = isLoadingDetails + ) + } + } +} + +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + placeholder = { Text("Search processes...") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear" + ) + } + } + }, + singleLine = true + ) +} + +@Composable +fun StatusIndicators( + isLoading: Boolean, + isAutoRefresh: Boolean, + processCount: Int, + isRootEnabled: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isLoading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Text( + text = "Updating...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + + StatusChip( + text = if (isAutoRefresh) "Auto-refresh ON" else "Auto-refresh OFF", + isActive = isAutoRefresh + ) + } + + Text( + text = "$processCount processes found", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun StatusChip( + text: String, + isActive: Boolean, + modifier: Modifier = Modifier +) { + AssistChip( + onClick = { /* Handle click if needed */ }, + label = { Text(text, style = MaterialTheme.typography.labelSmall) }, + modifier = modifier, + colors = AssistChipDefaults.assistChipColors( + containerColor = if (isActive) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + labelColor = if (isActive) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + ) +} + +@Composable +fun SettingsDialog( + viewModel: ProcessViewModel, + onDismiss: () -> Unit +) { + val refreshInterval by viewModel.refreshInterval.collectAsState() + val isAutoRefresh by viewModel.isAutoRefresh.collectAsState() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Settings") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Auto-refresh") + Switch( + checked = isAutoRefresh, + onCheckedChange = { viewModel.toggleAutoRefresh() } + ) + } + + if (isAutoRefresh) { + Text("Refresh interval: ${refreshInterval / 1000}s") + Slider( + value = refreshInterval.toFloat(), + onValueChange = { viewModel.updateRefreshInterval(it.toLong()) }, + valueRange = 1000f..10000f, + steps = 8 + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + } + ) +} diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/theme/Color.kt b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Color.kt new file mode 100644 index 0000000..6cd1566 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Color.kt @@ -0,0 +1,28 @@ +package com.manalejandro.topcommand.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors - Terminal/Console inspired +val Primary80 = Color(0xFF00E676) // Bright green +val PrimaryContainer80 = Color(0xFF1B5E20) // Dark green +val Secondary80 = Color(0xFF81C784) // Light green +val SecondaryContainer80 = Color(0xFF2E7D32) // Medium green + +val Primary40 = Color(0xFF4CAF50) // Green +val PrimaryContainer40 = Color(0xFFC8E6C9) // Very light green +val Secondary40 = Color(0xFF388E3C) // Dark green +val SecondaryContainer40 = Color(0xFFE8F5E8) // Very light green + +// System colors +val Surface = Color(0xFF121212) // Dark surface +val SurfaceVariant = Color(0xFF1E1E1E) // Slightly lighter dark +val OnSurface = Color(0xFFE0E0E0) // Light text +val OnSurfaceVariant = Color(0xFFB0B0B0) // Medium light text + +// Status colors +val CpuHigh = Color(0xFFF44336) // Red +val CpuMedium = Color(0xFFFF9800) // Orange +val CpuLow = Color(0xFF4CAF50) // Green +val MemoryHigh = Color(0xFFE91E63) // Pink +val MemoryMedium = Color(0xFF9C27B0) // Purple +val MemoryLow = Color(0xFF2196F3) // Blue diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/theme/Theme.kt b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Theme.kt new file mode 100644 index 0000000..f2a80d9 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Theme.kt @@ -0,0 +1,84 @@ +package com.manalejandro.topcommand.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Primary80, + onPrimary = Color.Black, + primaryContainer = PrimaryContainer80, + onPrimaryContainer = Primary80, + secondary = Secondary80, + onSecondary = Color.Black, + secondaryContainer = SecondaryContainer80, + onSecondaryContainer = Secondary80, + surface = Surface, + onSurface = OnSurface, + surfaceVariant = SurfaceVariant, + onSurfaceVariant = OnSurfaceVariant, + background = Color(0xFF0F0F0F), + onBackground = OnSurface +) + +private val LightColorScheme = lightColorScheme( + primary = Primary40, + onPrimary = Color.White, + primaryContainer = PrimaryContainer40, + onPrimaryContainer = Primary40, + secondary = Secondary40, + onSecondary = Color.White, + secondaryContainer = SecondaryContainer40, + onSecondaryContainer = Secondary40, + surface = Color.White, + onSurface = Color.Black, + surfaceVariant = Color(0xFFF5F5F5), + onSurfaceVariant = Color(0xFF666666), + background = Color(0xFFFFFBFE), + onBackground = Color(0xFF1C1B1F) +) + +@Composable +fun TopCommandTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, // Disabled to use our custom theme + 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 + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/topcommand/ui/theme/Type.kt b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Type.kt new file mode 100644 index 0000000..f8dd6f0 --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.manalejandro.topcommand.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/topcommand/viewmodel/ProcessViewModel.kt b/app/src/main/java/com/manalejandro/topcommand/viewmodel/ProcessViewModel.kt new file mode 100644 index 0000000..6fcd9ce --- /dev/null +++ b/app/src/main/java/com/manalejandro/topcommand/viewmodel/ProcessViewModel.kt @@ -0,0 +1,269 @@ +package com.manalejandro.topcommand.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.topcommand.model.ProcessInfo +import com.manalejandro.topcommand.model.SortBy +import com.manalejandro.topcommand.service.ProcessMonitorService +import com.manalejandro.topcommand.service.RootService +import com.manalejandro.topcommand.service.ProcessRootInfo +import com.manalejandro.topcommand.service.SystemInfo +import com.manalejandro.topcommand.service.ProcessDetailedInfo +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ProcessViewModel : ViewModel() { + + private val processService = ProcessMonitorService() + private val rootService = RootService() + + private val _processes = MutableStateFlow>(emptyList()) + val processes: StateFlow> = _processes.asStateFlow() + + private val _filteredProcesses = MutableStateFlow>(emptyList()) + val filteredProcesses: StateFlow> = _filteredProcesses.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _sortBy = MutableStateFlow(SortBy.CPU) + val sortBy: StateFlow = _sortBy.asStateFlow() + + private val _isAscending = MutableStateFlow(false) + val isAscending: StateFlow = _isAscending.asStateFlow() + + private val _refreshInterval = MutableStateFlow(2000L) + val refreshInterval: StateFlow = _refreshInterval.asStateFlow() + + private val _isAutoRefresh = MutableStateFlow(true) + val isAutoRefresh: StateFlow = _isAutoRefresh.asStateFlow() + + // Root access states + private val _isRootAvailable = MutableStateFlow(null) + val isRootAvailable: StateFlow = _isRootAvailable.asStateFlow() + + private val _isRootEnabled = MutableStateFlow(false) + val isRootEnabled: StateFlow = _isRootEnabled.asStateFlow() + + private val _systemInfo = MutableStateFlow(null) + val systemInfo: StateFlow = _systemInfo.asStateFlow() + + private val _rootError = MutableStateFlow(null) + val rootError: StateFlow = _rootError.asStateFlow() + + // Process details states + private val _selectedProcessDetails = MutableStateFlow(null) + val selectedProcessDetails: StateFlow = _selectedProcessDetails.asStateFlow() + + private val _isLoadingDetails = MutableStateFlow(false) + val isLoadingDetails: StateFlow = _isLoadingDetails.asStateFlow() + + private val _showProcessDetails = MutableStateFlow(false) + val showProcessDetails: StateFlow = _showProcessDetails.asStateFlow() + + init { + checkRootAvailability() + startAutoRefresh() + } + + private fun checkRootAvailability() { + viewModelScope.launch { + try { + val isAvailable = rootService.isRootAccessible() + _isRootAvailable.value = isAvailable + } catch (e: Exception) { + _isRootAvailable.value = false + } + } + } + + fun requestRootAccess() { + viewModelScope.launch { + _isLoading.value = true + _rootError.value = null + + try { + val success = rootService.requestRootAccess() + if (success) { + _isRootEnabled.value = true + loadSystemInfo() + loadProcesses() + } else { + _rootError.value = "Root access denied or not available" + } + } catch (e: Exception) { + _rootError.value = "Error requesting root access: ${e.message}" + } finally { + _isLoading.value = false + } + } + } + + fun disableRootAccess() { + viewModelScope.launch { + rootService.closeRootSession() + _isRootEnabled.value = false + _systemInfo.value = null + loadProcesses() // Reload with non-root method + } + } + + private fun loadSystemInfo() { + if (!_isRootEnabled.value) return + + viewModelScope.launch { + try { + val info = rootService.getSystemInfo() + _systemInfo.value = info + } catch (e: Exception) { + // System info is optional, don't show error + } + } + } + + fun loadProcesses() { + viewModelScope.launch { + _isLoading.value = true + try { + if (_isRootEnabled.value) { + loadRootProcesses() + } else { + loadNormalProcesses() + } + filterAndSortProcesses() + } catch (e: Exception) { + _rootError.value = "Error loading processes: ${e.message}" + } finally { + _isLoading.value = false + } + } + } + + private suspend fun loadRootProcesses() { + val rootProcesses = rootService.getAllProcessesWithRoot() + val processes = rootProcesses.map { rootProcess -> + ProcessInfo( + pid = rootProcess.pid, + name = rootProcess.name, + cpuUsage = rootProcess.cpuUsage, + memoryUsage = rootProcess.memoryUsage, + memoryPercentage = rootProcess.memoryPercentage, + user = rootProcess.user, + state = rootProcess.state, + priority = 0, // Not available from ps command + threads = 1 // Not available from ps command + ) + } + _processes.value = processes + } + + private suspend fun loadNormalProcesses() { + val processList = processService.getProcessList() + _processes.value = processList + } + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + filterAndSortProcesses() + } + + fun updateSortBy(sortBy: SortBy) { + if (_sortBy.value == sortBy) { + _isAscending.value = !_isAscending.value + } else { + _sortBy.value = sortBy + _isAscending.value = false + } + filterAndSortProcesses() + } + + fun toggleAutoRefresh() { + _isAutoRefresh.value = !_isAutoRefresh.value + if (_isAutoRefresh.value) { + startAutoRefresh() + } + } + + fun updateRefreshInterval(intervalMs: Long) { + _refreshInterval.value = intervalMs + } + + private fun startAutoRefresh() { + viewModelScope.launch { + while (_isAutoRefresh.value) { + loadProcesses() + delay(_refreshInterval.value) + } + } + } + + private fun filterAndSortProcesses() { + val query = _searchQuery.value.lowercase() + val filtered = if (query.isEmpty()) { + _processes.value + } else { + _processes.value.filter { process -> + process.name.lowercase().contains(query) || + process.pid.toString().contains(query) || + process.user.lowercase().contains(query) + } + } + + val sorted = when (_sortBy.value) { + SortBy.PID -> filtered.sortedBy { it.pid } + SortBy.NAME -> filtered.sortedBy { it.name.lowercase() } + SortBy.CPU -> filtered.sortedBy { it.cpuUsage } + SortBy.MEMORY -> filtered.sortedBy { it.memoryUsage } + SortBy.USER -> filtered.sortedBy { it.user.lowercase() } + } + + _filteredProcesses.value = if (_isAscending.value) sorted else sorted.reversed() + } + + fun clearRootError() { + _rootError.value = null + } + + fun showProcessDetails(process: ProcessInfo) { + _showProcessDetails.value = true + loadProcessDetails(process.pid) + } + + fun hideProcessDetails() { + _showProcessDetails.value = false + _selectedProcessDetails.value = null + } + + fun loadProcessDetails(pid: Int) { + viewModelScope.launch { + _isLoadingDetails.value = true + try { + if (_isRootEnabled.value) { + val details = rootService.getProcessDetailedInfo(pid) + _selectedProcessDetails.value = details + } else { + // For non-root, we can still show basic details from /proc + val details = processService.getProcessBasicDetails(pid) + _selectedProcessDetails.value = details + } + } catch (e: Exception) { + _rootError.value = "Error loading process details: ${e.message}" + } finally { + _isLoadingDetails.value = false + } + } + } + + override fun onCleared() { + super.onCleared() + viewModelScope.launch { + rootService.closeRootSession() + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..efe2ea1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b9b6ff7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c52506e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..83749be Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4e28a19 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..17ab583 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..3f25650 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f9146da Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2eb2135 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..53be990 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..fa39f9a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6a0d4e2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7d38b5a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..5216ab0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..78d1cdd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..ab57608 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #02B342 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c6daf5e --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Top Command + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6c21ca8 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +