Skip to content

Commit 86d69ae

Browse files
oschwaldclaude
andcommitted
feat: Add network context collection with WiFi details
Collect network information via ConnectivityManager and WifiManager: - Connection type (wifi, cellular, ethernet, bluetooth, vpn) - Metered status - Downstream bandwidth estimate - WiFi-specific: frequency, link speed, signal strength (RSSI) Adds ACCESS_WIFI_STATE permission (ACCESS_NETWORK_STATE was already present). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 30dbf7f commit 86d69ae

File tree

4 files changed

+255
-0
lines changed

4 files changed

+255
-0
lines changed

device-sdk/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
<!-- Required permissions for device data collection -->
55
<uses-permission android:name="android.permission.INTERNET" />
66
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
78

89
</manifest>

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
@@ -32,6 +32,7 @@ internal class DeviceDataCollector(private val context: Context) {
3232
private val cameraCollector = CameraCollector(context)
3333
private val codecCollector = CodecCollector()
3434
private val systemFeaturesCollector = SystemFeaturesCollector(context)
35+
private val networkCollector = NetworkCollector(context)
3536

3637
/**
3738
* Collects current device data.
@@ -50,6 +51,7 @@ internal class DeviceDataCollector(private val context: Context) {
5051
cameras = cameraCollector.collect(),
5152
codecs = codecCollector.collect(),
5253
systemFeatures = systemFeaturesCollector.collect(),
54+
network = networkCollector.collect(),
5355
installation = collectInstallationInfo(),
5456
locale = collectLocaleInfo(),
5557
// Timezone offset in minutes
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.net.ConnectivityManager
5+
import android.net.NetworkCapabilities
6+
import android.net.wifi.WifiInfo
7+
import android.net.wifi.WifiManager
8+
import android.os.Build
9+
import com.maxmind.device.model.NetworkInfo
10+
11+
/**
12+
* Collects network context information.
13+
*
14+
* Requires ACCESS_NETWORK_STATE and ACCESS_WIFI_STATE permissions.
15+
*/
16+
internal class NetworkCollector(
17+
private val context: Context,
18+
) {
19+
/**
20+
* Collects current network information.
21+
*
22+
* @return [NetworkInfo] containing network context, or null if unavailable
23+
*/
24+
fun collect(): NetworkInfo? {
25+
return try {
26+
val connectivityManager =
27+
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
28+
?: return null
29+
30+
val network = connectivityManager.activeNetwork ?: return null
31+
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return null
32+
33+
val connectionType = getConnectionType(capabilities)
34+
val isMetered =
35+
!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
36+
val downstreamBandwidth = capabilities.linkDownstreamBandwidthKbps
37+
38+
// Get WiFi-specific info if connected to WiFi
39+
val (wifiFrequency, wifiLinkSpeed, wifiSignalStrength) =
40+
if (connectionType == CONNECTION_TYPE_WIFI) {
41+
getWifiInfo()
42+
} else {
43+
Triple(null, null, null)
44+
}
45+
46+
NetworkInfo(
47+
connectionType = connectionType,
48+
isMetered = isMetered,
49+
linkDownstreamBandwidthKbps = downstreamBandwidth,
50+
wifiFrequency = wifiFrequency,
51+
wifiLinkSpeed = wifiLinkSpeed,
52+
wifiSignalStrength = wifiSignalStrength,
53+
)
54+
} catch (
55+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
56+
e: Exception,
57+
) {
58+
// Network info may fail on some devices or when permissions are missing
59+
null
60+
}
61+
}
62+
63+
private fun getConnectionType(capabilities: NetworkCapabilities): String =
64+
when {
65+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> CONNECTION_TYPE_WIFI
66+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> CONNECTION_TYPE_CELLULAR
67+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> CONNECTION_TYPE_ETHERNET
68+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> CONNECTION_TYPE_BLUETOOTH
69+
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> CONNECTION_TYPE_VPN
70+
else -> CONNECTION_TYPE_OTHER
71+
}
72+
73+
@Suppress("DEPRECATION")
74+
private fun getWifiInfo(): Triple<Int?, Int?, Int?> {
75+
val wifiManager =
76+
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
77+
?: return Triple(null, null, null)
78+
79+
val wifiInfo: WifiInfo =
80+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
81+
// On Android 12+, connectionInfo is deprecated
82+
// We would need to use NetworkCallback for WiFi info
83+
// For simplicity, we still try connectionInfo but it may be empty
84+
wifiManager.connectionInfo
85+
} else {
86+
wifiManager.connectionInfo
87+
}
88+
89+
val frequency = if (wifiInfo.frequency > 0) wifiInfo.frequency else null
90+
val linkSpeed = if (wifiInfo.linkSpeed > 0) wifiInfo.linkSpeed else null
91+
val rssi = if (wifiInfo.rssi != INVALID_RSSI) wifiInfo.rssi else null
92+
93+
return Triple(frequency, linkSpeed, rssi)
94+
}
95+
96+
internal companion object {
97+
const val CONNECTION_TYPE_WIFI = "wifi"
98+
const val CONNECTION_TYPE_CELLULAR = "cellular"
99+
const val CONNECTION_TYPE_ETHERNET = "ethernet"
100+
const val CONNECTION_TYPE_BLUETOOTH = "bluetooth"
101+
const val CONNECTION_TYPE_VPN = "vpn"
102+
const val CONNECTION_TYPE_OTHER = "other"
103+
104+
// Invalid RSSI value (WifiManager.INVALID_RSSI is not available in all API levels)
105+
private const val INVALID_RSSI = -127
106+
}
107+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package com.maxmind.device.collector
2+
3+
import android.content.Context
4+
import android.net.ConnectivityManager
5+
import android.net.Network
6+
import android.net.NetworkCapabilities
7+
import android.net.wifi.WifiInfo
8+
import android.net.wifi.WifiManager
9+
import io.mockk.every
10+
import io.mockk.mockk
11+
import org.junit.jupiter.api.Assertions.assertEquals
12+
import org.junit.jupiter.api.Assertions.assertNotNull
13+
import org.junit.jupiter.api.Assertions.assertNull
14+
import org.junit.jupiter.api.BeforeEach
15+
import org.junit.jupiter.api.Test
16+
17+
internal class NetworkCollectorTest {
18+
private lateinit var mockContext: Context
19+
private lateinit var mockConnectivityManager: ConnectivityManager
20+
private lateinit var mockWifiManager: WifiManager
21+
private lateinit var mockNetwork: Network
22+
private lateinit var mockCapabilities: NetworkCapabilities
23+
private lateinit var collector: NetworkCollector
24+
25+
@BeforeEach
26+
internal fun setUp() {
27+
mockContext = mockk(relaxed = true)
28+
mockConnectivityManager = mockk(relaxed = true)
29+
mockWifiManager = mockk(relaxed = true)
30+
mockNetwork = mockk(relaxed = true)
31+
mockCapabilities = mockk(relaxed = true)
32+
33+
every { mockContext.getSystemService(Context.CONNECTIVITY_SERVICE) } returns
34+
mockConnectivityManager
35+
every { mockContext.applicationContext } returns mockContext
36+
every { mockContext.getSystemService(Context.WIFI_SERVICE) } returns mockWifiManager
37+
every { mockConnectivityManager.activeNetwork } returns mockNetwork
38+
every { mockConnectivityManager.getNetworkCapabilities(mockNetwork) } returns
39+
mockCapabilities
40+
41+
collector = NetworkCollector(mockContext)
42+
}
43+
44+
@Test
45+
internal fun `collect returns wifi connection info`() {
46+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
47+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns false
48+
every {
49+
mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
50+
} returns true
51+
every { mockCapabilities.linkDownstreamBandwidthKbps } returns 100000
52+
53+
val mockWifiInfo =
54+
mockk<WifiInfo> {
55+
every { frequency } returns 5180
56+
every { linkSpeed } returns 866
57+
every { rssi } returns -50
58+
}
59+
every { mockWifiManager.connectionInfo } returns mockWifiInfo
60+
61+
val result = collector.collect()
62+
63+
assertNotNull(result)
64+
assertEquals("wifi", result?.connectionType)
65+
assertEquals(false, result?.isMetered)
66+
assertEquals(100000, result?.linkDownstreamBandwidthKbps)
67+
assertEquals(5180, result?.wifiFrequency)
68+
assertEquals(866, result?.wifiLinkSpeed)
69+
assertEquals(-50, result?.wifiSignalStrength)
70+
}
71+
72+
@Test
73+
internal fun `collect returns cellular connection info`() {
74+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
75+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns true
76+
every {
77+
mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
78+
} returns false
79+
every { mockCapabilities.linkDownstreamBandwidthKbps } returns 50000
80+
81+
val result = collector.collect()
82+
83+
assertNotNull(result)
84+
assertEquals("cellular", result?.connectionType)
85+
assertEquals(true, result?.isMetered)
86+
assertEquals(50000, result?.linkDownstreamBandwidthKbps)
87+
assertNull(result?.wifiFrequency)
88+
assertNull(result?.wifiLinkSpeed)
89+
assertNull(result?.wifiSignalStrength)
90+
}
91+
92+
@Test
93+
internal fun `collect returns null when no active network`() {
94+
every { mockConnectivityManager.activeNetwork } returns null
95+
96+
val result = collector.collect()
97+
98+
assertNull(result)
99+
}
100+
101+
@Test
102+
internal fun `collect returns null when no capabilities`() {
103+
every { mockConnectivityManager.getNetworkCapabilities(mockNetwork) } returns null
104+
105+
val result = collector.collect()
106+
107+
assertNull(result)
108+
}
109+
110+
@Test
111+
internal fun `collect returns null when ConnectivityManager unavailable`() {
112+
every { mockContext.getSystemService(Context.CONNECTIVITY_SERVICE) } returns null
113+
val collectorWithNoConnectivity = NetworkCollector(mockContext)
114+
115+
val result = collectorWithNoConnectivity.collect()
116+
117+
assertNull(result)
118+
}
119+
120+
@Test
121+
internal fun `collect handles ethernet connection`() {
122+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
123+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns false
124+
every { mockCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } returns true
125+
every {
126+
mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
127+
} returns true
128+
every { mockCapabilities.linkDownstreamBandwidthKbps } returns 1000000
129+
130+
val result = collector.collect()
131+
132+
assertNotNull(result)
133+
assertEquals("ethernet", result?.connectionType)
134+
}
135+
136+
@Test
137+
internal fun `connection type constants have correct values`() {
138+
assertEquals("wifi", NetworkCollector.CONNECTION_TYPE_WIFI)
139+
assertEquals("cellular", NetworkCollector.CONNECTION_TYPE_CELLULAR)
140+
assertEquals("ethernet", NetworkCollector.CONNECTION_TYPE_ETHERNET)
141+
assertEquals("bluetooth", NetworkCollector.CONNECTION_TYPE_BLUETOOTH)
142+
assertEquals("vpn", NetworkCollector.CONNECTION_TYPE_VPN)
143+
assertEquals("other", NetworkCollector.CONNECTION_TYPE_OTHER)
144+
}
145+
}

0 commit comments

Comments
 (0)