PM-20516: Update NetworkConnectionManager (#5085)

This commit is contained in:
David Perez
2025-04-23 14:52:29 -05:00
committed by GitHub
parent e4d0c48eed
commit 88b0fe59bb
9 changed files with 449 additions and 32 deletions

View File

@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -21,6 +22,9 @@ class BitwardenApplication : Application() {
@Inject
lateinit var logsManager: LogsManager
@Inject
lateinit var networkConnectionManager: NetworkConnectionManager
@Inject
lateinit var networkConfigManager: NetworkConfigManager

View File

@@ -259,8 +259,10 @@ object PlatformManagerModule {
@Singleton
fun provideNetworkConnectionManager(
application: Application,
dispatcherManager: DispatcherManager,
): NetworkConnectionManager = NetworkConnectionManagerImpl(
context = application.applicationContext,
dispatcherManager = dispatcherManager,
)
@Provides

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* A representation of the current network connection.
*/
sealed class NetworkConnection {
/**
* Currently not connected to the internet.
*/
data object None : NetworkConnection()
/**
* Currently connected to the internet via WiFi with a signal [strength] indication.
*/
data class Wifi(
val strength: NetworkSignalStrength,
) : NetworkConnection()
/**
* Currently connected to the internet via cellular connection.
*/
data object Cellular : NetworkConnection()
/**
* Currently connected to the internet via an unknown connection.
*/
data object Other : NetworkConnection()
}

View File

@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* An indicator of the signal strength for a network connection.
*/
enum class NetworkSignalStrength {
EXCELLENT,
GOOD,
FAIR,
WEAK,
NONE,
UNKNOWN,
}

View File

@@ -1,5 +1,8 @@
package com.x8bit.bitwarden.data.platform.manager.network
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import kotlinx.coroutines.flow.StateFlow
/**
* Manager to detect and handle changes to network connectivity.
*/
@@ -9,4 +12,21 @@ interface NetworkConnectionManager {
* available.
*/
val isNetworkConnected: Boolean
/**
* Emits `true` when the application has a network connection and access to the Internet is
* available.
*/
val isNetworkConnectedFlow: StateFlow<Boolean>
/**
* Returns the current network connection.
*/
val networkConnection: NetworkConnection
/**
* Emits the current [NetworkConnection] indicating what type of network the app is currently
* using to connect to the internet.
*/
val networkConnectionFlow: StateFlow<NetworkConnection>
}

View File

@@ -2,21 +2,153 @@ package com.x8bit.bitwarden.data.platform.manager.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.SIGNAL_STRENGTH_UNSPECIFIED
import android.net.NetworkRequest
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.manager.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.model.NetworkSignalStrength
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Primary implementation of [NetworkConnectionManager].
*/
class NetworkConnectionManagerImpl(
context: Context,
dispatcherManager: DispatcherManager,
) : NetworkConnectionManager {
private val unconfinedScope = CoroutineScope(context = dispatcherManager.unconfined)
private val networkChangeCallback = ConnectionChangeCallback()
private val connectivityManager: ConnectivityManager = context
.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
init {
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build(),
networkChangeCallback,
)
}
override val isNetworkConnected: Boolean
get() = connectivityManager
.getNetworkCapabilities(connectivityManager.activeNetwork)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
override val isNetworkConnectedFlow: StateFlow<Boolean> =
networkChangeCallback
.connectionChangeFlow
.map { isNetworkConnected }
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = isNetworkConnected,
)
override val networkConnection: NetworkConnection
get() = connectivityManager
.getNetworkCapabilities(connectivityManager.activeNetwork)
.networkConnection
override val networkConnectionFlow: StateFlow<NetworkConnection> = networkChangeCallback
.connectionChangeFlow
.map { _ -> networkConnection }
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = networkConnection,
)
/**
* A callback used to monitor the connection of a [Network].
*/
private class ConnectionChangeCallback : ConnectivityManager.NetworkCallback() {
private val mutableConnectionState: MutableSharedFlow<Unit> = bufferedMutableSharedFlow()
/**
* A [StateFlow] that emits when the connection state to a network changes.
*/
val connectionChangeFlow: SharedFlow<Unit> = mutableConnectionState.asSharedFlow()
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
mutableConnectionState.tryEmit(Unit)
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
super.onLinkPropertiesChanged(network, linkProperties)
mutableConnectionState.tryEmit(Unit)
}
override fun onAvailable(network: Network) {
super.onAvailable(network)
mutableConnectionState.tryEmit(Unit)
}
override fun onLost(network: Network) {
super.onLost(network)
mutableConnectionState.tryEmit(Unit)
}
}
}
/**
* Converts the [NetworkCapabilities] to a [NetworkConnection].
*/
private val NetworkCapabilities?.networkConnection: NetworkConnection
get() = this
?.let {
if (it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
NetworkConnection.Wifi(it.networkStrength)
} else if (it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
NetworkConnection.Cellular
} else {
NetworkConnection.Other
}
}
?: NetworkConnection.None
/**
* Converts an integer value to an enum signal strength based on the RSSI standard.
*
* * -50 dBm: Excellent signal
* * -60 to -75 dBm: Good signal
* * -76 to -90 dBm: Fair signal
* * -91 to -110 dBm: Weak signal
* * -110 dBm and below: No signal
*/
@Suppress("MagicNumber")
private val NetworkCapabilities.networkStrength: NetworkSignalStrength
get() {
val strength = this.signalStrength
return when {
(strength <= SIGNAL_STRENGTH_UNSPECIFIED) -> NetworkSignalStrength.UNKNOWN
(strength <= -110) -> NetworkSignalStrength.NONE
(strength <= -91) -> NetworkSignalStrength.WEAK
(strength <= -76) -> NetworkSignalStrength.FAIR
(strength <= -60) -> NetworkSignalStrength.GOOD
else -> NetworkSignalStrength.EXCELLENT
}
}

