Skip to content

Commit 03f6ef6

Browse files
oschwaldclaude
andcommitted
feat: Add camera hardware capabilities collection
Enumerate cameras via CameraManager (no permission required): - Camera ID and facing direction (front/back/external) - Sensor physical size - Supported JPEG resolutions - Available focal lengths Provides device fingerprinting without requesting camera permission. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 10d6901 commit 03f6ef6

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.graphics.ImageFormat
5+
import android.hardware.camera2.CameraCharacteristics
6+
import android.hardware.camera2.CameraManager
7+
import android.util.Size
8+
import com.maxmind.device.model.CameraInfo
9+
10+
/**
11+
* Collects camera hardware capabilities.
12+
*
13+
* Uses CameraManager to enumerate cameras and their characteristics
14+
* without requiring camera permission.
15+
*/
16+
internal class CameraCollector(
17+
private val context: Context,
18+
) {
19+
/**
20+
* Collects information about all cameras on the device.
21+
*
22+
* @return List of [CameraInfo] for each available camera
23+
*/
24+
fun collect(): List<CameraInfo> {
25+
val cameraManager =
26+
context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
27+
?: return emptyList()
28+
29+
return try {
30+
cameraManager.cameraIdList.mapNotNull { cameraID ->
31+
collectCameraInfo(cameraManager, cameraID)
32+
}
33+
} catch (
34+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
35+
e: Exception,
36+
) {
37+
// CameraManager may throw on some devices
38+
emptyList()
39+
}
40+
}
41+
42+
private fun collectCameraInfo(
43+
cameraManager: CameraManager,
44+
cameraID: String,
45+
): CameraInfo? =
46+
try {
47+
val characteristics = cameraManager.getCameraCharacteristics(cameraID)
48+
49+
val facing =
50+
characteristics.get(CameraCharacteristics.LENS_FACING)
51+
?: CameraCharacteristics.LENS_FACING_EXTERNAL
52+
53+
val physicalSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
54+
val physicalSizeString = physicalSize?.let { "${it.width}x${it.height}" }
55+
56+
val streamConfigMap =
57+
characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
58+
val resolutions =
59+
streamConfigMap
60+
?.getOutputSizes(ImageFormat.JPEG)
61+
?.map { size: Size -> "${size.width}x${size.height}" }
62+
?: emptyList()
63+
64+
val focalLengths =
65+
characteristics
66+
.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
67+
?.toList()
68+
69+
CameraInfo(
70+
cameraID = cameraID,
71+
facing = facing,
72+
sensorPhysicalSize = physicalSizeString,
73+
supportedResolutions = resolutions,
74+
focalLengths = focalLengths,
75+
)
76+
} catch (
77+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
78+
e: Exception,
79+
) {
80+
// Individual camera info may fail, skip it
81+
null
82+
}
83+
}

