Skip to content

Commit 6f9ffd6

Browse files
oschwaldclaude
andcommitted
refactor: Extract helper classes for testability
Extract inline collection methods into injectable helper classes to enable unit testing without requiring Robolectric or instrumented tests. New helper classes: - BuildInfoHelper: Collects device build information from Build.* - DisplayInfoHelper: Collects display metrics and HDR capabilities - HardwareInfoHelper: Collects CPU, memory, and storage info - InstallationInfoHelper: Collects app installation metadata - LocaleInfoHelper: Collects locale and timezone info DeviceDataCollector now accepts these helpers via constructor with defaults, allowing tests to inject mocks for isolated testing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ad3b152 commit 6f9ffd6

16 files changed

+1239
-180
lines changed

device-sdk/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ plugins {
77
alias(libs.plugins.ktlint)
88
id("maven-publish")
99
id("signing")
10+
id("tech.apter.junit5.jupiter.robolectric-extension-gradle-plugin") version "0.9.0"
1011
}
1112

1213
android {
Lines changed: 68 additions & 171 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
package com.maxmind.device.collector
22

3-
import android.app.ActivityManager
43
import android.content.Context
5-
import android.content.pm.PackageManager
6-
import android.hardware.display.DisplayManager
7-
import android.os.Build
8-
import android.os.Environment
9-
import android.os.StatFs
10-
import android.util.DisplayMetrics
114
import android.util.Log
12-
import android.view.Display
5+
import com.maxmind.device.collector.helper.BuildInfoHelper
6+
import com.maxmind.device.collector.helper.DisplayInfoHelper
7+
import com.maxmind.device.collector.helper.HardwareInfoHelper
8+
import com.maxmind.device.collector.helper.InstallationInfoHelper
9+
import com.maxmind.device.collector.helper.LocaleInfoHelper
1310
import com.maxmind.device.model.BuildInfo
1411
import com.maxmind.device.model.DeviceData
1512
import com.maxmind.device.model.DisplayInfo
@@ -18,7 +15,6 @@ import com.maxmind.device.model.InstallationInfo
1815
import com.maxmind.device.model.LocaleInfo
1916
import com.maxmind.device.model.StoredID
2017
import com.maxmind.device.storage.StoredIDStorage
21-
import java.util.Locale
2218
import java.util.TimeZone
2319

2420
/**
@@ -30,53 +26,69 @@ import java.util.TimeZone
3026
* @param context Application context for accessing system services
3127
* @param storedIDStorage Optional storage for server-generated stored IDs
3228
* @param enableLogging Whether to log collection failures (defaults to false)
29+
* @param buildInfoHelper Helper for collecting build info (injectable for testing)
30+
* @param displayInfoHelper Helper for collecting display info (injectable for testing)
31+
* @param hardwareInfoHelper Helper for collecting hardware info (injectable for testing)
32+
* @param installationInfoHelper Helper for collecting installation info (injectable for testing)
33+
* @param localeInfoHelper Helper for collecting locale info (injectable for testing)
3334
*/
35+
@Suppress("LongParameterList") // Intentional for dependency injection/testing
3436
internal class DeviceDataCollector(
35-
private val context: Context,
37+
context: Context,
3638
storedIDStorage: StoredIDStorage? = null,
3739
private val enableLogging: Boolean = false,
40+
private val buildInfoHelper: BuildInfoHelper = BuildInfoHelper(),
41+
private val displayInfoHelper: DisplayInfoHelper = DisplayInfoHelper(context),
42+
private val hardwareInfoHelper: HardwareInfoHelper = HardwareInfoHelper(context),
43+
private val installationInfoHelper: InstallationInfoHelper = InstallationInfoHelper(context),
44+
private val localeInfoHelper: LocaleInfoHelper = LocaleInfoHelper(),
3845
) {
3946
private companion object {
4047
private const val TAG = "DeviceDataCollector"
4148

4249
// Fallback values for when collection fails
43-
private val BUILD_INFO_FALLBACK = BuildInfo(
44-
fingerprint = "",
45-
manufacturer = "",
46-
model = "",
47-
brand = "",
48-
device = "",
49-
product = "",
50-
board = "",
51-
hardware = "",
52-
osVersion = "",
53-
sdkVersion = 0,
54-
)
55-
56-
private val DISPLAY_INFO_FALLBACK = DisplayInfo(
57-
widthPixels = 0,
58-
heightPixels = 0,
59-
densityDpi = 0,
60-
density = 0f,
61-
)
62-
63-
private val HARDWARE_INFO_FALLBACK = HardwareInfo(
64-
cpuCores = 0,
65-
totalMemoryBytes = 0L,
66-
totalStorageBytes = 0L,
67-
)
68-
69-
private val INSTALLATION_INFO_FALLBACK = InstallationInfo(
70-
firstInstallTime = 0L,
71-
lastUpdateTime = 0L,
72-
versionCode = 0L,
73-
)
74-
75-
private val LOCALE_INFO_FALLBACK = LocaleInfo(
76-
language = "",
77-
country = "",
78-
timezone = "",
79-
)
50+
private val BUILD_INFO_FALLBACK =
51+
BuildInfo(
52+
fingerprint = "",
53+
manufacturer = "",
54+
model = "",
55+
brand = "",
56+
device = "",
57+
product = "",
58+
board = "",
59+
hardware = "",
60+
osVersion = "",
61+
sdkVersion = 0,
62+
)
63+
64+
private val DISPLAY_INFO_FALLBACK =
65+
DisplayInfo(
66+
widthPixels = 0,
67+
heightPixels = 0,
68+
densityDpi = 0,
69+
density = 0f,
70+
)
71+
72+
private val HARDWARE_INFO_FALLBACK =
73+
HardwareInfo(
74+
cpuCores = 0,
75+
totalMemoryBytes = 0L,
76+
totalStorageBytes = 0L,
77+
)
78+
79+
private val INSTALLATION_INFO_FALLBACK =
80+
InstallationInfo(
81+
firstInstallTime = 0L,
82+
lastUpdateTime = 0L,
83+
versionCode = 0L,
84+
)
85+
86+
private val LOCALE_INFO_FALLBACK =
87+
LocaleInfo(
88+
language = "",
89+
country = "",
90+
timezone = "",
91+
)
8092
}
8193

8294
private val storedIDCollector = storedIDStorage?.let { StoredIDCollector(it) }
@@ -104,7 +116,10 @@ internal class DeviceDataCollector(
104116
* @return The collected value or fallback on failure
105117
*/
106118
@Suppress("TooGenericExceptionCaught")
107-
private inline fun <T> collectSafe(fallback: T, block: () -> T): T =
119+
private inline fun <T> collectSafe(
120+
fallback: T,
121+
block: () -> T,
122+
): T =
108123
try {
109124
block()
110125
} catch (e: Exception) {
@@ -123,143 +138,25 @@ internal class DeviceDataCollector(
123138
DeviceData(
124139
storedID = storedIDCollector?.collect() ?: StoredID(),
125140
deviceIDs = deviceIDsCollector.collect(),
126-
build = collectSafe(BUILD_INFO_FALLBACK) { collectBuildInfo() },
127-
display = collectSafe(DISPLAY_INFO_FALLBACK) { collectDisplayInfo() },
128-
hardware = collectSafe(HARDWARE_INFO_FALLBACK) { collectHardwareInfo() },
141+
build = collectSafe(BUILD_INFO_FALLBACK) { buildInfoHelper.collect() },
142+
display = collectSafe(DISPLAY_INFO_FALLBACK) { displayInfoHelper.collect() ?: DISPLAY_INFO_FALLBACK },
143+
hardware = collectSafe(HARDWARE_INFO_FALLBACK) { hardwareInfoHelper.collect() },
129144
gpu = gpuCollector.collect(),
130145
audio = audioCollector.collect(),
131146
sensors = sensorCollector.collect(),
132147
cameras = cameraCollector.collect(),
133148
codecs = codecCollector.collect(),
134149
systemFeatures = systemFeaturesCollector.collect(),
135150
network = networkCollector.collect(),
136-
installation = collectSafe(INSTALLATION_INFO_FALLBACK) { collectInstallationInfo() },
151+
installation = collectSafe(INSTALLATION_INFO_FALLBACK) { installationInfoHelper.collect() },
137152
settings = settingsCollector.collect(),
138153
behavior = behaviorCollector.collect(),
139154
telephony = telephonyCollector.collect(),
140155
fonts = fontCollector.collect(),
141-
locale = collectSafe(LOCALE_INFO_FALLBACK) { collectLocaleInfo() },
156+
locale = collectSafe(LOCALE_INFO_FALLBACK) { localeInfoHelper.collect() },
142157
// Timezone offset in minutes (uses getOffset to account for DST)
143158
timezoneOffset = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000,
144159
deviceTime = System.currentTimeMillis(),
145160
webViewUserAgent = webViewCollector.collectUserAgent(),
146161
)
147-
148-
private fun collectBuildInfo(): BuildInfo =
149-
BuildInfo(
150-
fingerprint = Build.FINGERPRINT,
151-
manufacturer = Build.MANUFACTURER,
152-
model = Build.MODEL,
153-
brand = Build.BRAND,
154-
device = Build.DEVICE,
155-
product = Build.PRODUCT,
156-
board = Build.BOARD,
157-
hardware = Build.HARDWARE,
158-
bootloader = Build.BOOTLOADER,
159-
osVersion = Build.VERSION.RELEASE,
160-
sdkVersion = Build.VERSION.SDK_INT,
161-
securityPatch = Build.VERSION.SECURITY_PATCH,
162-
supportedAbis = Build.SUPPORTED_ABIS.toList(),
163-
)
164-
165-
private fun collectDisplayInfo(): DisplayInfo {
166-
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
167-
?: return DISPLAY_INFO_FALLBACK
168-
169-
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
170-
?: return DISPLAY_INFO_FALLBACK
171-
172-
val displayMetrics = DisplayMetrics()
173-
174-
@Suppress("DEPRECATION")
175-
display.getMetrics(displayMetrics)
176-
177-
// Get refresh rate using modern API on Android R+
178-
val refreshRate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
179-
display.mode.refreshRate
180-
} else {
181-
@Suppress("DEPRECATION")
182-
display.refreshRate
183-
}
184-
windowManager.defaultDisplay.getMetrics(displayMetrics)
185-
display.getMetrics(displayMetrics)
186-
187-
// Get refresh rate using modern API on Android R+
188-
val refreshRate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
189-
display.mode.refreshRate
190-
} else {
191-
@Suppress("DEPRECATION")
192-
display.refreshRate
193-
}
194-
195-
// Collect HDR capabilities on Android N+ (API 24)
196-
val hdrCapabilities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
197-
display.hdrCapabilities?.supportedHdrTypes?.toList()
198-
} else {
199-
null
200-
}
201-
202-
return DisplayInfo(
203-
widthPixels = displayMetrics.widthPixels,
204-
heightPixels = displayMetrics.heightPixels,
205-
densityDpi = displayMetrics.densityDpi,
206-
density = displayMetrics.density,
207-
refreshRate = refreshRate,
208-
hdrCapabilities = hdrCapabilities,
209-
)
210-
}
211-
212-
private fun collectHardwareInfo(): HardwareInfo {
213-
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
214-
val memoryInfo = ActivityManager.MemoryInfo()
215-
activityManager.getMemoryInfo(memoryInfo)
216-
217-
val statFs = StatFs(Environment.getDataDirectory().path)
218-
val totalStorageBytes = statFs.blockCountLong * statFs.blockSizeLong
219-
220-
return HardwareInfo(
221-
cpuCores = Runtime.getRuntime().availableProcessors(),
222-
totalMemoryBytes = memoryInfo.totalMem,
223-
totalStorageBytes = totalStorageBytes,
224-
)
225-
}
226-
227-
private fun collectInstallationInfo(): InstallationInfo {
228-
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
229-
230-
val versionCode = packageInfo.longVersionCode
231-
232-
@Suppress("SwallowedException")
233-
val installerPackage =
234-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
235-
try {
236-
context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
237-
} catch (e: PackageManager.NameNotFoundException) {
238-
// Package not found is expected for some installation scenarios, return null
239-
null
240-
}
241-
} else {
242-
@Suppress("DEPRECATION")
243-
context.packageManager.getInstallerPackageName(context.packageName)
244-
}
245-
246-
return InstallationInfo(
247-
firstInstallTime = packageInfo.firstInstallTime,
248-
lastUpdateTime = packageInfo.lastUpdateTime,
249-
installerPackage = installerPackage,
250-
versionCode = versionCode,
251-
versionName = packageInfo.versionName,
252-
)
253-
}
254-
255-
private fun collectLocaleInfo(): LocaleInfo {
256-
val locale = Locale.getDefault()
257-
val timezone = TimeZone.getDefault()
258-
259-
return LocaleInfo(
260-
language = locale.language,
261-
country = locale.country,
262-
timezone = timezone.id,
263-
)
264-
}
265162
}

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ internal class GpuCollector {
2222
*/
2323
@Suppress("LongMethod", "ReturnCount", "CyclomaticComplexMethod")
2424
fun collect(): GpuInfo? {
25-
var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
26-
var context: EGLContext = EGL14.EGL_NO_CONTEXT
27-
var surface: EGLSurface = EGL14.EGL_NO_SURFACE
25+
// EGL14 constants may be null in test environments (e.g., Robolectric)
26+
val noDisplay = EGL14.EGL_NO_DISPLAY ?: return null
27+
val noContext = EGL14.EGL_NO_CONTEXT ?: return null
28+
val noSurface = EGL14.EGL_NO_SURFACE ?: return null
29+
30+
var display: EGLDisplay = noDisplay
31+
var context: EGLContext = noContext
32+
var surface: EGLSurface = noSurface
2833

2934
return try {
3035
// Initialize EGL display
@@ -128,17 +133,17 @@ internal class GpuCollector {
128133
null
129134
} finally {
130135
// Clean up EGL resources
131-
if (display != EGL14.EGL_NO_DISPLAY) {
136+
if (display != noDisplay) {
132137
EGL14.eglMakeCurrent(
133138
display,
134-
EGL14.EGL_NO_SURFACE,
135-
EGL14.EGL_NO_SURFACE,
136-
EGL14.EGL_NO_CONTEXT,
139+
noSurface,
140+
noSurface,
141+
noContext,
137142
)
138-
if (context != EGL14.EGL_NO_CONTEXT) {
143+
if (context != noContext) {
139144
EGL14.eglDestroyContext(display, context)
140145
}
141-
if (surface != EGL14.EGL_NO_SURFACE) {
146+
if (surface != noSurface) {
142147
EGL14.eglDestroySurface(display, surface)
143148
}
144149
EGL14.eglTerminate(display)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.maxmind.device.collector.helper
2+
3+
import android.os.Build
4+
import com.maxmind.device.model.BuildInfo
5+
6+
/**
7+
* Helper class for collecting device build information.
8+
*
9+
* Encapsulates access to [Build] static fields for testability.
10+
*/
11+
internal class BuildInfoHelper {
12+
/**
13+
* Collects build information from the device.
14+
*
15+
* @return [BuildInfo] containing device build details
16+
*/
17+
public fun collect(): BuildInfo =
18+
BuildInfo(
19+
fingerprint = Build.FINGERPRINT,
20+
manufacturer = Build.MANUFACTURER,
21+
model = Build.MODEL,
22+
brand = Build.BRAND,
23+
device = Build.DEVICE,
24+
product = Build.PRODUCT,
25+
board = Build.BOARD,
26+
hardware = Build.HARDWARE,
27+
bootloader = Build.BOOTLOADER,
28+
osVersion = Build.VERSION.RELEASE,
29+
sdkVersion = Build.VERSION.SDK_INT,
30+
securityPatch = Build.VERSION.SECURITY_PATCH,
31+
supportedAbis = Build.SUPPORTED_ABIS.toList(),
32+
)
33+
}

0 commit comments

Comments
 (0)