Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AttachedDeviceClass> {
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<AttachedDeviceClass, Int> =
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
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,6 +164,8 @@ internal object DeviceProtectedApplicationLoader :
withContext(Dispatchers.Main) {
initializeAnalytics()
}

trackAttachedDevicesMetric(app)
}

fun onTelemetryConsentGranted(app: IDEApplication) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading