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..dd3409ad8b --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/analytics/AttachedDevicesCollector.kt @@ -0,0 +1,117 @@ +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 +import org.slf4j.LoggerFactory + +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 logger = LoggerFactory.getLogger(AttachedDevicesCollector::class.java) + + 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 = + try { + countInputDeviceClasses() + } catch (e: RuntimeException) { + logger.warn("Failed to count input devices", e) + emptyMap() + } + val externalDisplays = + 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, + 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..a5a1ed921c 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 @@ -162,6 +164,8 @@ internal object DeviceProtectedApplicationLoader : withContext(Dispatchers.Main) { initializeAnalytics() } + + trackAttachedDevicesMetric(app) } fun onTelemetryConsentGranted(app: IDEApplication) { @@ -194,6 +198,16 @@ internal object DeviceProtectedApplicationLoader : } } + private fun trackAttachedDevicesMetric(app: IDEApplication) { + try { + analyticsManager.trackMetric( + AttachedDevicesMetric(AttachedDevicesCollector.collect(app)), + ) + } catch (e: Exception) { + logger.error("Failed to report attached devices metric", e) + } + } + fun handleUncaughtException( thread: Thread, exception: Throwable, 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..03cf0e80a7 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,8 @@ 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 @@ -95,4 +97,42 @@ 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 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"]).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 + 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) + } }