View File

@@ -4,20 +4,67 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import app.cash.turbine.test
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.model.NetworkSignalStrength
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Assertions
import io.mockk.mockkConstructor
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkConstructor
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class NetworkConnectionManagerTest {
private val networkCallback = slot<ConnectivityManager.NetworkCallback>()
private val connectivityManager = mockk<ConnectivityManager> {
every {
registerNetworkCallback(any<NetworkRequest>(), capture(networkCallback))
} just runs
every { activeNetwork } returns null
every { getNetworkCapabilities(any()) } returns null
}
private val context = mockk<Context> {
every { applicationContext } returns this
every { getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
}
private val fakeDispatcherManager = FakeDispatcherManager()
private lateinit var networkConnectionManager: NetworkConnectionManagerImpl
@BeforeEach
fun setup() {
mockkConstructor(NetworkRequest.Builder::class)
val builder = mockk<NetworkRequest.Builder> {
every { addTransportType(any()) } returns this
every { build() } returns mockk()
}
every { anyConstructed<NetworkRequest.Builder>().addCapability(any()) } returns builder
networkConnectionManager = NetworkConnectionManagerImpl(
context = context,
dispatcherManager = fakeDispatcherManager,
)
}
@AfterEach
fun tearDown() {
unmockkConstructor(NetworkRequest.Builder::class)
}
@Test
fun `isNetworkConnected should return false if no active network`() {
val connectivityManager: ConnectivityManager = mockk {
every { activeNetwork } returns null
every { getNetworkCapabilities(any()) } returns null
}
val networkConnectionManager = createNetworkConnectionManager(connectivityManager)
Assertions.assertFalse(networkConnectionManager.isNetworkConnected)
every { connectivityManager.activeNetwork } returns null
every { connectivityManager.getNetworkCapabilities(any()) } returns null
assertFalse(networkConnectionManager.isNetworkConnected)
}
@Test
@@ -26,12 +73,9 @@ class NetworkConnectionManagerTest {
val networkCapabilities: NetworkCapabilities = mockk {
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false
}
val connectivityManager: ConnectivityManager = mockk {
every { activeNetwork } returns network
every { getNetworkCapabilities(network) } returns networkCapabilities
}
val networkConnectionManager = createNetworkConnectionManager(connectivityManager)
Assertions.assertFalse(networkConnectionManager.isNetworkConnected)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
assertFalse(networkConnectionManager.isNetworkConnected)
}
@Test
@@ -40,24 +84,164 @@ class NetworkConnectionManagerTest {
val networkCapabilities: NetworkCapabilities = mockk {
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
}
val connectivityManager: ConnectivityManager = mockk {
every { activeNetwork } returns network
every { getNetworkCapabilities(network) } returns networkCapabilities
}
val networkConnectionManager = createNetworkConnectionManager(connectivityManager)
Assertions.assertTrue(networkConnectionManager.isNetworkConnected)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities
assertTrue(networkConnectionManager.isNetworkConnected)
}
private fun createNetworkConnectionManager(
connectivityManager: ConnectivityManager,
): NetworkConnectionManager {
val appContext: Context = mockk {
every { getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
}
val context: Context = mockk {
every { applicationContext } returns appContext
@Test
fun `isNetworkConnectedFlow should emit changes to the network state`() = runTest {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { signalStrength } returns -75
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
every { hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns true
}
every { connectivityManager.activeNetwork } returns network
return NetworkConnectionManagerImpl(context)
networkConnectionManager
.isNetworkConnectedFlow
.test {
assertFalse(awaitItem())
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
networkCallback.captured.onLost(mockk())
assertTrue(awaitItem())
every {
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} returns false
networkCallback.captured.onLinkPropertiesChanged(mockk(), mockk())
assertFalse(awaitItem())
}
}
@Test
fun `networkConnection should return None if no active network`() {
every { connectivityManager.activeNetwork } returns null
every { connectivityManager.getNetworkCapabilities(any()) } returns null
assertEquals(NetworkConnection.None, networkConnectionManager.networkConnection)
}
@Test
fun `networkConnection should return none Wifi if active network has wifi transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { signalStrength } returns -120
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.NONE),
networkConnectionManager.networkConnection,
)
}
@Test
fun `networkConnection should return weak Wifi if active network has wifi transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { signalStrength } returns -100
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.WEAK),
networkConnectionManager.networkConnection,
)
}
@Test
fun `networkConnection should return fair Wifi if active network has wifi transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { signalStrength } returns -90
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.FAIR),
networkConnectionManager.networkConnection,
)
}
@Test
fun `networkConnection should return good Wifi if active network has wifi transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { signalStrength } returns -75
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.GOOD),
networkConnectionManager.networkConnection,
)
}
@Test
fun `networkConnection should return excellent Wifi if active network has wifi transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true
every { signalStrength } returns -50
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.EXCELLENT),
networkConnectionManager.networkConnection,
)
}
@Test
fun `networkConnection should return Cellular if active network has cellular transport`() {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
every { hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns true
}
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
assertEquals(NetworkConnection.Cellular, networkConnectionManager.networkConnection)
}
@Test
fun `networkConnectionFlow should emit changes to the network state`() = runTest {
val network = mockk<Network>()
val capabilities = mockk<NetworkCapabilities> {
every { signalStrength } returns -75
every { hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true
every { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns false
every { hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) } returns true
}
every { connectivityManager.activeNetwork } returns network
networkConnectionManager
.networkConnectionFlow
.test {
assertEquals(NetworkConnection.None, awaitItem())
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
networkCallback.captured.onCapabilitiesChanged(mockk(), mockk())
assertEquals(NetworkConnection.Cellular, awaitItem())
every {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} returns true
every {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
} returns false
networkCallback.captured.onAvailable(mockk())
assertEquals(
NetworkConnection.Wifi(strength = NetworkSignalStrength.GOOD),
awaitItem(),
)
}
}
}

View File

@@ -1,7 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeNetworkConnectionManager(
override val isNetworkConnected: Boolean,
) : NetworkConnectionManager
isNetworkConnected: Boolean,
networkConnection: NetworkConnection,
) : NetworkConnectionManager {
private val mutableIsNetworkConnectedStateFlow = MutableStateFlow<Boolean>(isNetworkConnected)
private val mutableNetworkConnectionStateFlow = MutableStateFlow(networkConnection)
var fakeIsNetworkConnected: Boolean
get() = mutableIsNetworkConnectedStateFlow.value
set(value) {
mutableIsNetworkConnectedStateFlow.value = value
}
var fakeNetworkConnection: NetworkConnection
get() = mutableNetworkConnectionStateFlow.value
set(value) {
mutableNetworkConnectionStateFlow.value = value
}
override val isNetworkConnected: Boolean get() = fakeIsNetworkConnected
override val isNetworkConnectedFlow: StateFlow<Boolean> =
mutableIsNetworkConnectedStateFlow.asStateFlow()
override val networkConnection: NetworkConnection get() = fakeNetworkConnection
override val networkConnectionFlow: StateFlow<NetworkConnection> =
mutableNetworkConnectionStateFlow.asStateFlow()
}

View File

@@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.NetworkConnection
import com.x8bit.bitwarden.data.platform.manager.util.FakeNetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
@@ -1186,7 +1187,10 @@ class EnterpriseSignOnViewModelTest : BaseViewModelTest() {
environmentRepository = environmentRepository,
featureFlagManager = featureFlagManager,
generatorRepository = generatorRepository,
networkConnectionManager = FakeNetworkConnectionManager(isNetworkConnected),
networkConnectionManager = FakeNetworkConnectionManager(
isNetworkConnected = isNetworkConnected,
networkConnection = NetworkConnection.Cellular,
),
savedStateHandle = savedStateHandle,
)
.also {