Skip to content

Commit f347bb1

Browse files
authored
fix: update network listener to cover cases where network is blocked (#267)
* update network listener to cover cases where network is blocked * add capabilities check for more accurate validation of connection
1 parent 6f17917 commit f347bb1

File tree

3 files changed

+241
-37
lines changed

3 files changed

+241
-37
lines changed

android/src/main/java/com/amplitude/android/plugins/AndroidNetworkConnectivityCheckerPlugin.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ class AndroidNetworkConnectivityCheckerPlugin : Plugin {
3838
}
3939
}
4040
networkListener = AndroidNetworkListener(
41-
(amplitude.configuration as Configuration).context,
42-
amplitude.logger
41+
context = (amplitude.configuration as Configuration).context,
42+
logger = amplitude.logger,
43+
networkCallback = networkChangeHandler
4344
)
44-
networkListener.setNetworkChangeCallback(networkChangeHandler)
4545
networkListener.startListening()
4646
}
4747

android/src/main/java/com/amplitude/android/utilities/AndroidNetworkListener.kt

Lines changed: 124 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,34 @@ import android.content.Context
66
import android.content.Intent
77
import android.content.IntentFilter
88
import android.net.ConnectivityManager
9+
import android.net.ConnectivityManager.NetworkCallback
910
import android.net.Network
1011
import android.net.NetworkCapabilities
12+
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
13+
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
1114
import android.net.NetworkRequest
12-
import android.os.Build
15+
import android.os.Build.VERSION
16+
import android.os.Build.VERSION_CODES
17+
import androidx.annotation.VisibleForTesting
1318
import com.amplitude.common.Logger
1419