device-sdk/src/main/java/com/maxmind/device/collector/DeviceDataCollector.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal class DeviceDataCollector(private val context: Context) {
2929
private val gpuCollector = GpuCollector()
3030
private val audioCollector = AudioCollector(context)
3131
private val sensorCollector = SensorCollector(context)
32+
private val cameraCollector = CameraCollector(context)
3233

3334
/**
3435
* Collects current device data.
@@ -44,6 +45,7 @@ internal class DeviceDataCollector(private val context: Context) {
4445
gpu = gpuCollector.collect(),
4546
audio = audioCollector.collect(),
4647
sensors = sensorCollector.collect(),
48+
cameras = cameraCollector.collect(),
4749
installation = collectInstallationInfo(),
4850
locale = collectLocaleInfo(),
4951
// Timezone offset in minutes
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.graphics.ImageFormat
5+
import android.hardware.camera2.CameraCharacteristics
6+
import android.hardware.camera2.CameraManager
7+
import android.hardware.camera2.params.StreamConfigurationMap
8+
import android.util.Size
9+
import android.util.SizeF
10+
import io.mockk.every
11+
import io.mockk.mockk
12+
import org.junit.jupiter.api.Assertions.assertEquals
13+
import org.junit.jupiter.api.Assertions.assertNotNull
14+
import org.junit.jupiter.api.Assertions.assertTrue
15+
import org.junit.jupiter.api.BeforeEach
16+
import org.junit.jupiter.api.Disabled
17+
import org.junit.jupiter.api.Test
18+
19+
/**
20+
* Tests for CameraCollector.
21+
*
22+
* Uses MockK to mock CameraManager and CameraCharacteristics.
23+
*/
24+
internal class CameraCollectorTest {
25+
private lateinit var mockContext: Context
26+
private lateinit var mockCameraManager: CameraManager
27+
private lateinit var collector: CameraCollector
28+
29+
// CameraCharacteristics.LENS_FACING_* constants
30+
private val lensFacingBack = 1 // CameraCharacteristics.LENS_FACING_BACK
31+
private val lensFacingFront = 0 // CameraCharacteristics.LENS_FACING_FRONT
32+
33+
@BeforeEach
34+
internal fun setUp() {
35+
mockContext = mockk(relaxed = true)
36+
mockCameraManager = mockk(relaxed = true)
37+
every { mockContext.getSystemService(Context.CAMERA_SERVICE) } returns mockCameraManager
38+
collector = CameraCollector(mockContext)
39+
}
40+
41+
@Test
42+
@Disabled("CameraCharacteristics.LENS_FACING key is null in unit tests - use instrumented tests")
43+
internal fun `collect returns camera list with properties`() {
44+
val mockStreamMap =
45+
mockk<StreamConfigurationMap> {
46+
// ImageFormat.JPEG = 256
47+
every { getOutputSizes(ImageFormat.JPEG) } returns
48+
arrayOf(
49+
Size(4032, 3024),
50+
Size(1920, 1080),
51+
)
52+
}
53+
val mockCharacteristics =
54+
mockk<CameraCharacteristics> {
55+
every { get(CameraCharacteristics.LENS_FACING) } returns lensFacingBack
56+
every { get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) } returns
57+
SizeF(
58+
6.4f,
59+
4.8f,
60+
)
61+
every { get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) } returns
62+
mockStreamMap
63+
every { get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) } returns
64+
floatArrayOf(4.25f, 6.0f)
65+
}
66+
every { mockCameraManager.cameraIdList } returns arrayOf("0")
67+
every { mockCameraManager.getCameraCharacteristics("0") } returns mockCharacteristics
68+
69+
val result = collector.collect()
70+
71+
assertNotNull(result)
72+
assertEquals(1, result.size)
73+
assertEquals("0", result[0].cameraID)
74+
assertEquals(lensFacingBack, result[0].facing)
75+
assertEquals("6.4x4.8", result[0].sensorPhysicalSize)
76+
assertEquals(listOf("4032x3024", "1920x1080"), result[0].supportedResolutions)
77+
assertEquals(listOf(4.25f, 6.0f), result[0].focalLengths)
78+
}
79+
80+
@Test
81+
@Disabled("CameraCharacteristics.LENS_FACING key is null in unit tests - use instrumented tests")
82+
internal fun `collect returns multiple cameras`() {
83+
val backCameraChars =
84+
mockk<CameraCharacteristics>(relaxed = true) {
85+
every { get(CameraCharacteristics.LENS_FACING) } returns lensFacingBack
86+
}
87+
val frontCameraChars =
88+
mockk<CameraCharacteristics>(relaxed = true) {
89+
every { get(CameraCharacteristics.LENS_FACING) } returns lensFacingFront
90+
}
91+
every { mockCameraManager.cameraIdList } returns arrayOf("0", "1")
92+
every { mockCameraManager.getCameraCharacteristics("0") } returns backCameraChars
93+
every { mockCameraManager.getCameraCharacteristics("1") } returns frontCameraChars
94+
95+
val result = collector.collect()
96+
97+
assertEquals(2, result.size)
98+
assertEquals(lensFacingBack, result[0].facing)
99+
assertEquals(lensFacingFront, result[1].facing)
100+
}
101+
102+
@Test
103+
internal fun `collect returns empty list when no cameras available`() {
104+
every { mockCameraManager.cameraIdList } returns arrayOf()
105+
106+
val result = collector.collect()
107+
108+
assertNotNull(result)
109+
assertTrue(result.isEmpty())
110+
}
111+
112+
@Test
113+
internal fun `collect returns empty list when CameraManager unavailable`() {
114+
every { mockContext.getSystemService(Context.CAMERA_SERVICE) } returns null
115+
val collectorWithNoCameraManager = CameraCollector(mockContext)
116+
117+
val result = collectorWithNoCameraManager.collect()
118+
119+
assertNotNull(result)
120+
assertTrue(result.isEmpty())
121+
}
122+
123+
@Test
124+
internal fun `collect handles exception from CameraManager gracefully`() {
125+
every { mockCameraManager.cameraIdList } throws SecurityException("Camera access denied")
126+
127+
val result = collector.collect()
128+
129+
assertNotNull(result)
130+
assertTrue(result.isEmpty())
131+
}
132+
}

0 commit comments

Comments
 (0)