Skip to content

Commit 963303b

Browse files
oschwaldclaude
andcommitted
feat: Add telephony and font profile collection
Add two new device fingerprinting signals: 1. Telephony Context (TelephonyCollector): - Network operator name - SIM state - Phone type (GSM/CDMA/etc) - ICC card presence 2. Font Profile (FontCollector): - Tests for presence of standard Android fonts - Tests for manufacturer-specific fonts (Samsung, HTC, Sony, LG, Xiaomi, OnePlus) - Helps identify device manufacturers and custom ROMs These signals were identified as missing from the original implementation plan and provide additional device fingerprinting capabilities. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent a47ba6c commit 963303b

File tree

8 files changed

+272
-0
lines changed

8 files changed

+272
-0
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ internal class DeviceDataCollector(
4444
private val networkCollector = NetworkCollector(context)
4545
private val settingsCollector = SettingsCollector(context)
4646
private val behaviorCollector = BehaviorCollector(context)
47+
private val telephonyCollector = TelephonyCollector(context)
48+
private val fontCollector = FontCollector()
4749
private val webViewCollector = WebViewCollector(context)
4850

4951
/**
@@ -68,6 +70,8 @@ internal class DeviceDataCollector(
6870
installation = collectInstallationInfo(),
6971
settings = settingsCollector.collect(),
7072
behavior = behaviorCollector.collect(),
73+
telephony = telephonyCollector.collect(),
74+
fonts = fontCollector.collect(),
7175
locale = collectLocaleInfo(),
7276
// Timezone offset in minutes
7377
timezoneOffset = TimeZone.getDefault().rawOffset / 60000,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.maxmind.device.collector
2+
3+
import android.graphics.Typeface
4+
import com.maxmind.device.model.FontInfo
5+
6+
/**
7+
* Collects font profile information.
8+
*
9+
* Tests for the presence of various system fonts which can help identify
10+
* device manufacturers and custom ROMs.
11+
*/
12+
internal class FontCollector {
13+
/**
14+
* Collects font availability information.
15+
*
16+
* @return [FontInfo] containing list of available fonts
17+
*/
18+
fun collect(): FontInfo {
19+
val defaultTypeface = Typeface.DEFAULT
20+
21+
val availableFonts =
22+
TEST_FONTS.filter { fontFamily ->
23+
val typeface = Typeface.create(fontFamily, Typeface.NORMAL)
24+
// A font is considered "available" if it doesn't fall back to default
25+
// Roboto is always available as it's the Android default
26+
typeface != defaultTypeface || fontFamily == ROBOTO_FONT
27+
}
28+
29+
return FontInfo(availableFonts = availableFonts)
30+
}
31+
32+
internal companion object {
33+
const val ROBOTO_FONT = "Roboto"
34+
35+
// Common system fonts and manufacturer-specific fonts
36+
val TEST_FONTS =
37+
listOf(
38+
// Standard Android fonts
39+
ROBOTO_FONT,
40+
"Noto Sans",
41+
"Droid Sans",
42+
"Droid Serif",
43+
"Droid Sans Mono",
44+
// Samsung fonts
45+
"Samsung Sans",
46+
"SamsungOne",
47+
// HTC fonts
48+
"HTC Sense",
49+
// Sony fonts
50+
"Sony Sketch",
51+
"Sony Mobile UD Gothic",
52+
// LG fonts
53+
"LG Smart",
54+
// Xiaomi fonts
55+
"MIUI",
56+
"MiSans",
57+
// OnePlus fonts
58+
"OnePlus Slate",
59+
// Google fonts that may be present
60+
"Google Sans",
61+
"Product Sans",
62+
)
63+
}
64+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.telephony.TelephonyManager
5+
import com.maxmind.device.model.TelephonyInfo
6+
7+
/**
8+
* Collects telephony context information.
9+
*
10+
* Collects basic telephony information that doesn't require runtime permissions.
11+
*/
12+
internal class TelephonyCollector(private val context: Context) {
13+
/**
14+
* Collects current telephony information.
15+
*
16+
* @return [TelephonyInfo] containing telephony context, or null if unavailable
17+
*/
18+
@Suppress("SwallowedException")
19+
fun collect(): TelephonyInfo? {
20+
return try {
21+
val telephonyManager =
22+
context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
23+
?: return null
24+
25+
TelephonyInfo(
26+
networkOperatorName = telephonyManager.networkOperatorName?.takeIf { it.isNotBlank() },
27+
simState = telephonyManager.simState,
28+
phoneType = telephonyManager.phoneType,
29+
hasIccCard = telephonyManager.hasIccCard(),
30+
)
31+
} catch (e: Exception) {
32+
// Telephony info may fail on some devices
33+
null
34+
}
35+
}
36+
}

device-sdk/src/main/java/com/maxmind/device/model/DeviceData.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public data class DeviceData(
3434
val installation: InstallationInfo,
3535
val settings: SystemSettings = SystemSettings(),
3636
val behavior: BehaviorInfo = BehaviorInfo(),
37+
val telephony: TelephonyInfo? = null,
38+
val fonts: FontInfo? = null,
3739
// Context
3840
val locale: LocaleInfo,
3941
@SerialName("timezone_offset")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.maxmind.device.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Font profile information based on available system fonts.
8+
*/
9+
@Serializable
10+
public data class FontInfo(
11+
@SerialName("available_fonts")
12+
val availableFonts: List<String> = emptyList(),
13+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.maxmind.device.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Telephony context information from TelephonyManager.
8+
*/
9+
@Serializable
10+
public data class TelephonyInfo(
11+
@SerialName("network_operator_name")
12+
val networkOperatorName: String? = null,
13+
@SerialName("sim_state")
14+
val simState: Int? = null,
15+
@SerialName("phone_type")
16+
val phoneType: Int? = null,
17+
@SerialName("has_icc_card")
18+
val hasIccCard: Boolean? = null,
19+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.maxmind.device.collector
2+
3+
import org.junit.jupiter.api.Assertions.assertTrue
4+
import org.junit.jupiter.api.Test
5+
6+
/**
7+
* Tests for FontCollector.
8+
*
9+
* Note: Tests that require actual font detection (Typeface.create()) must run
10+
* in instrumented tests since Android framework classes cannot be mocked in unit tests.
11+
*/
12+
internal class FontCollectorTest {
13+
@Test
14+
internal fun `TEST_FONTS contains expected font families`() {
15+
val fonts = FontCollector.TEST_FONTS
16+
17+
// Should contain standard Android fonts
18+
assertTrue(fonts.contains("Roboto"))
19+
assertTrue(fonts.contains("Noto Sans"))
20+
assertTrue(fonts.contains("Droid Sans"))
21+
22+
// Should contain manufacturer-specific fonts
23+
assertTrue(fonts.contains("Samsung Sans"))
24+
assertTrue(fonts.contains("OnePlus Slate"))
25+
assertTrue(fonts.contains("MIUI"))
26+
}
27+
28+
@Test
29+
internal fun `TEST_FONTS is not empty`() {
30+
assertTrue(FontCollector.TEST_FONTS.isNotEmpty())
31+
}
32+
33+
@Test
34+
internal fun `ROBOTO_FONT constant is Roboto`() {
35+
assertTrue(FontCollector.ROBOTO_FONT == "Roboto")
36+
}
37+
38+
@Test
39+
internal fun `TEST_FONTS contains ROBOTO_FONT`() {
40+
assertTrue(FontCollector.TEST_FONTS.contains(FontCollector.ROBOTO_FONT))
41+
}
42+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.telephony.TelephonyManager
5+
import io.mockk.every
6+
import io.mockk.mockk
7+
import org.junit.jupiter.api.Assertions.assertEquals
8+
import org.junit.jupiter.api.Assertions.assertNotNull
9+
import org.junit.jupiter.api.Assertions.assertNull
10+
import org.junit.jupiter.api.Assertions.assertTrue
11+
import org.junit.jupiter.api.BeforeEach
12+
import org.junit.jupiter.api.Test
13+
14+
internal class TelephonyCollectorTest {
15+
private lateinit var mockContext: Context
16+
private lateinit var mockTelephonyManager: TelephonyManager
17+
private lateinit var collector: TelephonyCollector
18+
19+
@BeforeEach
20+
internal fun setUp() {
21+
mockContext = mockk(relaxed = true)
22+
mockTelephonyManager = mockk(relaxed = true)
23+
24+
every { mockContext.getSystemService(Context.TELEPHONY_SERVICE) } returns mockTelephonyManager
25+
26+
collector = TelephonyCollector(mockContext)
27+
}
28+
29+
@Test
30+
internal fun `collect returns telephony info with carrier name`() {
31+
every { mockTelephonyManager.networkOperatorName } returns "Test Carrier"
32+
every { mockTelephonyManager.simState } returns TelephonyManager.SIM_STATE_READY
33+
every { mockTelephonyManager.phoneType } returns TelephonyManager.PHONE_TYPE_GSM
34+
every { mockTelephonyManager.hasIccCard() } returns true
35+
36+
val result = collector.collect()
37+
38+
assertNotNull(result)
39+
assertEquals("Test Carrier", result?.networkOperatorName)
40+
assertEquals(TelephonyManager.SIM_STATE_READY, result?.simState)
41+
assertEquals(TelephonyManager.PHONE_TYPE_GSM, result?.phoneType)
42+
assertTrue(result?.hasIccCard == true)
43+
}
44+
45+
@Test
46+
internal fun `collect returns null network operator name for blank string`() {
47+
every { mockTelephonyManager.networkOperatorName } returns " "
48+
every { mockTelephonyManager.simState } returns TelephonyManager.SIM_STATE_ABSENT
49+
every { mockTelephonyManager.phoneType } returns TelephonyManager.PHONE_TYPE_NONE
50+
every { mockTelephonyManager.hasIccCard() } returns false
51+
52+
val result = collector.collect()
53+
54+
assertNotNull(result)
55+
assertNull(result?.networkOperatorName)
56+
assertEquals(TelephonyManager.SIM_STATE_ABSENT, result?.simState)
57+
assertEquals(TelephonyManager.PHONE_TYPE_NONE, result?.phoneType)
58+
assertEquals(false, result?.hasIccCard)
59+
}
60+
61+
@Test
62+
internal fun `collect returns null when TelephonyManager unavailable`() {
63+
every { mockContext.getSystemService(Context.TELEPHONY_SERVICE) } returns null
64+
val collectorWithNoTelephony = TelephonyCollector(mockContext)
65+
66+
val result = collectorWithNoTelephony.collect()
67+
68+
assertNull(result)
69+
}
70+
71+
@Test
72+
internal fun `collect handles exception gracefully`() {
73+
every { mockTelephonyManager.networkOperatorName } throws RuntimeException("Test exception")
74+
75+
val result = collector.collect()
76+
77+
assertNull(result)
78+
}
79+
80+
@Test
81+
internal fun `collect returns CDMA phone type`() {
82+
every { mockTelephonyManager.networkOperatorName } returns "CDMA Carrier"
83+
every { mockTelephonyManager.simState } returns TelephonyManager.SIM_STATE_READY
84+
every { mockTelephonyManager.phoneType } returns TelephonyManager.PHONE_TYPE_CDMA
85+
every { mockTelephonyManager.hasIccCard() } returns true
86+
87+
val result = collector.collect()
88+
89+
assertNotNull(result)
90+
assertEquals(TelephonyManager.PHONE_TYPE_CDMA, result?.phoneType)
91+
}
92+
}

0 commit comments

Comments
 (0)