diff --git a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt index e5861862b5..a822589fe7 100644 --- a/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt +++ b/android/app/src/main/java/com/fleetdm/agent/AgentApplication.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt index 8c9182fc4c..d85235f4a8 100644 --- a/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt +++ b/android/app/src/main/java/com/fleetdm/agent/ApiClient.kt @@ -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) }, ) diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt index a12768b928..6698ed9353 100644 --- a/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateEnrollmentWorker.kt @@ -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() } } diff --git a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt index 384e8a8521..50ec99eb1f 100644 --- a/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt +++ b/android/app/src/main/java/com/fleetdm/agent/CertificateOrchestrator.kt @@ -131,7 +131,7 @@ class CertificateOrchestrator( json.decodeFromString(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 diff --git a/android/app/src/main/java/com/fleetdm/agent/DebugScreen.kt b/android/app/src/main/java/com/fleetdm/agent/DebugScreen.kt new file mode 100644 index 0000000000..9f4bbf7aab --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/DebugScreen.kt @@ -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(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) { + 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, + @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, + @SerialName("last_error") + val lastError: LastError?, +) diff --git a/android/app/src/main/java/com/fleetdm/agent/DeviceKeystoreManager.kt b/android/app/src/main/java/com/fleetdm/agent/DeviceKeystoreManager.kt index 9a7d9409d8..e0c49e0a02 100644 --- a/android/app/src/main/java/com/fleetdm/agent/DeviceKeystoreManager.kt +++ b/android/app/src/main/java/com/fleetdm/agent/DeviceKeystoreManager.kt @@ -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 } } diff --git a/android/app/src/main/java/com/fleetdm/agent/FleetLog.kt b/android/app/src/main/java/com/fleetdm/agent/FleetLog.kt new file mode 100644 index 0000000000..0e27843a06 --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/FleetLog.kt @@ -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}") + } + } +} diff --git a/android/app/src/main/java/com/fleetdm/agent/LogScreen.kt b/android/app/src/main/java/com/fleetdm/agent/LogScreen.kt new file mode 100644 index 0000000000..9b7a11fe3a --- /dev/null +++ b/android/app/src/main/java/com/fleetdm/agent/LogScreen.kt @@ -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(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, + ) + } + }, + ) +} diff --git a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt index db5d3cb650..9c93c4bc4b 100644 --- a/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt +++ b/android/app/src/main/java/com/fleetdm/agent/MainActivity.kt @@ -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 { DebugScreen( onNavigateBack = { navController.navigateUp() }, + onNavigateToLogs = { navController.navigate(LogsDestination) }, ) } + + composable { + 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) { - 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() {