From fc842d6a1d55d89fec7c082073e09cbae685279c Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Wed, 1 Jul 2026 18:43:07 -0700 Subject: [PATCH] ADFA-2314 | Enrich Sentry reports with diagnostic context Register a single Sentry EventProcessor that attaches app-specific diagnostics to every event (fatal crashes, caught exceptions and plugin crashes alike): - SELinux contexts (process, private-data-dir file label, seinfo) - boot mode (direct-boot vs credential-unlocked) + locked duration - install location (internal vs external) - signing certificate SHA-256 digest + official-build flag - active plugins (id, version, minIdeVersion, recent crash count) - release name/code + git commit, process ABI/arch, device posture Each field is collected inside its own runCatching so a single failing collector (e.g. an SELinux denial, or credential storage being inaccessible in direct boot) drops only that field and the event is still reported. Running at capture time means the processor sees live boot state and the currently loaded plugins with no per-call-site wiring. Only the SELinux file label is stored, never any path or source. Wiring: install() in SentryAndroid.init; onUserUnlocked() in the existing ACTION_USER_UNLOCKED receiver. Adds a Robolectric test proving enrich populates the event and that a throwing collector never breaks the rest of the report. --- .../app/DeviceProtectedApplicationLoader.kt | 4 + .../itsaky/androidide/app/IDEApplication.kt | 4 + .../handlers/SentryDiagnosticsContext.kt | 203 ++++++++++++++++++ .../handlers/SentryDiagnosticsContextTest.kt | 98 +++++++++ 4 files changed, 309 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/handlers/SentryDiagnosticsContext.kt create mode 100644 app/src/test/java/com/itsaky/androidide/handlers/SentryDiagnosticsContextTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index 29c7859097..deb40e1786 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.events.LspApiEventsIndex import com.itsaky.androidide.events.LspJavaEventsIndex import com.itsaky.androidide.events.ProjectsApiEventsIndex import com.itsaky.androidide.handlers.CrashEventSubscriber +import com.itsaky.androidide.handlers.SentryDiagnosticsContext import com.itsaky.androidide.syntax.colorschemes.SchemeAndroidIDE import com.itsaky.androidide.ui.themes.IThemeManager import com.itsaky.androidide.utils.Environment @@ -73,6 +74,9 @@ internal object DeviceProtectedApplicationLoader : options.sessionReplay.quality = SentryReplayQuality.LOW options.environment = if (BuildConfig.DEBUG) IDEApplication.SENTRY_ENV_DEV else IDEApplication.SENTRY_ENV_PROD + + // Enrich every Sentry event with app-specific diagnostic context. + SentryDiagnosticsContext.install(options) } ShizukuSettings.initialize() diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index 09a28eeeae..b673341529 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -29,6 +29,7 @@ import androidx.work.Configuration import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.di.coreModule import com.itsaky.androidide.di.pluginModule +import com.itsaky.androidide.handlers.SentryDiagnosticsContext import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.treesitter.TreeSitter import com.itsaky.androidide.utils.RecyclableObjectPool @@ -84,6 +85,9 @@ class IDEApplication : ) { if (intent?.action == Intent.ACTION_USER_UNLOCKED) { runCatching { unregisterReceiver(this) } + // Stamp the unlock time so Sentry's boot_mode context reflects the + // live state and can report the direct-boot locked duration. + SentryDiagnosticsContext.onUserUnlocked() coroutineScope.launch(Dispatchers.Default) { logger.info("Device unlocked! Loading all components...") CredentialProtectedApplicationLoader.load(this@IDEApplication) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/SentryDiagnosticsContext.kt b/app/src/main/java/com/itsaky/androidide/handlers/SentryDiagnosticsContext.kt new file mode 100644 index 0000000000..640b797624 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/handlers/SentryDiagnosticsContext.kt @@ -0,0 +1,203 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + * + */ + +package com.itsaky.androidide.handlers + +import android.content.pm.ApplicationInfo +import android.os.SystemClock +import com.blankj.utilcode.util.AppUtils +import com.blankj.utilcode.util.DeviceUtils +import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider +import com.itsaky.androidide.buildinfo.BuildInfo +import com.itsaky.androidide.plugins.manager.core.PluginManager +import com.itsaky.androidide.utils.BuildInfoUtils +import com.termux.shared.android.PackageUtils +import com.termux.shared.android.SELinuxUtils +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import org.slf4j.LoggerFactory + +/** + * Enriches every Sentry event with app-specific diagnostic context (SELinux + * labels, boot mode, install location, signing certificate, active plugins, + * release/ABI/device posture). + * + * The context is attached through a single [EventProcessor] registered in + * [io.sentry.android.core.SentryAndroid.init]. Running at capture time means a + * single processor sees the live boot state and the currently loaded plugins + * for fatal crashes, caught exceptions and plugin crashes alike, with no + * per-call-site wiring. + * + * Every field is collected inside its own [runCatching] so that a single + * failing collector (e.g. an SELinux read denied by policy, or credential + * protected storage being inaccessible in direct boot) drops only that one + * field — the event is still reported with everything else intact. + * + * No source code or project file paths are ever read. The SELinux file-context + * collector keeps only the returned security label, not the directory path. + * + * @author Hal Eisen + */ +object SentryDiagnosticsContext { + + private val log = LoggerFactory.getLogger(SentryDiagnosticsContext::class.java) + + /** Process/boot start stamp, used to compute the direct-boot locked duration. */ + private val bootElapsedStartMs = SystemClock.elapsedRealtime() + + /** Elapsed-time stamp of when the user credential-unlocked the device, if observed. */ + @Volatile + private var userUnlockedElapsedMs: Long? = null + + /** Whether the app process started while the device was still credential-locked. */ + @Volatile + private var startedInDirectBoot = false + + // --- Static per-process values, computed once and cached lazily. --- + + private val appInfo: ApplicationInfo? by lazy { + val app = IDEApplication.instance + PackageUtils.getApplicationInfoForPackage(app, app.packageName) + } + + private val seInfo: String? by lazy { + appInfo?.let { PackageUtils.getApplicationInfoSeInfoForPackage(it) } + } + + private val signingDigest: String? by lazy { + val app = IDEApplication.instance + PackageUtils.getSigningCertificateSHA256DigestForPackage(app, app.packageName) + } + + private val installLocation: String? by lazy { + appInfo?.let { + if (PackageUtils.isAppInstalledOnExternalStorage(it)) "external" else "internal" + } + } + + /** + * Registers the diagnostics [EventProcessor] on the given Sentry [options]. + * Call once from within `SentryAndroid.init`. + */ + fun install(options: SentryOptions) { + // Capture whether we started locked at install time; install() runs from + // DeviceProtectedApplicationLoader, which is reachable in direct boot. + startedInDirectBoot = runCatching { !IDEApplication.instance.isUserUnlocked }.getOrDefault(false) + + options.addEventProcessor(object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent { + runCatching { enrich(event) }.onFailure { log.warn("Failed to enrich Sentry event", it) } + return event + } + }) + } + + /** + * Stamps the moment the device transitioned to credential-unlocked, so the + * direct-boot locked duration can be reported. Idempotent — only the first + * unlock is recorded. + */ + fun onUserUnlocked() { + if (userUnlockedElapsedMs == null) { + userUnlockedElapsedMs = SystemClock.elapsedRealtime() + } + } + + private fun enrich(event: SentryEvent) { + val app = runCatching { IDEApplication.instance }.getOrNull() ?: return + + // ① SELinux contexts (process, private-data-dir file label, seinfo). + context(event, "selinux") { + buildMap { + runCatching { SELinuxUtils.getContext() }.getOrNull()?.let { put("process_context", it) } + runCatching { SELinuxUtils.getFileContext(app.filesDir.absolutePath) } + .getOrNull()?.let { put("file_context", it) } + runCatching { seInfo }.getOrNull()?.let { put("seinfo", it) } + } + } + + // ② Boot mode, queried live, plus the locked duration if we started locked. + tag(event, "boot_mode") { + if (app.isUserUnlocked) "credential_unlocked" else "direct_boot" + } + if (startedInDirectBoot) { + tag(event, "boot_locked_duration_ms") { + val end = userUnlockedElapsedMs ?: SystemClock.elapsedRealtime() + (end - bootElapsedStartMs).toString() + } + } + + // ③ Install location (internal vs external/SD). + tag(event, "install_location") { installLocation } + + // ④ Signing certificate digest + official/unofficial build flag. + tag(event, "signing_sha256") { signingDigest } + tag(event, "signing_official") { BuildInfoUtils.isOfficialBuild(app).toString() } + + // ⑤ Active plugins (enabled + loaded) with version and recent crash count. + context(event, "active_plugins") { + val pm = PluginManager.getInstance() ?: return@context null + val plugins = pm.getAllPlugins() + .filter { it.isEnabled && it.isLoaded } + .map { info -> + mapOf( + "id" to info.metadata.id, + "version" to info.metadata.version, + "min_ide_version" to info.metadata.minIdeVersion, + "crash_count" to runCatching { + pm.crashTracker.getCrashCount(info.metadata.id) + }.getOrDefault(0), + ) + } + mapOf("count" to plugins.size, "plugins" to plugins) + } + + // A — release identifier + version code. + tag(event, "app_version_name") { BuildInfo.VERSION_NAME_SIMPLE } + tag(event, "app_version_code") { AppUtils.getAppVersionCode().toString() } + tag(event, "app_git_commit") { BuildInfo.CI_GIT_COMMIT_HASH } + tag(event, "app_ci_build") { BuildInfo.CI_BUILD.toString() } + + // B — process ABI / variant. + val buildConfig = IDEBuildConfigProvider.getInstance() + tag(event, "abi") { buildConfig.cpuAbiName } + tag(event, "cpu_arch") { buildConfig.cpuArch.name } + + // H — device posture (emulator vs physical, rooted). + tag(event, "device_emulator") { DeviceUtils.isEmulator().toString() } + tag(event, "device_rooted") { DeviceUtils.isDeviceRooted().toString() } + } + + /** Sets a single tag, guarded so a failing collector drops only that tag. */ + private inline fun tag(event: SentryEvent, key: String, value: () -> String?) { + runCatching { value()?.let { event.setTag(key, it) } } + .onFailure { log.debug("Sentry diagnostics: dropped tag '{}'", key, it) } + } + + /** + * Attaches a structured context group, guarded so a failing collector drops + * only that group. Empty/null maps are skipped. + */ + private inline fun context(event: SentryEvent, key: String, value: () -> Map?) { + runCatching { value()?.takeIf { it.isNotEmpty() }?.let { event.contexts.put(key, it) } } + .onFailure { log.debug("Sentry diagnostics: dropped context '{}'", key, it) } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/SentryDiagnosticsContextTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/SentryDiagnosticsContextTest.kt new file mode 100644 index 0000000000..fc590e6589 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/SentryDiagnosticsContextTest.kt @@ -0,0 +1,98 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.buildinfo.BuildInfo +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Verifies that [SentryDiagnosticsContext] enriches events and, crucially, that + * a single failing field collector never prevents the rest of the event from + * being reported. + * + * @author Hal Eisen + */ +@RunWith(RobolectricTestRunner::class) +class SentryDiagnosticsContextTest { + + private lateinit var app: IDEApplication + + @Before + fun setUp() { + // Skip the native-library block in IDEApplication's static initializer. + System.setProperty("androidide.test.mode", "true") + + app = mockk(relaxed = true) + every { app.packageName } returns "com.itsaky.androidide" + every { app.filesDir } returns File("/tmp/androidide-test") + every { app.isUserUnlocked } returns true + + mockkObject(IDEApplication.Companion) + every { IDEApplication.instance } returns app + } + + @After + fun tearDown() { + unmockkAll() + } + + /** Installs the processor on a fresh options instance and enriches a new event. */ + private fun enrichNewEvent(): SentryEvent { + val options = SentryOptions() + SentryDiagnosticsContext.install(options) + val processor = options.eventProcessors + .first { it.javaClass.name.contains("SentryDiagnosticsContext") } + return processor.process(SentryEvent(), Hint())!! + } + + @Test + fun `enrich populates diagnostic context on the event`() { + val event = enrichNewEvent() + + // Live boot state and a compile-time release constant are both attached. + assertThat(event.getTag("boot_mode")).isEqualTo("credential_unlocked") + assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) + } + + @Test + fun `a single failing collector never breaks the rest of the event`() { + // Make the boot-mode collector blow up (simulating e.g. an SELinux denial). + every { app.isUserUnlocked } throws RuntimeException("read denied") + + val event = enrichNewEvent() + + // The failing field is simply dropped... + assertThat(event.getTag("boot_mode")).isNull() + // ...the event is still returned, with every other field intact. + assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) + } +}