From 44ddf2e333a2409bfee0670634b47e27e96d3328 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 10:06:17 +0100 Subject: [PATCH 1/2] ADFA-4394: Report attached input devices and external displays to analytics and GlitchTip --- .../analytics/AttachedDevicesCollector.kt | 104 +++++++++++++++ .../analytics/AttachedDevicesMetric.kt | 19 +++ .../app/DeviceProtectedApplicationLoader.kt | 9 +- .../handlers/GlitchTipDiagnosticsContext.kt | 13 ++ .../analytics/AttachedDevicesCollectorTest.kt | 122 ++++++++++++++++++ .../analytics/AttachedDevicesMetricTest.kt | 35 +++++ .../GlitchTipDiagnosticsContextTest.kt | 19 +++ 7 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt create mode 100644 app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt new file mode 100644 index 0000000000..e6cd522da0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.analytics + +import android.content.Context +import android.hardware.display.DisplayManager +import android.os.Build +import android.view.Display +import android.view.InputDevice + +enum class AttachedDeviceClass { + MOUSE, + EXTERNAL_KEYBOARD, + TOUCHPAD, + STYLUS, + GAMEPAD, +} + +data class AttachedDevicesSnapshot( + val mouseCount: Int, + val externalKeyboardCount: Int, + val touchpadCount: Int, + val stylusCount: Int, + val gamepadCount: Int, + val externalDisplayCount: Int, +) + +object AttachedDevicesCollector { + private val DEVICE_CLASS_BY_SOURCE = + mapOf( + InputDevice.SOURCE_MOUSE to AttachedDeviceClass.MOUSE, + InputDevice.SOURCE_TOUCHPAD to AttachedDeviceClass.TOUCHPAD, + InputDevice.SOURCE_STYLUS to AttachedDeviceClass.STYLUS, + InputDevice.SOURCE_BLUETOOTH_STYLUS to AttachedDeviceClass.STYLUS, + InputDevice.SOURCE_GAMEPAD to AttachedDeviceClass.GAMEPAD, + InputDevice.SOURCE_JOYSTICK to AttachedDeviceClass.GAMEPAD, + ) + + fun classify( + sources: Int, + keyboardType: Int, + isVirtual: Boolean, + isExternal: Boolean?, + ): Set { + if (isVirtual || isExternal == false) { + return emptySet() + } + if (isExternal == null && sources.supportsSource(InputDevice.SOURCE_TOUCHSCREEN)) { + return emptySet() + } + val matched = + DEVICE_CLASS_BY_SOURCE + .filterKeys { sources.supportsSource(it) } + .values + .toSet() + return if (sources.supportsSource(InputDevice.SOURCE_KEYBOARD) && + keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC + ) { + matched + AttachedDeviceClass.EXTERNAL_KEYBOARD + } else { + matched + } + } + + fun collect(context: Context): AttachedDevicesSnapshot { + val classCounts = + runCatching { countInputDeviceClasses() }.getOrDefault(emptyMap()) + val externalDisplays = + runCatching { countExternalDisplays(context) }.getOrDefault(0) + return AttachedDevicesSnapshot( + mouseCount = classCounts[AttachedDeviceClass.MOUSE] ?: 0, + externalKeyboardCount = classCounts[AttachedDeviceClass.EXTERNAL_KEYBOARD] ?: 0, + touchpadCount = classCounts[AttachedDeviceClass.TOUCHPAD] ?: 0, + stylusCount = classCounts[AttachedDeviceClass.STYLUS] ?: 0, + gamepadCount = classCounts[AttachedDeviceClass.GAMEPAD] ?: 0, + externalDisplayCount = externalDisplays, + ) + } + + private fun countInputDeviceClasses(): Map = + InputDevice + .getDeviceIds() + .map { InputDevice.getDevice(it) } + .filterNotNull() + .flatMap { device -> + classify( + sources = device.sources, + keyboardType = device.keyboardType, + isVirtual = device.isVirtual, + isExternal = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + device.isExternal + } else { + null + }, + ) + }.groupingBy { it } + .eachCount() + + private fun countExternalDisplays(context: Context): Int = + requireNotNull(context.getSystemService(DisplayManager::class.java)) + .displays + .count { it.displayId != Display.DEFAULT_DISPLAY } + + private fun Int.supportsSource(source: Int): Boolean = (this and source) == source +} diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt new file mode 100644 index 0000000000..820f88c212 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesMetric.kt @@ -0,0 +1,19 @@ +package com.itsaky.androidide.analytics + +import android.os.Bundle + +class AttachedDevicesMetric( + private val snapshot: AttachedDevicesSnapshot, +) : Metric { + override val eventName = "attached_devices" + + override fun asBundle(): Bundle = + Bundle().apply { + putLong("mouse_count", snapshot.mouseCount.toLong()) + putLong("external_keyboard_count", snapshot.externalKeyboardCount.toLong()) + putLong("touchpad_count", snapshot.touchpadCount.toLong()) + putLong("stylus_count", snapshot.stylusCount.toLong()) + putLong("gamepad_count", snapshot.gamepadCount.toLong()) + putLong("external_display_count", snapshot.externalDisplayCount.toLong()) + } +} 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 69f023a985..ed1e29fe7e 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner import com.itsaky.androidide.BuildConfig +import com.itsaky.androidide.analytics.AttachedDevicesCollector +import com.itsaky.androidide.analytics.AttachedDevicesMetric import com.itsaky.androidide.analytics.IAnalyticsManager import com.itsaky.androidide.app.strictmode.StrictModeConfig import com.itsaky.androidide.app.strictmode.StrictModeManager @@ -160,7 +162,7 @@ internal object DeviceProtectedApplicationLoader : } withContext(Dispatchers.Main) { - initializeAnalytics() + initializeAnalytics(app) } } @@ -184,10 +186,13 @@ internal object DeviceProtectedApplicationLoader : } } - private fun initializeAnalytics() { + private fun initializeAnalytics(app: IDEApplication) { try { ProcessLifecycleOwner.get().lifecycle.addObserver(this) analyticsManager.initialize() + analyticsManager.trackMetric( + AttachedDevicesMetric(AttachedDevicesCollector.collect(app)), + ) logger.info("Firebase Analytics initialized successfully") } catch (e: Exception) { logger.error("Failed to initialize Firebase Analytics", e) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt index 09e151b921..19c59e7a5f 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContext.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.handlers import android.content.pm.ApplicationInfo import android.os.SystemClock +import com.itsaky.androidide.analytics.AttachedDevicesCollector import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.buildinfo.BuildInfo @@ -180,6 +181,18 @@ object GlitchTipDiagnosticsContext { mapOf("count" to plugins.size, "plugins" to plugins) } + context(event, "attached_devices") { + val snapshot = AttachedDevicesCollector.collect(app) + mapOf( + "mouse_count" to snapshot.mouseCount, + "external_keyboard_count" to snapshot.externalKeyboardCount, + "touchpad_count" to snapshot.touchpadCount, + "stylus_count" to snapshot.stylusCount, + "gamepad_count" to snapshot.gamepadCount, + "external_display_count" to snapshot.externalDisplayCount, + ) + } + // A — release identifier + version code. tag(event, "app_version_name") { BuildInfo.VERSION_NAME_SIMPLE } tag(event, "app_version_code") { IDEApplication.instance.getAppVersionCode().toString() } diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt new file mode 100644 index 0000000000..09b1621827 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesCollectorTest.kt @@ -0,0 +1,122 @@ +package com.itsaky.androidide.analytics + +import android.view.InputDevice +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AttachedDevicesCollectorTest { + @Test + fun `external mouse is classified as mouse`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.MOUSE) + } + + @Test + fun `external alphabetic keyboard is classified as keyboard`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.EXTERNAL_KEYBOARD) + } + + @Test + fun `non alphabetic keyboard is not classified`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `touchpad stylus and gamepad classes are detected`() { + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_TOUCHPAD, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.TOUCHPAD) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_STYLUS, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.STYLUS) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_BLUETOOTH_STYLUS, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.STYLUS) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_GAMEPAD, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.GAMEPAD) + assertThat( + AttachedDevicesCollector.classify(InputDevice.SOURCE_JOYSTICK, InputDevice.KEYBOARD_TYPE_NONE, false, true), + ).containsExactly(AttachedDeviceClass.GAMEPAD) + } + + @Test + fun `virtual devices are never classified`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = true, + isExternal = true, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `internal devices are never classified on api 29 plus`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = false, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `api 28 fallback excludes stylus capable touchscreens`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_STYLUS or InputDevice.SOURCE_TOUCHSCREEN, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = null, + ) + assertThat(classes).isEmpty() + } + + @Test + fun `api 28 fallback still detects a mouse`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_MOUSE, + keyboardType = InputDevice.KEYBOARD_TYPE_NONE, + isVirtual = false, + isExternal = null, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.MOUSE) + } + + @Test + fun `combo keyboard with touchpad yields both classes`() { + val classes = + AttachedDevicesCollector.classify( + sources = InputDevice.SOURCE_KEYBOARD or InputDevice.SOURCE_TOUCHPAD, + keyboardType = InputDevice.KEYBOARD_TYPE_ALPHABETIC, + isVirtual = false, + isExternal = true, + ) + assertThat(classes).containsExactly(AttachedDeviceClass.EXTERNAL_KEYBOARD, AttachedDeviceClass.TOUCHPAD) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt new file mode 100644 index 0000000000..bf03795f9f --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/analytics/AttachedDevicesMetricTest.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.analytics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AttachedDevicesMetricTest { + @Test + fun `bundle carries every device count under its exact param name`() { + val metric = + AttachedDevicesMetric( + AttachedDevicesSnapshot( + mouseCount = 1, + externalKeyboardCount = 2, + touchpadCount = 3, + stylusCount = 4, + gamepadCount = 5, + externalDisplayCount = 6, + ), + ) + + val bundle = metric.asBundle() + + assertThat(metric.eventName).isEqualTo("attached_devices") + assertThat(bundle.getLong("mouse_count")).isEqualTo(1L) + assertThat(bundle.getLong("external_keyboard_count")).isEqualTo(2L) + assertThat(bundle.getLong("touchpad_count")).isEqualTo(3L) + assertThat(bundle.getLong("stylus_count")).isEqualTo(4L) + assertThat(bundle.getLong("gamepad_count")).isEqualTo(5L) + assertThat(bundle.getLong("external_display_count")).isEqualTo(6L) + assertThat(bundle.keySet()).hasSize(6) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt index a183f9c14b..9f12254ba3 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.analytics.AttachedDevicesCollector import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.buildinfo.BuildInfo import io.mockk.every @@ -95,4 +96,22 @@ class GlitchTipDiagnosticsContextTest { // ...the event is still returned, with every other field intact. assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) } + + @Test + fun `attached devices context is included on every event`() { + val event = enrichNewEvent() + + assertThat(event.contexts["attached_devices"]).isNotNull() + } + + @Test + fun `a throwing attached devices collector drops only that section`() { + mockkObject(AttachedDevicesCollector) + every { AttachedDevicesCollector.collect(any()) } throws RuntimeException("input service dead") + + val event = enrichNewEvent() + + assertThat(event.contexts["attached_devices"]).isNull() + assertThat(event.getTag("app_version_name")).isEqualTo(BuildInfo.VERSION_NAME_SIMPLE) + } } From 2e9e29178f5a4161a0f8aacfd3657f6537974a2f Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 12:55:26 +0100 Subject: [PATCH 2/2] ADFA-4394: Address CodeRabbit review on attached devices metric Narrow the collector guards to RuntimeException and log fallbacks, move metric collection and tracking off the main dispatcher, and assert the exact attached_devices context map in the test. --- .../analytics/AttachedDevicesCollector.kt | 17 +++++++++++-- .../app/DeviceProtectedApplicationLoader.kt | 17 ++++++++++--- .../GlitchTipDiagnosticsContextTest.kt | 25 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt index e6cd522da0..dd3409ad8b 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt @@ -5,6 +5,7 @@ import android.hardware.display.DisplayManager import android.os.Build import android.view.Display import android.view.InputDevice +import org.slf4j.LoggerFactory enum class AttachedDeviceClass { MOUSE, @@ -24,6 +25,8 @@ data class AttachedDevicesSnapshot( ) object AttachedDevicesCollector { + private val logger = LoggerFactory.getLogger(AttachedDevicesCollector::class.java) + private val DEVICE_CLASS_BY_SOURCE = mapOf( InputDevice.SOURCE_MOUSE to AttachedDeviceClass.MOUSE, @@ -62,9 +65,19 @@ object AttachedDevicesCollector { fun collect(context: Context): AttachedDevicesSnapshot { val classCounts = - runCatching { countInputDeviceClasses() }.getOrDefault(emptyMap()) + try { + countInputDeviceClasses() + } catch (e: RuntimeException) { + logger.warn("Failed to count input devices", e) + emptyMap() + } val externalDisplays = - runCatching { countExternalDisplays(context) }.getOrDefault(0) + try { + countExternalDisplays(context) + } catch (e: RuntimeException) { + logger.warn("Failed to count external displays", e) + 0 + } return AttachedDevicesSnapshot( mouseCount = classCounts[AttachedDeviceClass.MOUSE] ?: 0, externalKeyboardCount = classCounts[AttachedDeviceClass.EXTERNAL_KEYBOARD] ?: 0, 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 ed1e29fe7e..a5a1ed921c 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -162,8 +162,10 @@ internal object DeviceProtectedApplicationLoader : } withContext(Dispatchers.Main) { - initializeAnalytics(app) + initializeAnalytics() } + + trackAttachedDevicesMetric(app) } fun onTelemetryConsentGranted(app: IDEApplication) { @@ -186,16 +188,23 @@ internal object DeviceProtectedApplicationLoader : } } - private fun initializeAnalytics(app: IDEApplication) { + private fun initializeAnalytics() { try { ProcessLifecycleOwner.get().lifecycle.addObserver(this) analyticsManager.initialize() + logger.info("Firebase Analytics initialized successfully") + } catch (e: Exception) { + logger.error("Failed to initialize Firebase Analytics", e) + } + } + + private fun trackAttachedDevicesMetric(app: IDEApplication) { + try { analyticsManager.trackMetric( AttachedDevicesMetric(AttachedDevicesCollector.collect(app)), ) - logger.info("Firebase Analytics initialized successfully") } catch (e: Exception) { - logger.error("Failed to initialize Firebase Analytics", e) + logger.error("Failed to report attached devices metric", e) } } diff --git a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt index 9f12254ba3..03cf0e80a7 100644 --- a/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt +++ b/app/src/test/java/com/itsaky/androidide/handlers/GlitchTipDiagnosticsContextTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.handlers import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.analytics.AttachedDevicesCollector +import com.itsaky.androidide.analytics.AttachedDevicesSnapshot import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.buildinfo.BuildInfo import io.mockk.every @@ -98,10 +99,30 @@ class GlitchTipDiagnosticsContextTest { } @Test - fun `attached devices context is included on every event`() { + fun `attached devices context carries every count under its exact key`() { + mockkObject(AttachedDevicesCollector) + every { AttachedDevicesCollector.collect(any()) } returns + AttachedDevicesSnapshot( + mouseCount = 1, + externalKeyboardCount = 2, + touchpadCount = 3, + stylusCount = 4, + gamepadCount = 5, + externalDisplayCount = 6, + ) + val event = enrichNewEvent() - assertThat(event.contexts["attached_devices"]).isNotNull() + assertThat(event.contexts["attached_devices"]).isEqualTo( + mapOf( + "mouse_count" to 1, + "external_keyboard_count" to 2, + "touchpad_count" to 3, + "stylus_count" to 4, + "gamepad_count" to 5, + "external_display_count" to 6, + ), + ) } @Test