Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,11 @@ class NetworkChangeManager(
val isValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
Log.v(TAG, "Network capabilities changed: internet=$hasInternet, validated=$isValidated")

// Compute transport class and emit if it changed since last emission.
// `onCapabilitiesChanged` fires after `onAvailable` and again whenever a
// capability flips (validation, metered, etc.) — the last-value cache
// collapses those into a single `onTransportChanged` per actual transport
// transition.
emitTransportIfChanged(currentTransportOf(networkCapabilities))
// The callback fires for every network matching NET_CAPABILITY_INTERNET,
// not just the default route — classify by activeNetwork so a cellular
// backup network's capability update doesn't masquerade as a transport
// change while Wi-Fi is still the default.
emitTransportIfChanged(currentTransportOf(connectivityManager))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,10 @@ class NetworkChangeManagerTest {
val wifiCaps = mockNetworkCapabilities(wifi = true)
val cellCaps = mockNetworkCapabilities(cellular = true)

every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns wifiCaps
callbackSlot.captured.onCapabilitiesChanged(network, wifiCaps)
every { connectivityManager.getNetworkCapabilities(network) } returns cellCaps
callbackSlot.captured.onCapabilitiesChanged(network, cellCaps)

assertEquals(
Expand All @@ -311,7 +314,10 @@ class NetworkChangeManagerTest {
val wifiCaps = mockNetworkCapabilities(wifi = true)
val cellCaps = mockNetworkCapabilities(cellular = true)

every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns wifiCaps
callbackSlot.captured.onCapabilitiesChanged(network, wifiCaps)
every { connectivityManager.getNetworkCapabilities(network) } returns cellCaps
callbackSlot.captured.onCapabilitiesChanged(network, cellCaps)
// Same transport class fired again — should be suppressed by the last-value cache.
callbackSlot.captured.onCapabilitiesChanged(network, cellCaps)
Expand All @@ -335,10 +341,10 @@ class NetworkChangeManagerTest {
)
mgr.start()
val network = mockk<android.net.Network>(relaxed = true)
callbackSlot.captured.onCapabilitiesChanged(
network,
mockNetworkCapabilities(wifi = true),
)
val wifiCaps = mockNetworkCapabilities(wifi = true)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns wifiCaps
callbackSlot.captured.onCapabilitiesChanged(network, wifiCaps)
// Active network goes null — simulates last network gone.
every { connectivityManager.activeNetwork } returns null
callbackSlot.captured.onLost(network)
Expand All @@ -362,10 +368,45 @@ class NetworkChangeManagerTest {
)
mgr.start()
val network = mockk<android.net.Network>(relaxed = true)
callbackSlot.captured.onCapabilitiesChanged(
network,
mockNetworkCapabilities(ethernet = true),
)
val ethCaps = mockNetworkCapabilities(ethernet = true)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns ethCaps
callbackSlot.captured.onCapabilitiesChanged(network, ethCaps)
assertEquals(listOf(CurrentTransport.WIFI_LIKE), transports)
mgr.stop()
}

@Suppress("NoRelaxedMocks")
@Test
fun `onCapabilitiesChanged cellular backup on wifi default does not flip transport`() {
// Dual-radio case: Wi-Fi is the default route, cellular backup is up
// simultaneously. Android's NET_CAPABILITY_INTERNET callback fires for
// every matching network, including cellular's capability updates. The
// production code must classify off `activeNetwork`, not the per-callback
// capabilities, so the cellular update doesn't masquerade as a transport
// change while Wi-Fi is still the default.
val transports = mutableListOf<CurrentTransport>()
val mgr =
NetworkChangeManager(
context = context,
lockManager = lockManager,
onTransportChanged = { transports.add(it) },
)
mgr.start()
val wifiNetwork = mockk<android.net.Network>(relaxed = true)
val cellularNetwork = mockk<android.net.Network>(relaxed = true)
val wifiCaps = mockNetworkCapabilities(wifi = true)
val cellCaps = mockNetworkCapabilities(cellular = true)

every { connectivityManager.activeNetwork } returns wifiNetwork
every { connectivityManager.getNetworkCapabilities(wifiNetwork) } returns wifiCaps
every { connectivityManager.getNetworkCapabilities(cellularNetwork) } returns cellCaps

callbackSlot.captured.onCapabilitiesChanged(wifiNetwork, wifiCaps)
// Cellular backup network reports a capability update; activeNetwork is
// still the Wi-Fi mock, so transport must remain WIFI_LIKE.
callbackSlot.captured.onCapabilitiesChanged(cellularNetwork, cellCaps)

assertEquals(listOf(CurrentTransport.WIFI_LIKE), transports)
mgr.stop()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import app.cash.turbine.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
Expand Down Expand Up @@ -65,18 +66,25 @@ class ApkSharingViewModelTest {
}

@Test
fun `viewModel init triggers startServer which sets error state in test environment`() =
fun `viewModel init triggers startServer which reaches a terminal sharing-state`() =
runTest(testDispatcher) {
val viewModel = ApkSharingViewModel(application)
advanceUntilIdle()

viewModel.state.test(timeout = 5.seconds) {
val state = awaitItem()
// In Robolectric, either sourceDir doesn't exist or there's no WiFi,
// so we expect an error state or the default non-running state
assertFalse(state.isServerRunning)
cancelAndIgnoreRemainingEvents()
// The previous assertion (state.isServerRunning == false) was environment-dependent:
// on Linux CI getLocalIpAddress() can return a non-loopback IPv4 and the server
// actually binds, flipping the assertion red (issue #883). Wait for the post-init
// flow to settle into one of the three terminal shapes — production code is
// environment-aware, so accept any of them rather than pinning a specific value.
//
// Note: avoid withTimeout here — its virtual-time deadline races the real-IO
// bind in launchHttpServer; rely on runTest's outer real-time timeout instead.
viewModel.state.first { state ->
state.errorMessage != null ||
state.needsHotspotPermission ||
(state.isServerRunning && state.downloadUrl != null)
}
// Reaching here means a terminal state was emitted (Flow.first contract);
// if the predicate is never satisfied, runTest's outer real-time deadline fires.
}

@Test
Expand Down
Loading