Move debug screen to its own file, layout improvements, copy json (#39994)
**Related issue:** Resolves #38034
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user