Skip to content

Commit 5aba5d0

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 95dc9cb commit 5aba5d0

File tree

6 files changed

+222
-133
lines changed

6 files changed

+222
-133
lines changed
Lines changed: 21 additions & 133 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,11 +26,21 @@ 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
*/
3435
internal class DeviceDataCollector(
35-
private val context: Context,
36+
context: Context,
3637
storedIDStorage: StoredIDStorage? = null,
3738
private val enableLogging: Boolean = false,
39+
private val buildInfoHelper: BuildInfoHelper = BuildInfoHelper(),
40+
private val displayInfoHelper: DisplayInfoHelper = DisplayInfoHelper(context),
41+
private val hardwareInfoHelper: HardwareInfoHelper = HardwareInfoHelper(context),
42+
private val installationInfoHelper: InstallationInfoHelper = InstallationInfoHelper(context),
43+
private val localeInfoHelper: LocaleInfoHelper = LocaleInfoHelper(),
3844
) {
3945
private companion object {
4046
private const val TAG = "DeviceDataCollector"
@@ -123,143 +129,25 @@ internal class DeviceDataCollector(
123129
DeviceData(
124130
storedID = storedIDCollector?.collect() ?: StoredID(),
125131
deviceIDs = deviceIDsCollector.collect(),
126-
build = collectSafe(BUILD_INFO_FALLBACK) { collectBuildInfo() },
127-
display = collectSafe(DISPLAY_INFO_FALLBACK) { collectDisplayInfo() },
128-
hardware = collectSafe(HARDWARE_INFO_FALLBACK) { collectHardwareInfo() },
132+
build = collectSafe(BUILD_INFO_FALLBACK) { buildInfoHelper.collect() },
133+
display = collectSafe(DISPLAY_INFO_FALLBACK) { displayInfoHelper.collect() ?: DISPLAY_INFO_FALLBACK },
134+
hardware = collectSafe(HARDWARE_INFO_FALLBACK) { hardwareInfoHelper.collect() },
129135
gpu = gpuCollector.collect(),
130136
audio = audioCollector.collect(),
131137
sensors = sensorCollector.collect(),
132138
cameras = cameraCollector.collect(),
133139
codecs = codecCollector.collect(),
134140
systemFeatures = systemFeaturesCollector.collect(),
135141
network = networkCollector.collect(),
136-
installation = collectSafe(INSTALLATION_INFO_FALLBACK) { collectInstallationInfo() },
142+
installation = collectSafe(INSTALLATION_INFO_FALLBACK) { installationInfoHelper.collect() },
137143
settings = settingsCollector.collect(),
138144
behavior = behaviorCollector.collect(),
139145
telephony = telephonyCollector.collect(),
140146
fonts = fontCollector.collect(),
141-
locale = collectSafe(LOCALE_INFO_FALLBACK) { collectLocaleInfo() },
147+
locale = collectSafe(LOCALE_INFO_FALLBACK) { localeInfoHelper.collect() },
142148
// Timezone offset in minutes (uses getOffset to account for DST)
143149
timezoneOffset = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000,
144150
deviceTime = System.currentTimeMillis(),
145151
webViewUserAgent = webViewCollector.collectUserAgent(),
146152
)
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-
}
265153
}
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+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.maxmind.device.collector.helper
2+
3+
import android.content.Context
4+
import android.hardware.display.DisplayManager
5+
import android.os.Build
6+
import android.util.DisplayMetrics
7+
import android.view.Display
8+
import com.maxmind.device.model.DisplayInfo
9+
10+
/**
11+
* Helper class for collecting display information.
12+
*
13+
* Encapsulates access to [DisplayManager] for testability.
14+
*/
15+
internal class DisplayInfoHelper(
16+
private val context: Context,
17+
) {
18+
/**
19+
* Collects display information from the device.
20+
*
21+
* @return [DisplayInfo] containing display metrics, or null if unavailable
22+
*/
23+
@Suppress("DEPRECATION")
24+
public fun collect(): DisplayInfo? {
25+
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
26+
?: return null
27+
28+
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
29+
?: return null
30+
31+
val displayMetrics = DisplayMetrics()
32+
display.getMetrics(displayMetrics)
33+
34+
// Get refresh rate using modern API on Android R+
35+
val refreshRate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
36+
display.mode.refreshRate
37+
} else {
38+
display.refreshRate
39+
}
40+
41+
// Collect HDR capabilities on Android N+ (API 24)
42+
val hdrCapabilities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
43+
display.hdrCapabilities?.supportedHdrTypes?.toList()
44+
} else {
45+
null
46+
}
47+
48+
return DisplayInfo(
49+
widthPixels = displayMetrics.widthPixels,
50+
heightPixels = displayMetrics.heightPixels,
51+
densityDpi = displayMetrics.densityDpi,
52+
density = displayMetrics.density,
53+
refreshRate = refreshRate,
54+
hdrCapabilities = hdrCapabilities,
55+
)
56+
}
57+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.maxmind.device.collector.helper
2+
3+
import android.app.ActivityManager
4+
import android.content.Context
5+
import android.os.Environment
6+
import android.os.StatFs
7+
import com.maxmind.device.model.HardwareInfo
8+
9+
/**
10+
* Helper class for collecting hardware information.
11+
*
12+
* Encapsulates access to [ActivityManager] and [StatFs] for testability.
13+
*/
14+
internal class HardwareInfoHelper(
15+
private val context: Context,
16+
) {
17+
/**
18+
* Collects hardware information from the device.
19+
*
20+
* @return [HardwareInfo] containing CPU, memory, and storage details
21+
*/
22+
public fun collect(): HardwareInfo {
23+
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
24+
val memoryInfo = ActivityManager.MemoryInfo()
25+
activityManager.getMemoryInfo(memoryInfo)
26+
27+
val statFs = StatFs(Environment.getDataDirectory().path)
28+
val totalStorageBytes = statFs.blockCountLong * statFs.blockSizeLong
29+
30+
return HardwareInfo(
31+
cpuCores = Runtime.getRuntime().availableProcessors(),
32+
totalMemoryBytes = memoryInfo.totalMem,
33+
totalStorageBytes = totalStorageBytes,
34+
)
35+
}
36+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.maxmind.device.collector.helper
2+
3+
import android.content.Context
4+
import android.content.pm.PackageManager
5+
import android.os.Build
6+
import com.maxmind.device.model.InstallationInfo
7+
8+
/**
9+
* Helper class for collecting app installation information.
10+
*
11+
* Encapsulates access to [PackageManager] for testability.
12+
*/
13+
internal class InstallationInfoHelper(
14+
private val context: Context,
15+
) {
16+
/**
17+
* Collects installation information for the current app.
18+
*
19+
* @return [InstallationInfo] containing install times, version info, and installer details
20+
*/
21+
@Suppress("SwallowedException", "DEPRECATION")
22+
public fun collect(): InstallationInfo {
23+
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
24+
25+
val versionCode = packageInfo.longVersionCode
26+
27+
val installerPackage =
28+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
29+
try {
30+
context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
31+
} catch (e: PackageManager.NameNotFoundException) {
32+
// Package not found is expected for some installation scenarios, return null
33+
null
34+
}
35+
} else {
36+
context.packageManager.getInstallerPackageName(context.packageName)
37+
}
38+
39+
return InstallationInfo(
40+
firstInstallTime = packageInfo.firstInstallTime,
41+
lastUpdateTime = packageInfo.lastUpdateTime,
42+
installerPackage = installerPackage,
43+
versionCode = versionCode,
44+
versionName = packageInfo.versionName,
45+
)
46+
}
47+
}

0 commit comments

Comments
 (0)