Move debug screen to its own file, layout improvements, copy json (#39994)

**Related issue:** Resolves #38034
This commit is contained in:
Dante Catalfamo
2026-02-20 12:06:05 -05:00
committed by GitHub
parent 0130848f6e
commit 014f871983
9 changed files with 538 additions and 156 deletions
@@ -45,6 +45,18 @@ class AgentApplication : Application() {
super.onCreate()
Log.i(TAG, "Fleet agent process started")
FleetLog.initialize(this)
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
FleetLog.e("fleet-crash", "Uncaught exception on thread ${thread.name}", throwable)
if (defaultHandler != null) {
defaultHandler.uncaughtException(thread, throwable)
} else {
android.os.Process.killProcess(android.os.Process.myPid())
}
}
// Initialize dependencies
ApiClient.initialize(this)
certificateOrchestrator = CertificateOrchestrator()
@@ -86,7 +98,7 @@ class AgentApplication : Application() {
Log.d(TAG, "MDM enrollment credentials not available")
}
} catch (e: Exception) {
Log.e(TAG, "Error refreshing enrollment credentials", e)
FleetLog.e(TAG, "Error refreshing enrollment credentials", e)
}
}
}
@@ -101,7 +101,7 @@ object ApiClient : CertificateApiClient {
return try {
KeystoreManager.decrypt(encrypted)
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt API key", e)
FleetLog.e(TAG, "Failed to decrypt API key", e)
null
}
}
@@ -171,7 +171,7 @@ object ApiClient : CertificateApiClient {
?: "HTTP $responseCode"
}
Log.d(TAG, "server response from $method $endpoint ($responseCode): $response")
Log.d(TAG, "server response from $method $endpoint ($responseCode)")
if (responseCode in 200..299) {
val parsed = json.decodeFromString(string = response, deserializer = responseSerializer)
@@ -287,7 +287,7 @@ object ApiClient : CertificateApiClient {
Result.success(CertificateTemplateResult(template, scepUrl))
},
onFailure = { throwable ->
Log.e(TAG, "failed to get certificate template $certificateId")
FleetLog.e(TAG, "failed to get certificate template $certificateId")
Result.failure(throwable)
},
)
@@ -318,7 +318,7 @@ object ApiClient : CertificateApiClient {
).fold(
onSuccess = { response ->
if (response.error != null) {
Log.e(TAG, "failed to update certificate status $certificateId: ${response.error}")
FleetLog.e(TAG, "failed to update certificate status $certificateId: ${response.error}")
Result.failure(Exception(response.error))
} else {
Log.i(TAG, "successfully updated certificate status for $certificateId to $status")
@@ -326,7 +326,7 @@ object ApiClient : CertificateApiClient {
}
},
onFailure = { throwable ->
Log.e(TAG, "failed to update certificate status $certificateId: ${throwable.message}")
FleetLog.e(TAG, "failed to update certificate status $certificateId: ${throwable.message}")
Result.failure(throwable)
},
)
@@ -371,7 +371,7 @@ object ApiClient : CertificateApiClient {
Result.success(response.orbitNodeKey)
},
onFailure = { error ->
Log.e(TAG, "Auto-enrollment failed: ${error.message}")
FleetLog.e(TAG, "Auto-enrollment failed: ${error.message}")
Result.failure(error)
},
)
@@ -63,7 +63,7 @@ class CertificateEnrollmentWorker(context: Context, workerParams: WorkerParamete
is CleanupResult.AlreadyRemoved ->
Log.d(TAG, "Certificate $certId already removed (alias: ${result.alias})")
is CleanupResult.Failure ->
Log.e(TAG, "Failed to cleanup certificate $certId: ${result.reason}", result.exception)
FleetLog.e(TAG, "Failed to cleanup certificate $certId: ${result.reason}", result.exception)
}
}
@@ -100,7 +100,7 @@ class CertificateEnrollmentWorker(context: Context, workerParams: WorkerParamete
// Treat as handled - no retry needed
}
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
Log.e(TAG, "Certificate $certificateId enrollment failed: ${result.reason}", result.exception)
FleetLog.e(TAG, "Certificate $certificateId enrollment failed: ${result.reason}", result.exception)
if (result.isRetryable) {
hasTransientFailure = true
} else {
@@ -140,7 +140,7 @@ class CertificateEnrollmentWorker(context: Context, workerParams: WorkerParamete
}
}
} catch (e: Exception) {
Log.e(TAG, "Unexpected error in certificate enrollment", e)
FleetLog.e(TAG, "Unexpected error in certificate enrollment", e)
Result.failure()
}
}
@@ -131,7 +131,7 @@ class CertificateOrchestrator(
json.decodeFromString<CertificateStateMap>(jsonString)
} catch (e: Exception) {
Log.e(TAG, "Failed to read installed certificates from DataStore: ${e.message}", e)
FleetLog.e(TAG, "Failed to read installed certificates from DataStore: ${e.message}", e)
emptyMap()
}
}
@@ -207,7 +207,7 @@ class CertificateOrchestrator(
Log.d(TAG, "Stored certificate mapping: $certificateId${certInstallInfo.alias} (total: ${updatedMap.size})")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to store certificate installation: ${e.message}", e)
FleetLog.e(TAG, "Failed to store certificate installation: ${e.message}", e)
// Non-fatal error - enrollment was successful, just tracking failed
}
}
@@ -247,7 +247,7 @@ class CertificateOrchestrator(
Log.d(TAG, "Removed certificate mapping for ID $certificateId (remaining: ${updatedMap.size})")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to remove certificate installation info: ${e.message}", e)
FleetLog.e(TAG, "Failed to remove certificate installation info: ${e.message}", e)
// Non-fatal error - cleanup was attempted
}
}
@@ -376,7 +376,7 @@ class CertificateOrchestrator(
if (reportResult.isSuccess) {
markCertificateRemoved(context, certId, certState.alias, hostCert.uuid)
} else {
Log.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
FleetLog.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
// Mark as unreported so retry logic will handle it
markCertificateUnreported(context, certId, certState.alias, hostCert.uuid, isInstall = false)
}
@@ -402,7 +402,7 @@ class CertificateOrchestrator(
if (reportResult.isSuccess) {
markCertificateRemoved(context, certId, certState.alias, hostCert.uuid)
} else {
Log.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
FleetLog.e(TAG, "Failed to report removal status for ID $certId: ${reportResult.exceptionOrNull()?.message}")
// Keep as unreported with new uuid so retry logic will handle it
markCertificateUnreported(context, certId, certState.alias, hostCert.uuid, isInstall = false)
}
@@ -424,7 +424,7 @@ class CertificateOrchestrator(
status = UpdateCertificateStatusStatus.VERIFIED,
operationType = UpdateCertificateStatusOperation.REMOVE,
).onFailure { error ->
Log.e(TAG, "Failed to report removal status for ID $certId: ${error.message}", error)
FleetLog.e(TAG, "Failed to report removal status for ID $certId: ${error.message}", error)
}
markCertificateRemoved(context, certId, alias, hostCert.uuid)
results[certId] = CleanupResult.Success(alias)
@@ -494,10 +494,10 @@ class CertificateOrchestrator(
operationType = UpdateCertificateStatusOperation.REMOVE,
detail = errorDetail,
).onFailure { error ->
Log.e(TAG, "Failed to report removal failure for ID $certificateId: ${error.message}", error)
FleetLog.e(TAG, "Failed to report removal failure for ID $certificateId: ${error.message}", error)
}
Log.e(TAG, "Failed to remove certificate ID $certificateId (alias: '$alias')")
FleetLog.e(TAG, "Failed to remove certificate ID $certificateId (alias: '$alias')")
CleanupResult.Failure(
reason = errorDetail,
exception = null,
@@ -691,7 +691,7 @@ class CertificateOrchestrator(
// Fetch certificate template from API (only if not already installed)
val templateResult = apiClient.getCertificateTemplate(certificateId)
val (template, scepUrl) = templateResult.getOrElse { error ->
Log.e(TAG, "Failed to fetch certificate template for ID $certificateId: ${error.message}", error)
FleetLog.e(TAG, "Failed to fetch certificate template for ID $certificateId: ${error.message}", error)
return CertificateEnrollmentHandler.EnrollmentResult.Failure(
reason = "Failed to fetch certificate template: ${error.message}",
exception = error as? Exception,
@@ -771,14 +771,14 @@ class CertificateOrchestrator(
is CertificateEnrollmentHandler.EnrollmentResult.Failure -> {
val updatedInfo = markCertificateFailure(context = context, certificateId = certificateId, alias = template.name)
if (!updatedInfo.shouldRetry()) {
Log.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception)
FleetLog.e(TAG, "Certificate enrollment failed for ID $certificateId: ${result.reason}", result.exception)
apiClient.updateCertificateStatus(
certificateId = certificateId,
status = UpdateCertificateStatusStatus.FAILED,
operationType = UpdateCertificateStatusOperation.INSTALL,
detail = result.reason,
).onFailure { error ->
Log.e(TAG, "Failed to update certificate status to failed for ID $certificateId: ${error.message}", error)
FleetLog.e(TAG, "Failed to update certificate status to failed for ID $certificateId: ${error.message}", error)
}
}
}
@@ -841,7 +841,7 @@ class CertificateOrchestrator(
if (success) {
Log.i(TAG, "Certificate successfully installed with alias: $alias")
} else {
Log.e(TAG, "Certificate installation failed. Check MDM policy and delegation status.")
FleetLog.e(TAG, "Certificate installation failed. Check MDM policy and delegation status.")
}
return success
@@ -0,0 +1,284 @@
package com.fleetdm.agent
import android.app.admin.DevicePolicyManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.RestrictionsManager
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fleetdm.agent.ui.theme.MyApplicationTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private val jsonPretty = Json { prettyPrint = true }
@Serializable
data class LastError(
@SerialName("timestamp")
val timestamp: String,
@SerialName("tag")
val tag: String,
@SerialName("message")
val message: String,
)
fun readLastError(): LastError? {
val text = FleetLog.readErrors()
if (text.isBlank()) return null
// FleetLog line format: "2026-02-18T12:34:56Z E fleet-tag message"
val lineRegex = Regex("""^(\S+)\s+E\s+(\S+)\s+(.*)$""")
return text.lines()
.lastOrNull { lineRegex.matches(it) }
?.let { line ->
val match = lineRegex.find(line) ?: return null
val (timestamp, tag, message) = match.destructured
LastError(timestamp = timestamp, tag = tag, message = message)
}
}
@Composable
fun DebugScreen(onNavigateBack: () -> Unit, onNavigateToLogs: () -> Unit) {
val context = LocalContext.current
val restrictionsManager = context.getSystemService(RestrictionsManager::class.java)
val dpm = context.getSystemService(DevicePolicyManager::class.java)
if (restrictionsManager == null || dpm == null) {
Text("Debug information unavailable: system services not available")
return
}
val appRestrictions = restrictionsManager.applicationRestrictions
val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) }
val delegatedScopes = remember { dpm.getDelegatedScopes(null, context.packageName).toList() }
val enrollmentSpecificID = remember { appRestrictions.getString("host_uuid")?.let { "****" + it.takeLast(4) } }
val permissionsList = remember {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
val permissions = packageInfo.requestedPermissions ?: return@remember emptyList()
val flags = packageInfo.requestedPermissionsFlags ?: return@remember emptyList()
permissions.zip(flags.toList())
.filter { (_, flag) -> flag and PackageInfo.REQUESTED_PERMISSION_GRANTED != 0 }
.map { (permission, _) -> permission }
}
val baseUrl by ApiClient.baseUrlFlow.collectAsStateWithLifecycle(initialValue = null)
val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
var lastError by remember { mutableStateOf<LastError?>(null) }
LaunchedEffect(Unit) {
lastError = withContext(Dispatchers.IO) {
runCatching { readLastError() }.getOrNull()
}
}
val margin = Modifier.padding(horizontal = 20.dp)
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Debug information") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
actions = {
IconButton(onClick = onNavigateToLogs) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "view logs")
}
IconButton(onClick = {
val clipboard = context.getSystemService(ClipboardManager::class.java)
if (clipboard == null) {
Toast.makeText(context, "Clipboard not available", Toast.LENGTH_SHORT).show()
return@IconButton
}
val info = DebugInformation(
packageName = context.packageName,
versionName = BuildConfig.VERSION_NAME,
longVersionCode = BuildConfig.VERSION_CODE.toLong(),
delegatedScopes = delegatedScopes,
serverUrl = baseUrl,
hostUuidMasked = enrollmentSpecificID,
certificateStatus = installedCerts,
permissionList = permissionsList,
lastError = lastError,
)
val infoJson = jsonPretty.encodeToString(info)
clipboard.setPrimaryClip(ClipData.newPlainText("fleet debug information", infoJson))
Toast.makeText(context, "Debug info copied", Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Filled.ContentCopy, contentDescription = "copy debug information")
}
},
)
},
content = { paddingValues ->
Column(
Modifier
.padding(paddingValues = paddingValues)
.verticalScroll(rememberScrollState()),
) {
KeyValue(modifier = margin, key = "Package name", value = context.packageName)
KeyValue(modifier = margin, key = "Version name", value = BuildConfig.VERSION_NAME)
KeyValue(modifier = margin, key = "Long version code", value = BuildConfig.VERSION_CODE.toString())
KeyValue(modifier = margin, key = "Delegated scopes", value = delegatedScopes.toString())
KeyValue(modifier = margin, key = "Host UUID", value = enrollmentSpecificID)
KeyValue(modifier = margin, key = "Server URL", value = baseUrl)
DebugCertificateList(modifier = margin, certificates = installedCerts)
PermissionList(
modifier = margin,
permissionsList = permissionsList,
)
LastErrorSection(modifier = margin, lastError = lastError)
}
},
)
}
@Composable
fun LastErrorSection(modifier: Modifier = Modifier, lastError: LastError?) {
Column(modifier = modifier) {
Text("Last error:", fontWeight = FontWeight.Bold)
Column(modifier = Modifier.padding(start = 20.dp)) {
if (lastError == null) {
Text("None")
} else {
Text("time: " + lastError.timestamp)
Text("tag: " + lastError.tag)
Text("message: " + lastError.message)
}
}
}
}
@Composable
fun DebugCertificateList(modifier: Modifier = Modifier, certificates: CertificateStateMap) {
Column(modifier = modifier) {
Text("Certificate states:", fontWeight = FontWeight.Bold)
certificates.forEach { (key, value) ->
Row(modifier = Modifier.padding(bottom = 5.dp, start = 10.dp)) {
Text(
text = key.toString(),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 5.dp),
)
Column {
Text(text = "alias: ${value.alias}")
Text(text = "status: ${value.status}")
Text(text = "retries: ${value.retries}")
Text(text = "uuid: ${value.uuid}")
Text(text = "notBefore: ${value.notBefore}")
Text(text = "notAfter: ${value.notAfter}")
Text(text = "serial: ${value.serialNumber}")
}
}
}
}
HorizontalDivider()
}
@Composable
fun PermissionList(modifier: Modifier = Modifier, permissionsList: List<String>) {
Column(modifier = modifier) {
Text(text = "Permission list:", fontWeight = FontWeight.Bold)
permissionsList.forEach {
Row {
Text(text = "- ", modifier = Modifier.padding(end = 8.dp))
Text(text = it)
}
}
}
HorizontalDivider()
}
@Composable
fun KeyValue(modifier: Modifier = Modifier, key: String, value: String?) {
Text(
modifier = modifier,
text = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(key)
}
append(": $value")
},
)
HorizontalDivider()
}
@Preview(showBackground = true)
@Composable
fun DebugCertificateListPreview() {
MyApplicationTheme {
DebugCertificateList(
certificates = mapOf(
1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED),
2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED),
),
)
}
}
@Serializable
data class DebugInformation(
@SerialName("package_name")
val packageName: String,
@SerialName("version_name")
val versionName: String,
@SerialName("long_version_code")
val longVersionCode: Long,
@SerialName("delegated_scopes")
val delegatedScopes: List<String>,
@SerialName("server_url")
val serverUrl: String?,
@SerialName("host_uuid_masked")
val hostUuidMasked: String?,
@SerialName("certificate_status")
val certificateStatus: CertificateStateMap,
@SerialName("permission_list")
val permissionList: List<String>,
@SerialName("last_error")
val lastError: LastError?,
)
@@ -37,7 +37,7 @@ class AndroidDeviceKeystoreManager(private val context: Context) : DeviceKeystor
override fun hasKeyPair(alias: String): Boolean = try {
dpm.hasKeyPair(alias)
} catch (e: Exception) {
Log.e(TAG, "Error checking if certificate '$alias' exists: ${e.message}", e)
FleetLog.e(TAG, "Error checking if certificate '$alias' exists: ${e.message}", e)
false
}
@@ -50,15 +50,15 @@ class AndroidDeviceKeystoreManager(private val context: Context) : DeviceKeystor
if (removed) {
Log.i(TAG, "Successfully removed certificate keypair with alias: $alias")
} else {
Log.e(TAG, "Failed to remove certificate keypair '$alias'. Check MDM policy and delegation status.")
FleetLog.e(TAG, "Failed to remove certificate keypair '$alias'. Check MDM policy and delegation status.")
}
removed
}
} catch (e: SecurityException) {
Log.e(TAG, "Security exception removing certificate '$alias': ${e.message}", e)
FleetLog.e(TAG, "Security exception removing certificate '$alias': ${e.message}", e)
false
} catch (e: Exception) {
Log.e(TAG, "Error removing certificate '$alias': ${e.message}", e)
FleetLog.e(TAG, "Error removing certificate '$alias': ${e.message}", e)
false
}
}
@@ -0,0 +1,62 @@
package com.fleetdm.agent
import android.content.Context
import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
/**
* Persistent error logger that writes to internal storage alongside the normal logcat output.
*
* Call [initialize] once in Application.onCreate(). Then use [e] wherever you would call
* Log.e it writes to logcat AND appends to fleet_errors.log in filesDir.
* The log file is capped at 512 KB; when it exceeds that size the current file is renamed
* to fleet_errors.log.1 and a fresh file is started.
*/
object FleetLog {
private const val TAG = "FleetLog"
private const val LOG_FILE_NAME = "fleet_errors.log"
private const val MAX_SIZE_BYTES = 512 * 1024L // 512 KB
@Volatile private var logFile: File? = null
fun initialize(context: Context) {
logFile = File(context.filesDir, LOG_FILE_NAME)
}
fun e(tag: String, msg: String, throwable: Throwable? = null) {
Log.e(tag, msg, throwable)
appendToFile(tag, msg, throwable)
}
fun readErrors(): String = logFile?.takeIf { it.exists() }?.readText() ?: ""
@Synchronized
private fun appendToFile(tag: String, msg: String, throwable: Throwable?) {
val file = logFile ?: return
try {
if (file.exists() && file.length() > MAX_SIZE_BYTES) {
val backup = File(file.parent, "$LOG_FILE_NAME.1")
backup.delete()
if (!file.renameTo(backup)) {
Log.w(TAG, "Failed to rotate log file; continuing to append to existing file")
}
}
val timestamp = Instant.now().toString()
val sb = StringBuilder()
sb.append("$timestamp E $tag $msg\n")
throwable?.let { t ->
sb.append(" $t\n")
t.stackTrace.forEach { element ->
sb.append(" at $element\n")
}
}
file.appendText(sb.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to write to log file: ${e.message}")
}
}
}
@@ -0,0 +1,146 @@
package com.fleetdm.agent
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private enum class LogLevel(val label: String, val logcatCode: String) {
VERBOSE("Verbose", "V"),
DEBUG("Debug", "D"),
INFO("Info", "I"),
WARNING("Warning", "W"),
ERROR("Error", "E"),
}
@Composable
fun LogsScreen(onNavigateBack: () -> Unit) {
var logs by remember { mutableStateOf<String?>(null) }
var selectedLevel by remember { mutableStateOf(LogLevel.INFO) }
var levelMenuExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(logs) {
if (logs != null) scrollState.scrollTo(scrollState.maxValue)
}
LaunchedEffect(selectedLevel) {
logs = null
logs = withContext(Dispatchers.IO) {
try {
val fleetTags = listOf(
"fleet-app",
"fleet-ApiClient",
"fleet-CertificateEnrollmentWorker",
"fleet-CertificateOrchestrator",
"fleet-AndroidCertInstaller",
"fleet-DeviceKeystoreManager",
"fleet-boot",
"fleet-RoleNotificationReceiverService",
"fleet-crash",
)
val filterArgs = fleetTags.map { "$it:${selectedLevel.logcatCode}" } + listOf("*:S")
val command = listOf("logcat", "-d") + filterArgs
val process = ProcessBuilder(command).redirectErrorStream(true).start()
try {
process.inputStream.bufferedReader().readText()
} finally {
process.destroy()
}
} catch (e: Exception) {
"Failed to read logs: ${e.message}"
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Logs") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
Box {
TextButton(onClick = { levelMenuExpanded = true }) {
Text(selectedLevel.label)
}
DropdownMenu(
expanded = levelMenuExpanded,
onDismissRequest = { levelMenuExpanded = false },
) {
LogLevel.entries.forEach { level ->
DropdownMenuItem(
text = { Text(level.label) },
onClick = {
selectedLevel = level
levelMenuExpanded = false
},
)
}
}
}
IconButton(onClick = {
val clipboard = context.getSystemService(ClipboardManager::class.java)
?: return@IconButton
clipboard.setPrimaryClip(ClipData.newPlainText("fleet logs", logs ?: ""))
Toast.makeText(context, "Logs copied", Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Filled.ContentCopy, contentDescription = "copy logs")
}
},
)
},
content = { paddingValues ->
if (logs == null) {
Box(Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
Text("Loading logs…")
}
} else {
Text(
text = logs!!,
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = 12.dp)
.verticalScroll(scrollState),
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
lineHeight = 12.sp,
)
}
},
)
}
@@ -72,6 +72,9 @@ object MainDestination
@Serializable
object DebugDestination
@Serializable
object LogsDestination
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -107,8 +110,13 @@ fun AppNavigation() {
composable<DebugDestination> {
DebugScreen(
onNavigateBack = { navController.navigateUp() },
onNavigateToLogs = { navController.navigate(LogsDestination) },
)
}
composable<LogsDestination> {
LogsScreen(onNavigateBack = { navController.navigateUp() })
}
}
}
@@ -154,123 +162,6 @@ fun MainScreen(onNavigateToDebug: () -> Unit) {
)
}
@Composable
fun DebugScreen(onNavigateBack: () -> Unit) {
val context = LocalContext.current
val restrictionsManager = context.getSystemService(RestrictionsManager::class.java)
?: error("RestrictionsManager not available")
val appRestrictions = restrictionsManager.applicationRestrictions
val dpm = context.getSystemService(DevicePolicyManager::class.java)
?: error("DevicePolicyManager not available")
val orchestrator = remember { AgentApplication.getCertificateOrchestrator(context) }
val delegatedScopes = remember { dpm.getDelegatedScopes(null, context.packageName).toList() }
val enrollmentSpecificID = remember { appRestrictions.getString("host_uuid")?.let { "****" + it.takeLast(4) } }
val hostCertificates = remember { orchestrator.getHostCertificates(context) }
val permissionsList = remember {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
val permissions = packageInfo.requestedPermissions ?: return@remember emptyList()
val flags = packageInfo.requestedPermissionsFlags ?: return@remember emptyList()
permissions.zip(flags.toList())
.filter { (_, flag) -> flag and PackageInfo.REQUESTED_PERMISSION_GRANTED != 0 }
.map { (permission, _) -> permission }
}
val fleetBaseUrl = remember { appRestrictions.getString("server_url") }
val baseUrl by ApiClient.baseUrlFlow.collectAsStateWithLifecycle(initialValue = null)
val installedCerts by orchestrator.installedCertsFlow(context).collectAsStateWithLifecycle(initialValue = emptyMap())
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Debug Information") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
content = { paddingValues ->
Column(
Modifier
.padding(paddingValues = paddingValues)
.verticalScroll(rememberScrollState()),
) {
KeyValue("packageName", context.packageName)
KeyValue("versionName", BuildConfig.VERSION_NAME)
KeyValue("longVersionCode", BuildConfig.VERSION_CODE.toString())
KeyValue("delegatedScopes", delegatedScopes.toString())
KeyValue("host_uuid (MC)", enrollmentSpecificID)
KeyValue("server_url (MC)", fleetBaseUrl)
KeyValue("server_url (DS)", baseUrl)
KeyValue("host_certificates", hostCertificates?.map { "${it.id}:${it.operation}" }.toString())
DebugCertificateList(certificates = installedCerts)
PermissionList(
permissionsList = permissionsList,
)
}
},
)
}
@Composable
fun DebugCertificateList(certificates: CertificateStateMap) {
Column {
Text("certificate status:", fontWeight = FontWeight.Bold)
certificates.forEach { (key, value) ->
Row(modifier = Modifier.padding(bottom = 5.dp, start = 10.dp)) {
Text(
text = key.toString(),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 5.dp),
)
Column {
Text(text = "alias: ${value.alias}")
Text(text = "status: ${value.status}")
Text(text = "retries: ${value.retries}")
Text(text = "uuid: ${value.uuid}")
Text(text = "notBefore: ${value.notBefore}")
Text(text = "notAfter: ${value.notAfter}")
Text(text = "serial: ${value.serialNumber}")
}
}
}
HorizontalDivider()
}
}
@Composable
fun PermissionList(modifier: Modifier = Modifier, permissionsList: List<String>) {
Column(modifier = modifier) {
Text(text = "permission list:", fontWeight = FontWeight.Bold)
permissionsList.forEach {
Row {
Text(text = "- ", modifier = Modifier.padding(end = 8.dp))
Text(text = it)
}
}
}
}
@Composable
fun KeyValue(key: String, value: String?) {
Text(
buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(key)
}
append(": $value")
},
)
HorizontalDivider()
}
@Composable
fun AboutFleet(modifier: Modifier = Modifier, onLearnClick: () -> Unit = {}) {
Column(modifier = modifier.padding(20.dp)) {
@@ -363,19 +254,6 @@ fun FleetScreenPreview() {
}
}
@Preview(showBackground = true)
@Composable
fun DebugCertificateListPreview() {
MyApplicationTheme {
DebugCertificateList(
certificates = mapOf(
1 to CertificateState(alias = "WIFI-1", status = CertificateStatus.INSTALLED),
2 to CertificateState(alias = "VPN-3", status = CertificateStatus.FAILED),
),
)
}
}
@Preview(showBackground = true)
@Composable
fun AboutFleetPreview() {