Skip to content

Commit aba3ff8

Browse files
oschwaldclaude
andcommitted
feat: Add system settings and behavioral signals collection
Collect system settings via Settings.System/Global: - Screen timeout - Development settings enabled - ADB enabled - Animator duration scale - Boot count Collect behavioral signals via Settings.Secure: - Enabled input methods (keyboards) - Enabled accessibility services These signals help identify device configuration and usage patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 86d69ae commit aba3ff8

File tree

5 files changed

+384
-0
lines changed

5 files changed

+384
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.provider.Settings
5+
import com.maxmind.device.model.BehaviorInfo
6+
7+
/**
8+
* Collects behavioral signals from user configuration.
9+
*
10+
* Gathers information about enabled input methods and accessibility services
11+
* which can indicate device usage patterns.
12+
*/
13+
internal class BehaviorCollector(
14+
private val context: Context,
15+
) {
16+
/**
17+
* Collects behavioral information.
18+
*
19+
* @return [BehaviorInfo] containing enabled services
20+
*/
21+
fun collect(): BehaviorInfo =
22+
BehaviorInfo(
23+
enabledKeyboards = getEnabledKeyboards(),
24+
enabledAccessibilityServices = getEnabledAccessibilityServices(),
25+
)
26+
27+
private fun getEnabledKeyboards(): List<String> =
28+
try {
29+
val enabledInputMethods =
30+
Settings.Secure.getString(
31+
context.contentResolver,
32+
Settings.Secure.ENABLED_INPUT_METHODS,
33+
)
34+
enabledInputMethods?.split(":")?.filter { it.isNotBlank() } ?: emptyList()
35+
} catch (
36+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
37+
e: Exception,
38+
) {
39+
emptyList()
40+
}
41+
42+
private fun getEnabledAccessibilityServices(): List<String> =
43+
try {
44+
val enabledServices =
45+
Settings.Secure.getString(
46+
context.contentResolver,
47+
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
48+
)
49+
enabledServices?.split(":")?.filter { it.isNotBlank() } ?: emptyList()
50+
} catch (
51+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
52+
e: Exception,
53+
) {
54+
emptyList()
55+
}
56+
}

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
@@ -33,6 +33,8 @@ internal class DeviceDataCollector(private val context: Context) {
3333
private val codecCollector = CodecCollector()
3434
private val systemFeaturesCollector = SystemFeaturesCollector(context)
3535
private val networkCollector = NetworkCollector(context)
36+
private val settingsCollector = SettingsCollector(context)
37+
private val behaviorCollector = BehaviorCollector(context)
3638

3739
/**
3840
* Collects current device data.
@@ -53,6 +55,8 @@ internal class DeviceDataCollector(private val context: Context) {
5355
systemFeatures = systemFeaturesCollector.collect(),
5456
network = networkCollector.collect(),
5557
installation = collectInstallationInfo(),
58+
settings = settingsCollector.collect(),
59+
behavior = behaviorCollector.collect(),
5660
locale = collectLocaleInfo(),
5761
// Timezone offset in minutes
5862
timezoneOffset = TimeZone.getDefault().rawOffset / 60000,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.provider.Settings
5+
import com.maxmind.device.model.SystemSettings
6+
7+
/**
8+
* Collects system settings information.
9+
*
10+
* Gathers various system configuration settings that can be useful
11+
* for device fingerprinting.
12+
*/
13+
internal class SettingsCollector(
14+
private val context: Context,
15+
) {
16+
/**
17+
* Collects system settings.
18+
*
19+
* @return [SystemSettings] containing available settings
20+
*/
21+
fun collect(): SystemSettings =
22+
SystemSettings(
23+
screenTimeout = getScreenTimeout(),
24+
developmentSettingsEnabled = getDevelopmentSettingsEnabled(),
25+
adbEnabled = getAdbEnabled(),
26+
animatorDurationScale = getAnimatorDurationScale(),
27+
bootCount = getBootCount(),
28+
)
29+
30+
private fun getScreenTimeout(): Int? =
31+
try {
32+
Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_OFF_TIMEOUT)
33+
} catch (
34+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
35+
e: Exception,
36+
) {
37+
null
38+
}
39+
40+
private fun getDevelopmentSettingsEnabled(): Boolean? =
41+
try {
42+
Settings.Global.getInt(
43+
context.contentResolver,
44+
Settings.Global.DEVELOPMENT_SETTINGS_ENABLED,
45+
) == 1
46+
} catch (
47+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
48+
e: Exception,
49+
) {
50+
null
51+
}
52+
53+
private fun getAdbEnabled(): Boolean? =
54+
try {
55+
Settings.Global.getInt(context.contentResolver, Settings.Global.ADB_ENABLED) == 1
56+
} catch (
57+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
58+
e: Exception,
59+
) {
60+
null
61+
}
62+
63+
private fun getAnimatorDurationScale(): Float? =
64+
try {
65+
Settings.Global.getFloat(
66+
context.contentResolver,
67+
Settings.Global.ANIMATOR_DURATION_SCALE,
68+
)
69+
} catch (
70+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
71+
e: Exception,
72+
) {
73+
null
74+
}
75+
76+
private fun getBootCount(): Int? =
77+
try {
78+
Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT)
79+
} catch (
80+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
81+
e: Exception,
82+
) {
83+
null
84+
}
85+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.ContentResolver
4+
import android.content.Context
5+
import android.provider.Settings
6+
import io.mockk.every
7+
import io.mockk.mockk
8+
import io.mockk.mockkStatic
9+
import io.mockk.unmockkStatic
10+
import org.junit.jupiter.api.AfterEach
11+
import org.junit.jupiter.api.Assertions.assertEquals
12+
import org.junit.jupiter.api.Assertions.assertNotNull
13+
import org.junit.jupiter.api.Assertions.assertTrue
14+
import org.junit.jupiter.api.BeforeEach
15+
import org.junit.jupiter.api.Test
16+
17+
internal class BehaviorCollectorTest {
18+
private lateinit var mockContext: Context
19+
private lateinit var mockContentResolver: ContentResolver
20+
private lateinit var collector: BehaviorCollector
21+
22+
@BeforeEach
23+
internal fun setUp() {
24+
mockContext = mockk(relaxed = true)
25+
mockContentResolver = mockk(relaxed = true)
26+
every { mockContext.contentResolver } returns mockContentResolver
27+
28+
mockkStatic(Settings.Secure::class)
29+
30+
collector = BehaviorCollector(mockContext)
31+
}
32+
33+
@AfterEach
34+
internal fun tearDown() {
35+
unmockkStatic(Settings.Secure::class)
36+
}
37+
38+
@Test
39+
internal fun `collect returns enabled keyboards`() {
40+
val keyboards =
41+
"com.google.android.inputmethod.latin/com.android.inputmethod.latin" +
42+
".LatinIME:com.swiftkey.swiftkey/com.touchtype.KeyboardService"
43+
every {
44+
Settings.Secure.getString(mockContentResolver, Settings.Secure.ENABLED_INPUT_METHODS)
45+
} returns keyboards
46+
47+
val result = collector.collect()
48+
49+
assertNotNull(result)
50+
assertEquals(2, result.enabledKeyboards.size)
51+
assertTrue(
52+
result.enabledKeyboards.contains(
53+
"com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME",
54+
),
55+
)
56+
assertTrue(
57+
result.enabledKeyboards.contains("com.swiftkey.swiftkey/com.touchtype.KeyboardService"),
58+
)
59+
}
60+
61+
@Test
62+
internal fun `collect returns enabled accessibility services`() {
63+
every {
64+
Settings.Secure.getString(
65+
mockContentResolver,
66+
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
67+
)
68+
} returns "com.example.accessibility/com.example.AccessibilityService"
69+
70+
val result = collector.collect()
71+
72+
assertNotNull(result)
73+
assertEquals(1, result.enabledAccessibilityServices.size)
74+
assertEquals(
75+
"com.example.accessibility/com.example.AccessibilityService",
76+
result.enabledAccessibilityServices[0],
77+
)
78+
}
79+
80+
@Test
81+
internal fun `collect returns empty lists when no services enabled`() {
82+
every {
83+
Settings.Secure.getString(mockContentResolver, Settings.Secure.ENABLED_INPUT_METHODS)
84+
} returns null
85+
every {
86+
Settings.Secure.getString(
87+
mockContentResolver,
88+
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
89+
)
90+
} returns null
91+
92+
val result = collector.collect()
93+
94+
assertNotNull(result)
95+
assertTrue(result.enabledKeyboards.isEmpty())
96+
assertTrue(result.enabledAccessibilityServices.isEmpty())
97+
}
98+
99+
@Test
100+
internal fun `collect filters out blank entries`() {
101+
every {
102+
Settings.Secure.getString(mockContentResolver, Settings.Secure.ENABLED_INPUT_METHODS)
103+
} returns "com.example.keyboard:::"
104+
105+
val result = collector.collect()
106+
107+
assertNotNull(result)
108+
assertEquals(1, result.enabledKeyboards.size)
109+
assertEquals("com.example.keyboard", result.enabledKeyboards[0])
110+
}
111+
112+
@Test
113+
internal fun `collect handles exception gracefully`() {
114+
every {
115+
Settings.Secure.getString(mockContentResolver, any())
116+
} throws SecurityException("Permission denied")
117+
118+
val result = collector.collect()
119+
120+
assertNotNull(result)
121+
assertTrue(result.enabledKeyboards.isEmpty())
122+
assertTrue(result.enabledAccessibilityServices.isEmpty())
123+
}
124+
}

0 commit comments

Comments
 (0)