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