15-
class AndroidNetworkListener(private val context: Context, private val logger: Logger) {
16-
private var networkCallback: NetworkChangeCallback? = null
20+
class AndroidNetworkListener(
21+
private val context: Context,
22+
private val logger: Logger,
23+
private val networkCallback: NetworkChangeCallback,
24+
) {
1725
private var networkCallbackForLowerApiLevels: BroadcastReceiver? = null
18-
private var networkCallbackForHigherApiLevels: ConnectivityManager.NetworkCallback? = null
26+
private var networkCallbackForHigherApiLevels: NetworkCallback? = null
1927

2028
interface NetworkChangeCallback {
2129
fun onNetworkAvailable()
2230

2331
fun onNetworkUnavailable()
2432
}
2533

26-
fun setNetworkChangeCallback(callback: NetworkChangeCallback) {
27-
this.networkCallback = callback
28-
}
29-
3034
fun startListening() {
3135
try {
32-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
36+
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
3337
setupNetworkCallback()
3438
} else {
3539
setupBroadcastReceiver()
@@ -48,29 +52,66 @@ class AndroidNetworkListener(private val context: Context, private val logger: L
4852
@SuppressLint("NewApi", "MissingPermission")
4953
// startListening() checks API level
5054
// ACCESS_NETWORK_STATE permission should be added manually by users to enable this feature
51-
private fun setupNetworkCallback() {
52-
val connectivityManager =
53-
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
54-
val networkRequest =
55-
NetworkRequest.Builder()
56-
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
57-
.build()
58-
59-
networkCallbackForHigherApiLevels =
60-
object : ConnectivityManager.NetworkCallback() {
61-
override fun onAvailable(network: Network) {
62-
networkCallback?.onNetworkAvailable()
63-
}
55+
@VisibleForTesting
56+
internal fun setupNetworkCallback() {
57+
val connectivityManager = context
58+
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
59+
val networkRequest = NetworkRequest.Builder()
60+
.addCapability(NET_CAPABILITY_INTERNET)
61+
.build()
6462

65-
override fun onLost(network: Network) {
66-
networkCallback?.onNetworkUnavailable()
67-
}
63+
networkCallbackForHigherApiLevels = object : NetworkCallback() {
64+
private var networkState: NetworkState? = null
65+
66+
override fun onAvailable(network: Network) {
67+
// A default network is available, set the network state and callback
68+
val capabilities = connectivityManager.getNetworkCapabilities(network)
69+
networkState = NetworkState(
70+
network = network,
71+
networkCallback = networkCallback,
72+
available = capabilities?.available() ?: true,
73+
blocked = false
74+
)
75+
}
76+
77+
override fun onUnavailable() {
78+
// no network is found
79+
networkCallback.onNetworkUnavailable()
6880
}
6981

70-
connectivityManager.registerNetworkCallback(
71-
networkRequest,
72-
networkCallbackForHigherApiLevels!!
73-
)
82+
override fun onLost(network: Network) {
83+
networkState?.update(network, available = false)
84+
}
85+
86+
override fun onCapabilitiesChanged(
87+
network: Network,
88+
networkCapabilities: NetworkCapabilities,
89+
) {
90+
networkState?.update(network, available = networkCapabilities.available())
91+
}
92+
93+
override fun onBlockedStatusChanged(
94+
network: Network,
95+
blocked: Boolean,
96+
) {
97+
networkState?.update(network, blocked = blocked)
98+
}
99+
100+
// Best attempt to check if the network is available
101+
private fun NetworkCapabilities.available(): Boolean {
102+
val validated = if (VERSION.SDK_INT >= VERSION_CODES.M) {
103+
hasCapability(NET_CAPABILITY_VALIDATED)
104+
} else {
105+
true
106+
}
107+
return hasCapability(NET_CAPABILITY_INTERNET) && validated
108+
}
109+
}.also { callbackForHigherApiLevels ->
110+
connectivityManager.registerNetworkCallback(
111+
networkRequest,
112+
callbackForHigherApiLevels
113+
)
114+
}
74115
}
75116

76117
private fun setupBroadcastReceiver() {
@@ -82,15 +123,15 @@ class AndroidNetworkListener(private val context: Context, private val logger: L
82123
intent: Intent,
83124
) {
84125
if (ConnectivityManager.CONNECTIVITY_ACTION == intent.action) {
85-
val connectivityManager =
86-
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
126+
val connectivityManager = context
127+
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
87128
val activeNetwork = connectivityManager.activeNetworkInfo
88129
val isConnected = activeNetwork?.isConnectedOrConnecting == true
89130

90131
if (isConnected) {
91-
networkCallback?.onNetworkAvailable()
132+
networkCallback.onNetworkAvailable()
92133
} else {
93-
networkCallback?.onNetworkUnavailable()
134+
networkCallback.onNetworkUnavailable()
94135
}
95136
}
96137
}
@@ -102,7 +143,7 @@ class AndroidNetworkListener(private val context: Context, private val logger: L
102143

103144
fun stopListening() {
104145
try {
105-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
146+
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
106147
val connectivityManager =
107148
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
108149
networkCallbackForHigherApiLevels?.let {
@@ -127,4 +168,53 @@ class AndroidNetworkListener(private val context: Context, private val logger: L
127168
logger.warn("Error stopping network listener: ${throwable.message}")
128169
}
129170
}
171+
172+
/**
173+
* NetworkState is used to track the state of a network connection.
174+
* It considers the availability and blocked status of the network before notifying the callback.
175+
*
176+
* On initialization, it checks if the network is available and not blocked.
177+
*/
178+
private class NetworkState(
179+
private val network: Network,
180+
private val networkCallback: NetworkChangeCallback,
181+
private var available: Boolean,
182+
private var blocked: Boolean,
183+
) {
184+
init {
185+
notifyNetworkCallback()
186+
}
187+
188+
/**
189+
* Notify the network callback based on the current state of the network.
190+
* Ideally called only when the state changes (e.g. initialized, available or blocked toggled).
191+
*/
192+
private fun notifyNetworkCallback() {
193+
if (available && !blocked) {
194+
networkCallback.onNetworkAvailable()
195+
} else {
196+
networkCallback.onNetworkUnavailable()
197+
}
198+
}
199+
200+
/**
201+
* Update the availability/blocked state and notify the callback if necessary.
202+
* Checks if we're on the same network, else just ignore.
203+
*/
204+
fun update(
205+
network: Network,
206+
available: Boolean = this.available,
207+
blocked: Boolean = this.blocked,
208+
) {
209+
@SuppressLint("NewApi")
210+
if (this.network != network) return
211+
val toggled = this.available != available || this.blocked != blocked
212+
this.available = available
213+
this.blocked = blocked
214+
215+
if (toggled) {
216+
notifyNetworkCallback()
217+
}
218+
}
219+
}
130220
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.amplitude.android.utilities
2+
3+
import android.content.Context
4+
import android.net.ConnectivityManager
5+
import android.net.ConnectivityManager.NetworkCallback
6+
import android.net.Network
7+
import android.net.NetworkCapabilities
8+
import android.net.NetworkRequest
9+
import com.amplitude.common.Logger
10+
import io.mockk.every
11+
import io.mockk.mockk
12+
import io.mockk.mockkConstructor
13+
import io.mockk.slot
14+
import io.mockk.verify
15+
import kotlin.test.Test
16+
import org.junit.Before
17+
import org.junit.jupiter.api.Assertions.assertFalse
18+
import org.junit.jupiter.api.Assertions.assertTrue
19+
20+
class AndroidNetworkListenerTest {
21+
private val fakeContext = mockk<Context>(relaxed = true)
22+
private val fakeLogger = mockk<Logger>(relaxed = true)
23+
private val fakeConnectivityManager = mockk<ConnectivityManager>(relaxed = true)
24+
25+
@Before
26+
fun setup() {
27+
every {
28+
fakeContext.getSystemService(Context.CONNECTIVITY_SERVICE)
29+
} returns fakeConnectivityManager
30+
31+
mockkConstructor(NetworkRequest.Builder::class)
32+
every {
33+
anyConstructed<NetworkRequest.Builder>().addCapability(any()).build()
34+
} returns mockk()
35+
}
36+
37+
@Test
38+
fun `setup network callback should notify states`() {
39+
val networkChangeCallback = object : AndroidNetworkListener.NetworkChangeCallback {
40+
var available = false
41+
override fun onNetworkAvailable() {
42+
available = true
43+
}
44+
45+
override fun onNetworkUnavailable() {
46+
available = false
47+
}
48+
}
49+
val networkListener = AndroidNetworkListener(
50+
context = fakeContext,
51+
logger = fakeLogger,
52+
networkCallback = networkChangeCallback
53+
)
54+
55+
networkListener.setupNetworkCallback()
56+
val networkCallbackSlot = slot<NetworkCallback>()
57+
verify {
58+
fakeConnectivityManager.registerNetworkCallback(
59+
any<NetworkRequest>(), capture(networkCallbackSlot)
60+
)
61+
}
62+
val networkCallback = networkCallbackSlot.captured
63+
val network = mockk<Network>()
64+
val availableCapability = mockk<NetworkCapabilities>() {
65+
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
66+
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true
67+
}
68+
val unavailableCapability = mockk<NetworkCapabilities>() {
69+
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false
70+
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns false
71+
}
72+
every { fakeConnectivityManager.getNetworkCapabilities(network) } returns availableCapability
73+
74+
// available: true, blocked: false
75+
networkCallback.onAvailable(network)
76+
assertTrue(networkChangeCallback.available)
77+
78+
// available: true, blocked: false -> true
79+
networkCallback.onBlockedStatusChanged(network, true)
80+
assertFalse(networkChangeCallback.available)
81+
82+
// available: true, blocked: true (nothing toggled)
83+
networkCallback.onCapabilitiesChanged(network, availableCapability)
84+
assertFalse(networkChangeCallback.available) // still blocked
85+
86+
// available: true, blocked: true -> false
87+
networkCallback.onBlockedStatusChanged(network, false)
88+
assertTrue(networkChangeCallback.available)
89+
90+
// available: true -> false, blocked: false
91+
networkCallback.onCapabilitiesChanged(network, unavailableCapability)
92+
assertFalse(networkChangeCallback.available) // now unavailable
93+
94+
// available: false -> true, blocked: false
95+
networkCallback.onCapabilitiesChanged(network, availableCapability)
96+
assertTrue(networkChangeCallback.available)
97+
98+
// available: true -> false, blocked: false
99+
networkCallback.onLost(network)
100+
assertFalse(networkChangeCallback.available)
101+
102+
// available: false -> true, blocked: false
103+
networkCallback.onCapabilitiesChanged(network, availableCapability)
104+
assertTrue(networkChangeCallback.available) // available again
105+
106+
// available: true, blocked: false (new state)
107+
networkCallback.onAvailable(network)
108+
assertTrue(networkChangeCallback.available)
109+
110+
// N/A
111+
networkCallback.onUnavailable()
112+
assertFalse(networkChangeCallback.available)
113+
}
114+
}

0 commit comments

Comments
 (0)