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)
+ }
+}