From a8c599ed4c8d0aa8d6e426a3eb2f29ec9313562b Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:15:38 +0200 Subject: [PATCH 01/23] Add new network monitor and network sample UI in core --- .../android/core/sample/SampleActivity.kt | 94 +++--- .../core/sample/client/StreamClient.kt | 24 +- .../core/sample/ui/ConnectionStateCard.kt | 156 ++++++++++ .../android/core/sample/ui/NetworkInfoCard.kt | 245 +++++++++++++++ .../core/sample/ui/NetworkInfoComponents.kt | 165 +++++++++++ gradle/libs.versions.toml | 2 + stream-android-core/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 3 + .../android/core/api/StreamClient.kt | 19 +- .../StreamAndroidComponentsProvider.kt | 67 +++++ .../connection/network/StreamNetworkInfo.kt | 206 +++++++++++++ .../observers/network/StreamNetworkMonitor.kt | 79 +++++ .../network/StreamNetworkMonitorListener.kt | 49 +++ .../api/socket/monitor/StreamHealthMonitor.kt | 13 +- .../android/core/api/utils/Algebra.kt | 51 ++++ .../core/internal/client/StreamClientImpl.kt | 52 +++- .../StreamAndroidComponentsProviderImpl.kt | 55 ++++ .../network/StreamNetworkMonitorImpl.kt | 279 ++++++++++++++++++ .../network/StreamNetworkMonitorUtils.kt | 62 ++++ .../network/StreamNetworkSignalProcessing.kt | 104 +++++++ .../network/StreamNetworkSnapshotBuilder.kt | 202 +++++++++++++ .../socket/monitor/StreamHealthMonitorImpl.kt | 7 +- .../core/api/StreamClientFactoryTest.kt | 9 + .../android/core/api/utils/AlgebraTest.kt | 106 +++++++ .../internal/client/StreamClientIImplTest.kt | 1 + .../socket/StreamSocketSessionTest.kt | 2 +- 26 files changed, 2010 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index 81a2a45..f5bc4b2 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -21,14 +21,18 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -41,6 +45,8 @@ import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl import io.getstream.android.core.sample.client.createStreamClient +import io.getstream.android.core.sample.ui.ConnectionStateCard +import io.getstream.android.core.sample.ui.NetworkInfoCard import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -48,56 +54,68 @@ import kotlinx.coroutines.runBlocking class SampleActivity : ComponentActivity() { val userId = StreamUserId.fromString("petar") - val streamClient = - createStreamClient( - scope = lifecycleScope, - apiKey = StreamApiKey.fromString("pd67s34fzpgw"), - userId = userId, - wsUrl = - StreamWsUrl.fromString( - "wss://chat-edge-frankfurt-ce1.stream-io-api.com/api/v2/connect" - ), - clientInfoHeader = - StreamHttpClientInfoHeader.create( - product = "android-core", - productVersion = "1.0.0", - os = "Android", - apiLevel = Build.VERSION.SDK_INT, - deviceModel = "Pixel 7 Pro", - app = "Stream Android Core Sample", - appVersion = "1.0.0", - ), - tokenProvider = - object : StreamTokenProvider { - override suspend fun loadToken(userId: StreamUserId): StreamToken { - return StreamToken.fromString( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicGV0YXIifQ.mZFi4iSblaIoyo9JDdcxIkGkwI-tuApeSBawxpz42rs" - ) - } - }, - ) + var streamClient: StreamClient? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val streamClient2 = + createStreamClient( + context = this.applicationContext, + scope = lifecycleScope, + apiKey = StreamApiKey.fromString("pd67s34fzpgw"), + userId = userId, + wsUrl = + StreamWsUrl.fromString( + "wss://chat-edge-frankfurt-ce1.stream-io-api.com/api/v2/connect" + ), + clientInfoHeader = + StreamHttpClientInfoHeader.create( + product = "android-core", + productVersion = "1.0.0", + os = "Android", + apiLevel = Build.VERSION.SDK_INT, + deviceModel = "Pixel 7 Pro", + app = "Stream Android Core Sample", + appVersion = "1.0.0", + ), + tokenProvider = + object : StreamTokenProvider { + override suspend fun loadToken(userId: StreamUserId): StreamToken { + return StreamToken.fromString( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicGV0YXIifQ.mZFi4iSblaIoyo9JDdcxIkGkwI-tuApeSBawxpz42rs" + ) + } + }, + ) + streamClient = streamClient2 lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { streamClient.connect() } + repeatOnLifecycle(Lifecycle.State.RESUMED) { streamClient?.connect() } } enableEdgeToEdge() setContent { StreamandroidcoreTheme { + val scrollState = rememberScrollState() Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Column { - Greeting(name = "Android", modifier = Modifier.padding(innerPadding)) - ClientInfo(streamClient = streamClient) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Greeting(name = "Android") + ClientInfo(streamClient = streamClient2) } } } } } - override fun onPause() { - runBlocking { streamClient.disconnect() } - super.onPause() + override fun onStop() { + runBlocking { streamClient?.disconnect() } + super.onStop() } } @@ -115,6 +133,10 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() + val networkSnapshot = streamClient.networkInfo.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") - Text(text = "Client state: ${state.value}") + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ConnectionStateCard(state = state.value) + NetworkInfoCard(snapshot = networkSnapshot.value) + } } diff --git a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt index c13e859..8b5dcc3 100644 --- a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt +++ b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt @@ -15,9 +15,11 @@ */ package io.getstream.android.core.sample.client +import android.content.Context import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -25,6 +27,7 @@ import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.processing.StreamBatcher import io.getstream.android.core.api.processing.StreamRetryProcessor import io.getstream.android.core.api.processing.StreamSerialProcessingQueue @@ -49,6 +52,7 @@ import kotlinx.coroutines.CoroutineScope * @return A new [createStreamClient] instance. */ fun createStreamClient( + context: Context, scope: CoroutineScope, apiKey: StreamApiKey, userId: StreamUserId, @@ -88,6 +92,23 @@ fun createStreamClient( maxDelayMs = 1_000L, ) + val androidComponentsProvider = StreamAndroidComponentsProvider(context) + val connectivityManager = androidComponentsProvider.connectivityManager().getOrThrow() + val wifiManager = androidComponentsProvider.wifiManager().getOrThrow() + val telephonyManager = androidComponentsProvider.telephonyManager().getOrThrow() + val networkMonitor = + StreamNetworkMonitor( + logger = logProvider.taggedLogger("SCNetworkMonitor"), + scope = scope, + connectivityManager = connectivityManager, + wifiManager = wifiManager, + telephonyManager = telephonyManager, + subscriptionManager = + StreamSubscriptionManager( + logger = logProvider.taggedLogger("SCNetworkMonitorSubscriptions") + ), + ) + return StreamClient( scope = scope, apiKey = apiKey, @@ -105,6 +126,7 @@ fun createStreamClient( connectionIdHolder = connectionIdHolder, socketFactory = socketFactory, healthMonitor = healthMonitor, + networkMonitor = networkMonitor, serializationConfig = StreamClientSerializationConfig.default( object : StreamEventSerialization { @@ -113,6 +135,6 @@ fun createStreamClient( override fun deserialize(raw: String): Result = Result.success(Unit) } ), - batcher = batcher, + batcher = batcher ) } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt new file mode 100644 index 0000000..e55ce03 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.StreamConnectedUser +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme +import java.util.Date + +@Composable +public fun ConnectionStateCard(state: StreamConnectionState) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Connection", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + val statusLabel = connectionStatusLabel(state) + val statusState = connectionStatusState(state) + val statusAlert = statusState == false + + NetworkFactRow( + label = "Status", + value = statusLabel, + state = statusState, + alert = statusAlert, + ) + + when (state) { + is StreamConnectionState.Connected -> { + Divider() + NetworkFactRow( + label = "User", + value = state.connectedUser.displayName(), + state = null, + ) + NetworkFactRow( + label = "Connection ID", + value = state.connectionId, + state = null, + ) + } + + is StreamConnectionState.Connecting.Opening -> { + Divider() + NetworkFactRow( + label = "Stage", + value = "Opening socket", + state = null, + ) + NetworkFactRow( + label = "User", + value = state.userId, + state = null, + ) + } + + is StreamConnectionState.Connecting.Authenticating -> { + Divider() + NetworkFactRow( + label = "Stage", + value = "Authenticating", + state = null, + ) + NetworkFactRow( + label = "User", + value = state.userId, + state = null, + ) + } + + is StreamConnectionState.Disconnected -> { + Divider() + NetworkFactRow( + label = "Cause", + value = state.cause?.localizedMessage ?: "No details", + state = false, + alert = state.cause != null, + ) + } + + StreamConnectionState.Idle -> { + Divider() + NetworkFactRow( + label = "Details", + value = "Client idle", + state = null, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectionStateCardPreview() { + StreamandroidcoreTheme { + ConnectionStateCard( + StreamConnectionState.Connected( + connectedUser = sampleConnectedUser(), + connectionId = "conn-1234", + ) + ) + } +} + +private fun sampleConnectedUser(): StreamConnectedUser = + StreamConnectedUser( + createdAt = Date(), + id = "petar", + language = "en", + role = "user", + updatedAt = Date(), + teams = emptyList(), + name = "Petar", + ) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt new file mode 100644 index 0000000..25f87c4 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme +import kotlin.time.ExperimentalTime + +@Composable +@OptIn(ExperimentalTime::class) +public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + if (snapshot == null) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Network", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "No active network detected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@OutlinedCard + } + + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Network", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Last update: ${snapshot.timestamp}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(snapshot.transports.toList()) { transport -> + TransportChip(label = transport.label()) + } + } + + Divider() + + val signalData = snapshot.signalSummary() + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Signal", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + Text( + text = signalData.description, + style = MaterialTheme.typography.bodyMedium, + ) + signalData.progress?.let { progress -> + LinearProgressIndicator( + progress = progress, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Divider() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Status", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + NetworkFactRow( + label = "Internet", + value = snapshot.internet.toStatusValue("Available", "Unavailable"), + state = snapshot.internet, + ) + NetworkFactRow( + label = "Validated", + value = snapshot.validated.toStatusValue("Validated", "Pending"), + state = snapshot.validated, + ) + NetworkFactRow( + label = "VPN", + value = snapshot.vpn.toStatusValue("Enabled", "Disabled"), + state = snapshot.vpn, + ) + NetworkFactRow( + label = "Metered", + value = snapshot.metered.label, + state = + when (snapshot.metered) { + StreamNetworkInfo.Metered.NOT_METERED, + StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED -> true + StreamNetworkInfo.Metered.UNKNOWN_OR_METERED -> false + }, + alert = snapshot.metered == StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, + ) + NetworkFactRow( + label = "Priority", + value = snapshot.priority.label, + state = null, + ) + } + + Divider() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Throughput", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + val bandwidth = snapshot.bandwidthKbps + val downText = bandwidth?.downKbps?.let { "$it kbps" } ?: "Unknown" + val upText = bandwidth?.upKbps?.let { "$it kbps" } ?: "Unknown" + NetworkFactRow(label = "Downlink", value = downText, state = null) + NetworkFactRow(label = "Uplink", value = upText, state = null) + } + + snapshot.link?.let { link -> + Divider() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Link", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + link.interfaceName?.let { + NetworkFactRow(label = "Interface", value = it, state = null) + } + if (link.addresses.isNotEmpty()) { + NetworkFactRow( + label = "Address", + value = link.addresses.first(), + state = null, + ) + } + if (link.dnsServers.isNotEmpty()) { + NetworkFactRow( + label = "DNS", + value = link.dnsServers.joinToString(), + state = null, + ) + } + link.httpProxy?.let { + NetworkFactRow(label = "Proxy", value = it, state = null) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NetworkInfoCardPreview() { + StreamandroidcoreTheme { + NetworkInfoCard(sampleSnapshot()) + } +} + +@OptIn(ExperimentalTime::class) +private fun sampleSnapshot(): StreamNetworkInfo.Snapshot = + StreamNetworkInfo.Snapshot( + transports = setOf(StreamNetworkInfo.Transport.WIFI, StreamNetworkInfo.Transport.VPN), + internet = true, + validated = true, + captivePortal = false, + vpn = true, + trusted = true, + localOnly = false, + metered = StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, + roaming = false, + bandwidthKbps = StreamNetworkInfo.Bandwidth(downKbps = 12_000, upKbps = 2_500), + priority = StreamNetworkInfo.PriorityHint.LATENCY, + signal = + StreamNetworkInfo.Signal.Wifi( + rssiDbm = -55, + level0to4 = 4, + ssid = "Stream Guest", + bssid = "AA:BB:CC:00:11:22", + frequencyMhz = 5220, + ), + link = + StreamNetworkInfo.Link( + interfaceName = "wlan0", + addresses = listOf("192.168.0.12"), + dnsServers = listOf("1.1.1.1", "8.8.8.8"), + domains = listOf("getstream.io"), + mtu = 1500, + httpProxy = "proxy.local:8080", + ), + ) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt new file mode 100644 index 0000000..40090e5 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.StreamConnectedUser +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo + +@Composable +internal fun TransportChip(label: String) { + Box( + modifier = + Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +internal fun NetworkFactRow( + label: String, + value: String, + state: Boolean?, + alert: Boolean = false, +) { + val indicatorColor: Color = + when (state) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.outline + } + val baseValueColor = + when (state) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurfaceVariant + } + val valueColor = if (alert) MaterialTheme.colorScheme.error else baseValueColor + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(10.dp) + .background(indicatorColor, CircleShape), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.weight(1f)) + Text(text = value, style = MaterialTheme.typography.bodyMedium, color = valueColor) + } +} + +internal data class SignalViewData(val description: String, val progress: Float?) + +internal fun StreamNetworkInfo.Snapshot.signalSummary(): SignalViewData { + val level = signal.level()?.coerceIn(0, 4) + val progress = level?.let { it / 4f } + return SignalViewData(signal.summary(), progress) +} + +private fun StreamNetworkInfo.Signal?.level(): Int? = + when (this) { + is StreamNetworkInfo.Signal.Wifi -> level0to4 + is StreamNetworkInfo.Signal.Cellular -> level0to4 + else -> null + } + +private fun StreamNetworkInfo.Signal?.summary(): String = + when (this) { + is StreamNetworkInfo.Signal.Wifi -> + "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Cellular -> + "Cellular ${rat ?: "Radio"} RSRP: ${rsrpDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Generic -> "Generic signal: $value" + null -> "Signal data unavailable" + } + +internal fun Boolean?.toStatusValue(trueText: String, falseText: String): String = + when (this) { + true -> trueText + false -> falseText + null -> "Unknown" + } + +internal val StreamNetworkInfo.Metered.label: String + get() = + when (this) { + StreamNetworkInfo.Metered.NOT_METERED -> "Unmetered" + StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED -> "Temporarily unmetered" + StreamNetworkInfo.Metered.UNKNOWN_OR_METERED -> "Metered" + } + +internal val StreamNetworkInfo.PriorityHint.label: String + get() = + when (this) { + StreamNetworkInfo.PriorityHint.NONE -> "Balanced" + StreamNetworkInfo.PriorityHint.LATENCY -> "Latency" + StreamNetworkInfo.PriorityHint.BANDWIDTH -> "Bandwidth" + } + +internal fun StreamNetworkInfo.Transport.label(): String = + name.lowercase().replace('_', ' ').replaceFirstChar { it.uppercaseChar() } + +internal fun connectionStatusLabel(state: StreamConnectionState): String = + when (state) { + StreamConnectionState.Idle -> "Idle" + is StreamConnectionState.Connecting.Opening -> "Connecting" + is StreamConnectionState.Connecting.Authenticating -> "Authenticating" + is StreamConnectionState.Connected -> "Connected" + is StreamConnectionState.Disconnected -> "Disconnected" + } + +internal fun connectionStatusState(state: StreamConnectionState): Boolean? = + when (state) { + StreamConnectionState.Idle -> null + is StreamConnectionState.Connecting -> null + is StreamConnectionState.Connected -> true + is StreamConnectionState.Disconnected -> false + } + +internal fun StreamConnectedUser.displayName(): String = name ?: id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c204b8d..7dc10e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ kover = "0.9.1" sonarqube = "6.0.1.5171" kotlinDokka = "1.9.20" nexusPlugin = "1.3.0" +annotationJvm = "1.9.1" [libraries] androidx-core = { module = "androidx.test:core", version.ref = "core" } @@ -64,6 +65,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } retrofit-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-annotation-jvm = { group = "androidx.annotation", name = "annotation-jvm", version.ref = "annotationJvm" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/stream-android-core/build.gradle.kts b/stream-android-core/build.gradle.kts index 83bffe4..fa6e385 100644 --- a/stream-android-core/build.gradle.kts +++ b/stream-android-core/build.gradle.kts @@ -80,6 +80,9 @@ dependencies { detektPlugins(libs.detekt.formatting) + // Android + implementation(libs.androidx.annotation.jvm) + // Network implementation(libs.moshi) implementation(libs.moshi.kotlin) diff --git a/stream-android-core/src/main/AndroidManifest.xml b/stream-android-core/src/main/AndroidManifest.xml index a5918e6..48e9a84 100644 --- a/stream-android-core/src/main/AndroidManifest.xml +++ b/stream-android-core/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + \ No newline at end of file diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index d4d2868..f8ffea8 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -18,6 +18,7 @@ package io.getstream.android.core.api import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.http.StreamOkHttpInterceptors import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -25,10 +26,12 @@ import io.getstream.android.core.api.model.config.StreamHttpConfig import io.getstream.android.core.api.model.config.StreamSocketConfig import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.processing.StreamBatcher import io.getstream.android.core.api.processing.StreamRetryProcessor import io.getstream.android.core.api.processing.StreamSerialProcessingQueue @@ -107,6 +110,17 @@ public interface StreamClient { */ public val connectionState: StateFlow + /** + * Read-only, hot state holder for the current network snapshot. + * + * **Semantics** + * - Emits the latest network snapshot whenever it changes. + * - Hot & conflated: new collectors receive the latest value immediately. + * - `null` if no network is available. + */ + @StreamInternalApi + public val networkInfo: StateFlow + /** * Establishes a connection for the current user. * @@ -219,8 +233,10 @@ public fun StreamClient( // Socket connectionIdHolder: StreamConnectionIdHolder, socketFactory: StreamWebSocketFactory, - healthMonitor: StreamHealthMonitor, batcher: StreamBatcher, + // Monitoring + healthMonitor: StreamHealthMonitor, + networkMonitor: StreamNetworkMonitor, // Http httpConfig: StreamHttpConfig? = null, // Serialization @@ -279,6 +295,7 @@ public fun StreamClient( logger = clientLogger, mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle), subscriptionManager = clientSubscriptionManager, + networkMonitor = networkMonitor, socketSession = StreamSocketSession( logger = logProvider.taggedLogger("SCSocketSession"), diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt new file mode 100644 index 0000000..6a692d6 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.telephony.TelephonyManager +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.internal.components.StreamAndroidComponentsProviderImpl + +/** + * Provides access to Android system services. + * + * This interface abstracts away the details of accessing Android system services, allowing the SDK + * to work with different versions of Android and different build environments. + */ +@StreamInternalApi +public interface StreamAndroidComponentsProvider { + + /** + * Retrieves the [ConnectivityManager] system service. + * + * @return A [Result] containing the [ConnectivityManager] if successful, or an error if the + * service cannot be retrieved. + */ + public fun connectivityManager(): Result + + /** + * Retrieves the [WifiManager] system service. + * + * @return A [Result] containing the [WifiManager] if successful, or an error if the service + * cannot be retrieved. + */ + public fun wifiManager(): Result + + /** + * Retrieves the [TelephonyManager] system service. + * + * @return A [Result] containing the [TelephonyManager] if successful, or an error if the + * service cannot be retrieved. + */ + public fun telephonyManager(): Result +} + +/** + * Creates a new [StreamAndroidComponentsProvider] instance. + * + * @param context The application context. + * @return A new [StreamAndroidComponentsProvider] instance. + */ +@StreamInternalApi +public fun StreamAndroidComponentsProvider(context: Context): StreamAndroidComponentsProvider = + StreamAndroidComponentsProviderImpl(context) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt new file mode 100644 index 0000000..aa3087c --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.model.connection.network + +import io.getstream.android.core.annotations.StreamInternalApi +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Container for the strongly typed connectivity metadata that Stream surfaces to observers. + * + * The nested data structures mirror the information exposed by Android's connectivity stack while + * normalising names and value ranges so they remain stable across API levels. + */ +@StreamInternalApi +public class StreamNetworkInfo { + + /** + * Immutable capture of the device's currently selected network at a specific point in time. + * + * Every field maps back to values provided by [android.net.NetworkCapabilities] or + * [android.net.LinkProperties]. Nullable fields either were not reported by the platform or are + * hidden due to missing runtime permissions (for example, fine location for Wi-Fi SSID). + * + * @property timestamp When the snapshot was assembled. Defaults to [Clock.System.now]. + * @property transports Physical or virtual mediums (Wi-Fi, cellular, VPN, …) backing the + * connection. + * @property internet Whether the OS believes the network offers internet reachability. + * @property validated Whether the OS completed captive portal / validation checks. + * @property captivePortal True when the network is gated behind a captive portal login flow. + * @property vpn Indicates that the network represents, or is routed through, a VPN. + * @property trusted True when the network is marked as trusted by the system. + * @property localOnly True for local-only networks without internet routing (e.g., Wi-Fi + * Direct). + * @property metered Billing hint describing if traffic may incur charges. + * @property roaming True when the cellular connection is roaming, `null` when unknown. + * @property congested False when the OS explicitly reports the network is *not* congested. + * @property suspended False when the OS explicitly reports the network is *not* suspended. + * @property bandwidthConstrained False when the OS explicitly reports the network is *not* + * bandwidth constrained. + * @property bandwidthKbps Platform-estimated downstream and upstream throughput in kilobits per + * second. + * @property priority Platform hint for whether latency or bandwidth should be prioritised. + * @property signal Normalised radio-level signal information when exposed. + * @property link Link-layer metadata such as interface name, IP addresses, DNS servers, and + * MTU. + */ + @OptIn(ExperimentalTime::class) + @StreamInternalApi + public data class Snapshot( + val timestamp: Instant = Clock.System.now(), + val transports: Set = emptySet(), + val internet: Boolean? = null, + val validated: Boolean? = null, + val captivePortal: Boolean? = null, + val vpn: Boolean? = null, + val trusted: Boolean? = null, + val localOnly: Boolean? = null, + val metered: Metered = Metered.UNKNOWN_OR_METERED, + val roaming: Boolean? = null, + val congested: Boolean? = null, + val suspended: Boolean? = null, + val bandwidthConstrained: Boolean? = null, + val bandwidthKbps: Bandwidth? = null, + val priority: PriorityHint = PriorityHint.NONE, + val signal: Signal? = null, + val link: Link? = null, + ) + + /** + * Enumerates the logical transport mediums that Android associates with a network. + * + * Values correspond to the `TRANSPORT_*` constants from [android.net.NetworkCapabilities]. When + * the system reports no recognised transports, [UNKNOWN] is used as a defensive fallback. + */ + public enum class Transport { + WIFI, + CELLULAR, + ETHERNET, + BLUETOOTH, + WIFI_AWARE, + LOW_PAN, + USB, + THREAD, + SATELLITE, + VPN, + UNKNOWN, + } + + /** Expresses the OS-level priority hint for the current network when multiplexing is needed. */ + public enum class PriorityHint { + /** No explicit hint was published by the platform. */ + NONE, + + /** Low latency should be favoured over throughput (e.g., for real-time media). */ + LATENCY, + + /** Throughput should be prioritised over latency (e.g., large downloads). */ + BANDWIDTH, + } + + /** Billing and quota hint for the connection. */ + public enum class Metered { + /** The platform guarantees the connection is currently unmetered. */ + NOT_METERED, + + /** Temporarily unmetered (e.g., carrier promotion) but should not be relied upon. */ + TEMPORARILY_NOT_METERED, + + /** Unknown or treated as metered to stay on the safe side. */ + UNKNOWN_OR_METERED, + } + + /** + * Platform-supplied bandwidth estimate in kilobits per second. Values are positive when + * available; `null` indicates the estimate is missing or deemed unreliable. + */ + public data class Bandwidth(val downKbps: Int?, val upKbps: Int?) + + /** + * Link-layer metadata for the network interface backing the connection. + * + * Includes identifiers and addressing information that can be used for diagnostics or to tailor + * behaviour (for example, adapting timeouts when behind a known proxy). + */ + public data class Link( + val interfaceName: String?, + val addresses: List, + val dnsServers: List, + val domains: List, + val mtu: Int?, + val httpProxy: String?, + ) + + /** + * Normalised radio signal readings for different transport technologies. + * + * Only the values surfaced by the platform are exposed; absent metrics stay `null`. Use + * [strengthDbm] and [level0to4] as generic helpers when the underlying transport is unknown. + */ + public sealed interface Signal { + /** + * Raw signal strength in dBm when the transport exposes it. Larger (less negative) values + * generally indicate a stronger signal. + */ + public val strengthDbm: Int? + get() = null + + /** Canonical 0..4 "bars" representation when available from the platform APIs. */ + public val level0to4: Int? + get() = null + + /** + * Wi-Fi specific signal details reported by [android.net.wifi.WifiManager]. + * + * @property rssiDbm Received signal strength indicator in dBm, `null` if withheld. + * @property level0to4 Normalised signal level (0 weakest – 4 strongest). + * @property ssid Network SSID when permissions allow disclosure. + * @property bssid Access point BSSID when available. + * @property frequencyMhz Operating frequency of the access point. + */ + public data class Wifi( + val rssiDbm: Int?, + override val level0to4: Int?, + val ssid: String?, + val bssid: String?, + val frequencyMhz: Int?, + override val strengthDbm: Int? = rssiDbm, + ) : Signal + + /** + * Cellular radio measurements aggregated from [android.telephony.SignalStrength]. + * + * @property rat Radio access technology label (NR, LTE, WCDMA, …) when determined. + * @property level0to4 Normalised signal level (0 weakest – 4 strongest). + * @property rsrpDbm Reference signal received power for LTE/NR, in dBm. + * @property rsrqDb Reference signal received quality for LTE/NR, in dB. + * @property sinrDb Signal-to-interference-plus-noise ratio for LTE/NR, in dB. + */ + public data class Cellular( + val rat: String?, + override val level0to4: Int?, + val rsrpDbm: Int?, + val rsrqDb: Int?, + val sinrDb: Int?, + override val strengthDbm: Int? = rsrpDbm, + ) : Signal + + /** Fallback signal bucket used when the platform only exposes a generic strength value. */ + public data class Generic(val value: Int, override val strengthDbm: Int? = value) : Signal + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt new file mode 100644 index 0000000..3f0e857 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.telephony.TelephonyManager +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.network.StreamNetworkMonitorImpl +import io.getstream.android.core.internal.observers.network.StreamNetworkSignalProcessing +import io.getstream.android.core.internal.observers.network.StreamNetworkSnapshotBuilder +import kotlinx.coroutines.CoroutineScope + +/** + * Observes changes to the device's active network and provides snapshots of its capabilities. + * + * Implementations are expected to be life-cycle aware and safe to invoke from any thread. + */ +@StreamInternalApi +public interface StreamNetworkMonitor { + + /** Registers [listener] to receive network updates. */ + public fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options = StreamSubscriptionManager.Options(), + ): Result + + /** Starts monitoring connectivity changes. Safe to call multiple times. */ + public fun start(): Result + + /** Stops monitoring and releases platform callbacks. Safe to call multiple times. */ + public fun stop(): Result +} + +/** + * Creates a [StreamNetworkMonitor] instance. + * + * @param logger The logger to use for logging. + * @param scope The coroutine scope to use for running the monitor. + * @param subscriptionManager The subscription manager to use for managing listeners. + * @param componentsProvider Provides access to Android system services used for monitoring. + */ +@StreamInternalApi +public fun StreamNetworkMonitor( + logger: StreamLogger, + scope: CoroutineScope, + subscriptionManager: StreamSubscriptionManager, + wifiManager: WifiManager, + telephonyManager: TelephonyManager, + connectivityManager: ConnectivityManager, +): StreamNetworkMonitor = + StreamNetworkMonitorImpl( + logger = logger, + scope = scope, + streamSubscriptionManager = subscriptionManager, + snapshotBuilder = + StreamNetworkSnapshotBuilder( + signalProcessing = StreamNetworkSignalProcessing(), + wifiManager = wifiManager, + telephonyManager = telephonyManager, + ), + connectivityManager = connectivityManager, + ) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt new file mode 100644 index 0000000..a2c197c --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo + +/** + * Listener interface for network state changes. + * + * Implement this interface to receive updates about network state changes. + */ +@StreamInternalApi +public interface StreamNetworkMonitorListener { + /** + * Called when the network is connected. + * + * @param snapshot A [StreamNetworkInfo.Snapshot] describing the newly connected network. + */ + public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) + + /** + * Called when the network is lost. + * + * @param permanent True if the network is lost permanently (e.g., due to airplane mode). + */ + public suspend fun onNetworkLost(permanent: Boolean = false) + + /** + * Called when the properties of the currently connected network change while the connection + * remains active. + * + * @param snapshot A [StreamNetworkInfo.Snapshot] containing the updated properties. + */ + public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt index 322cd6d..95658a8 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt @@ -62,12 +62,21 @@ public interface StreamHealthMonitor { public fun acknowledgeHeartbeat() /** Starts the health monitor, beginning the heartbeat and liveness checks. */ - public fun start() + public fun start(): Result /** Stops the health monitor, halting heartbeat and liveness checks. */ - public fun stop() + public fun stop(): Result } +/** + * Creates a new [StreamHealthMonitor] instance. + * + * @param logger The logger to use for logging. + * @param scope The coroutine scope to use for running the health monitor. + * @param interval The interval between heartbeats in milliseconds. + * @param livenessThreshold The liveness threshold in milliseconds. + * @return A new [StreamHealthMonitor] instance. + */ @OptIn(ExperimentalTime::class) @StreamInternalApi public fun StreamHealthMonitor( diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt new file mode 100644 index 0000000..033bd19 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.utils + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.annotations.StreamPublishedApi +import io.getstream.android.core.api.model.exceptions.StreamAggregateException + +/** + * Operator overload for creating a [StreamAggregateException] from two [Throwable]s. + * + * @param other The other [Throwable] to combine with this one. + * @return A [StreamAggregateException] containing both [Throwable]s. + */ +@StreamPublishedApi +public operator fun Throwable.plus(other: Throwable): StreamAggregateException { + val message = "Multiple errors occurred. (${this.message}, ${other.message})" + return if (this is StreamAggregateException && other is StreamAggregateException) { + StreamAggregateException(message, causes + other.causes) + } else if (this is StreamAggregateException) { + StreamAggregateException(message, causes + other) + } else if (other is StreamAggregateException) { + StreamAggregateException(message, listOf(this) + other.causes) + } else { + StreamAggregateException(message, listOf(this, other)) + } +} + +/** + * Operator overload for creating a [Result] of a pair from two [Result]s. + * + * @param other The other [Result] to combine with this one. + * @return A [Result] containing a pair of the values from this and the other [Result], or a + * [StreamAggregateException] if either [Result] is a failure. + */ +@StreamInternalApi +public operator fun Result.times(other: Result): Result> = + this.flatMap { first -> other.map { second -> first to second } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index cccb6ea..4595f58 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -15,13 +15,17 @@ */ package io.getstream.android.core.internal.client +import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.model.StreamTypedKey.Companion.randomExecutionKey import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.api.socket.StreamConnectionIdHolder @@ -47,6 +51,7 @@ internal class StreamClientImpl( private val mutableConnectionState: MutableStateFlow, private val logger: StreamLogger, private val subscriptionManager: StreamSubscriptionManager, + private val networkMonitor: StreamNetworkMonitor, private val scope: CoroutineScope, ) : StreamClient { companion object { @@ -55,14 +60,58 @@ internal class StreamClientImpl( } private var handle: StreamSubscription? = null + private var networkMonitorHandle: StreamSubscription? = null override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() + private var internalNetworkInfo: MutableStateFlow = MutableStateFlow(null) + + @StreamInternalApi + override val networkInfo: StateFlow + get() = internalNetworkInfo.asStateFlow() + override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) override suspend fun connect(): Result = singleFlight.run(connectKey) { + if (networkMonitorHandle == null) { + logger.v { "[connect] Starting network monitor" } + networkMonitorHandle = + networkMonitor + .subscribe( + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected( + snapshot: StreamNetworkInfo.Snapshot? + ) { + logger.v { "[connect] Network connected: $snapshot" } + internalNetworkInfo.update { snapshot } + connect() + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "[connect] Network lost" } + internalNetworkInfo.update { null } + disconnect() + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + logger.v { "[connect] Network changed: $snapshot" } + internalNetworkInfo.update { snapshot } + } + }, + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() + } + + networkMonitor.start() + val currentState = connectionState.value if (currentState is StreamConnectionState.Connected) { logger.w { "[connect] Already connected!" } @@ -74,7 +123,6 @@ internal class StreamClientImpl( socketSession .subscribe( object : StreamClientListener { - override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } mutableConnectionState.update(state) @@ -132,6 +180,8 @@ internal class StreamClientImpl( connectionIdHolder.clear() socketSession.disconnect() handle?.cancel() + networkMonitorHandle?.cancel() + networkMonitorHandle = null handle = null tokenManager.invalidate() serialQueue.stop() diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt new file mode 100644 index 0000000..9cce078 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.components + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider + +internal class StreamAndroidComponentsProviderImpl(context: Context) : + StreamAndroidComponentsProvider { + + private val applicationContext = context.applicationContext + + override fun connectivityManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(ConnectivityManager::class.java) + } else { + applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + } + + @SuppressLint("WifiManagerPotentialLeak") + override fun wifiManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(WifiManager::class.java) + } else { + applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + } + } + + override fun telephonyManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(TelephonyManager::class.java) + } else { + applicationContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + } + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt new file mode 100644 index 0000000..12e325b --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.annotation.SuppressLint +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@StreamInternalApi +internal class StreamNetworkMonitorImpl( + private val logger: StreamLogger, + private val scope: CoroutineScope, + private val streamSubscriptionManager: StreamSubscriptionManager, + private val snapshotBuilder: StreamNetworkSnapshotBuilder, + private val connectivityManager: ConnectivityManager, +) : StreamNetworkMonitor { + private val started = AtomicBoolean(false) + private val networkCallbackRef = AtomicReference() + private val activeState = AtomicReference() + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result = streamSubscriptionManager.subscribe(listener, options) + + @SuppressLint("MissingPermission") + override fun start(): Result = runCatching { + if (!started.compareAndSet(false, true)) { + logger.v { "StreamNetworkMonitor already started" } + return@runCatching + } + + val callback = MonitorCallback() + networkCallbackRef.set(callback) + + try { + registerCallback(callback) + resolveInitialState()?.also { initialState -> + activeState.set(initialState) + notifyConnected(initialState.snapshot) + } + } catch (throwable: Throwable) { + logger.e(throwable) { "Failed to start network monitor" } + safeUnregister(callback) + cleanup() + throw throwable + } + } + + override fun stop(): Result = runCatching { + val callback = networkCallbackRef.getAndSet(null) + if (callback != null) { + safeUnregister(callback) + } + cleanup() + } + + private fun registerCallback(callback: ConnectivityManager.NetworkCallback) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(callback) + } else { + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + } + } + + private fun resolveInitialState(): ActiveNetworkState? { + val defaultNetwork = + resolveDefaultNetwork() + ?: run { + logger.v { "No active network available at start" } + return null + } + val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) + val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) + val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null + return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) + } + + private fun resolveDefaultNetwork(): Network? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() + } else { + connectivityManager.allNetworks.firstOrNull() + } + + private fun buildSnapshot( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): StreamNetworkInfo.Snapshot? = + snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } + + private fun handleUpdate( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + reason: UpdateReason, + ) { + if (!shouldProcessNetwork(network)) { + logger.v { "[handleUpdate] Ignoring network $network; not default." } + return + } + + val resolvedCapabilities = + capabilities ?: connectivityManager.getNetworkCapabilities(network) + val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) + val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) + if (snapshot == null) { + logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } + return + } + + val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) + val previousState = activeState.getAndSet(newState) + + val networkChanged = previousState?.network != network || previousState == null + val snapshotChanged = previousState?.snapshot != snapshot + + when { + reason == UpdateReason.AVAILABLE || networkChanged -> { + logger.v { "[handleUpdate] Active network set to $network" } + notifyConnected(snapshot) + } + + snapshotChanged -> { + logger.v { "[handleUpdate] Network properties updated for $network" } + notifyPropertiesChanged(snapshot) + } + + else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } + } + } + + private fun handleLoss(network: Network?, permanent: Boolean) { + val current = activeState.get() + if (current == null) { + logger.v { "[handleLoss] No active network to clear." } + return + } + + if (network != null && network != current.network) { + logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } + return + } + + if (activeState.compareAndSet(current, null)) { + logger.v { "[handleLoss] Network lost: ${current.network}" } + notifyLost(permanent) + } + } + + private fun shouldProcessNetwork(network: Network): Boolean { + val tracked = activeState.get()?.network + if (tracked != null && tracked == network) return true + return isDefaultNetwork(network) + } + + private fun isDefaultNetwork(network: Network): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork == network + } else { + connectivityManager.allNetworks.firstOrNull() == network + } + + private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { + if (snapshot == null) { + return + } + notifyListeners { listener -> listener.onNetworkConnected(snapshot) } + } + + private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { + notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } + } + + private fun notifyLost(permanent: Boolean = false) { + notifyListeners { listener -> listener.onNetworkLost(permanent) } + } + + private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { + streamSubscriptionManager + .forEach { listener -> + scope.launch { + runCatching { block(listener) } + .onFailure { throwable -> + logger.e(throwable) { "Network monitor listener failure" } + } + } + } + .onFailure { throwable -> + logger.e(throwable) { "Failed to iterate network monitor listeners" } + } + } + + private fun cleanup() { + activeState.set(null) + started.set(false) + } + + private fun safeUnregister(callback: ConnectivityManager.NetworkCallback) { + runCatching { connectivityManager.unregisterNetworkCallback(callback) } + .onFailure { logger.w { "Failed to unregister network callback: ${it.message}" } } + } + + private inner class MonitorCallback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + logger.v { "Network available: $network" } + handleUpdate(network, null, null, UpdateReason.AVAILABLE) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + logger.v { "Network capabilities changed for $network" } + handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) + } + + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + logger.v { "Link properties changed for $network" } + handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) + } + + override fun onLost(network: Network) { + handleLoss(network, permanent = false) + } + + override fun onUnavailable() { + handleLoss(network = null, permanent = true) + } + } + + private data class ActiveNetworkState( + val network: Network, + val capabilities: NetworkCapabilities?, + val linkProperties: LinkProperties?, + val snapshot: StreamNetworkInfo.Snapshot, + ) + + private enum class UpdateReason { + AVAILABLE, + PROPERTIES, + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt new file mode 100644 index 0000000..31af231 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager + +internal fun NetworkCapabilities.safeHasCapability(capability: Int): Boolean? = + runCatching { hasCapability(capability) }.getOrNull() + +internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = + runCatching { hasTransport(transport) }.getOrNull() + +internal fun sanitizeSsid(info: WifiInfo): String? { + val raw = info.ssid?.trim('"') ?: return null + val isPlatformUnknown = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID else false + val isLegacyUnknown = raw == "" + return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } +} + +internal fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = + runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() + +internal fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null + +internal fun signalLevel(strength: SignalStrength?): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null + +internal fun nrStrength(strength: SignalStrength?): CellSignalStrengthNr? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + +internal fun lteStrength(strength: SignalStrength?): CellSignalStrengthLte? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt new file mode 100644 index 0000000..872fd94 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.annotation.SuppressLint +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport + +internal class StreamNetworkSignalProcessing { + + @SuppressLint("MissingPermission") + fun bestEffortSignal( + wifiManager: WifiManager, + telephonyManager: TelephonyManager, + capabilities: NetworkCapabilities?, + transports: Set, + ): Signal? { + val genericValue = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } + } else { + null + } + if (genericValue != null) return Signal.Generic(genericValue) + + return when { + Transport.WIFI in transports -> wifiSignal(wifiManager) + Transport.CELLULAR in transports -> cellularSignal(telephonyManager) + else -> null + } + } + + @SuppressLint("MissingPermission") + fun wifiSignal(wifiManager: WifiManager): Signal.Wifi? { + val info = wifiManager.connectionInfo ?: return null + val rssi = info.rssi + return Signal.Wifi( + rssiDbm = rssi, + level0to4 = wifiSignalLevel(rssi), + ssid = sanitizeSsid(info), + bssid = info.bssid, + frequencyMhz = info.frequency.takeIf { it > 0 }, + ) + } + + @SuppressLint("MissingPermission") + fun cellularSignal(telephonyManager: TelephonyManager): Signal.Cellular? { + val strength = telephonySignalStrength(telephonyManager) ?: return null + val level = signalLevel(strength) + + val nr = nrStrength(strength) + if (nr != null) { + val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrp else null + val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrq else null + val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssSinr else null + return Signal.Cellular( + rat = "NR", + level0to4 = level, + rsrpDbm = rsrp, + rsrqDb = rsrq, + sinrDb = sinr, + ) + } + + val lte = lteStrength(strength) + if (lte != null) { + val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrp else null + val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrq else null + val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rssnr else null + return Signal.Cellular( + rat = "LTE", + level0to4 = level, + rsrpDbm = rsrp, + rsrqDb = rsrq, + sinrDb = sinr, + ) + } + + return Signal.Cellular( + rat = null, + level0to4 = level, + rsrpDbm = null, + rsrqDb = null, + sinrDb = null, + ) + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt new file mode 100644 index 0000000..054f5fa --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Bandwidth +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Link +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Metered +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.PriorityHint +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import kotlin.time.ExperimentalTime + +internal class StreamNetworkSnapshotBuilder( + private val signalProcessing: StreamNetworkSignalProcessing, + private val wifiManager: WifiManager, + private val telephonyManager: TelephonyManager, +) { + @OptIn(ExperimentalTime::class) + fun build( + network: Network, + networkCapabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): Result = runCatching { + val transports = transportsFor(networkCapabilities) + val internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val validated = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + null + } + val captivePortal = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } else { + null + } + val notVpn = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + val vpn = + networkCapabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || (notVpn == false) + val trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED) + val localOnly = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) + } else { + null + } + + val metered = + when { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> + Metered.NOT_METERED + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED + ) == true -> Metered.TEMPORARILY_NOT_METERED + else -> Metered.UNKNOWN_OR_METERED + } + + val congested = + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { + true -> false + else -> null + } + val suspended = + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } + val bandwidthConstrained = + when ( + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED + ) + ) { + true -> false + else -> null + } + + val priority = + when { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == + true -> PriorityHint.LATENCY + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == + true -> PriorityHint.BANDWIDTH + else -> PriorityHint.NONE + } + + val bandwidth = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Bandwidth( + downKbps = networkCapabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, + upKbps = networkCapabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 }, + ) + } else { + null + } + + val signal = + signalProcessing.bestEffortSignal( + wifiManager, + telephonyManager, + networkCapabilities, + transports, + ) + + val link = linkProperties?.toLink() + + val notRoaming = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) + } else { + null + } + + StreamNetworkInfo.Snapshot( + transports = transports, + internet = internet, + validated = validated, + captivePortal = captivePortal, + vpn = vpn, + trusted = trusted, + localOnly = localOnly, + metered = metered, + roaming = notRoaming?.not(), + congested = congested, + suspended = suspended, + bandwidthConstrained = bandwidthConstrained, + bandwidthKbps = bandwidth, + priority = priority, + signal = signal, + link = link, + ) + } + + private fun transportsFor(capabilities: NetworkCapabilities?): Set { + if (capabilities == null) return emptySet() + val out = mutableSetOf() + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) + out += Transport.WIFI + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) + out += Transport.CELLULAR + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true) + out += Transport.ETHERNET + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) == true) + out += Transport.BLUETOOTH + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) == true) + out += Transport.WIFI_AWARE + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_LOWPAN) == true) + out += Transport.LOW_PAN + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_USB) == true) + out += Transport.USB + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_THREAD) == true) + out += Transport.THREAD + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_SATELLITE) == true) + out += Transport.SATELLITE + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_VPN) == true) + out += Transport.VPN + if (out.isEmpty()) out += Transport.UNKNOWN + return out + } + + private fun LinkProperties.toLink(): Link? { + val addresses = linkAddresses.mapNotNull { it.address?.hostAddress } + val dnsServers = dnsServers.mapNotNull { it.hostAddress } + val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + val mtuValue = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } + return Link( + interfaceName = interfaceName, + addresses = addresses, + dnsServers = dnsServers, + domains = domains, + mtu = mtuValue, + httpProxy = httpProxyValue, + ) + } + + private fun NetworkCapabilities?.flag(capability: Int): Boolean? = + this?.safeHasCapability(capability) + + private fun NetworkCapabilities?.transport(transport: Int): Boolean = + this?.safeHasTransport(transport) == true +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt index b1ad898..85d3f71 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt @@ -67,11 +67,11 @@ internal class StreamHealthMonitorImpl( } /** Starts (or restarts) the periodic health-check loop */ - override fun start() { + override fun start() = runCatching { logger.d { "[start] Staring health monitor" } if (monitorJob?.isActive == true) { logger.d { "Health monitor already running" } - return + return@runCatching } monitorJob = scope.launch { @@ -91,8 +91,9 @@ internal class StreamHealthMonitorImpl( } /** Stops the health-check loop */ - override fun stop() { + override fun stop() = runCatching { logger.d { "[stop] Stopping heath monitor" } monitorJob?.cancel() + Unit } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index e1aabeb..5cb7d39 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -17,6 +17,7 @@ package io.getstream.android.core.api +import android.net.ConnectivityManager import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider @@ -51,6 +52,7 @@ import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.StreamWebSocketImpl import io.getstream.android.core.testutil.assertFieldEquals import io.getstream.android.core.testutil.readPrivateField +import io.mockk.every import io.mockk.mockk import kotlin.test.assertEquals import kotlin.test.assertNotSame @@ -150,6 +152,13 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, + androidComponentsProvider = + mockk(relaxed = true) { + every { connectivityManager() } returns + Result.success(mockk(relaxed = true)) + every { wifiManager() } returns Result.success(mockk(relaxed = true)) + every { telephonyManager() } returns Result.success(mockk(relaxed = true)) + }, ) return client to deps diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt new file mode 100644 index 0000000..3ecbef3 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.utils + +import io.getstream.android.core.api.model.exceptions.StreamAggregateException +import kotlin.test.assertSame +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlgebraTest { + + @Test + fun `plus combines plain throwables into aggregate`() { + val first = IllegalStateException("one") + val second = IllegalArgumentException("two") + + val combined = first + second + + assertEquals(listOf(first, second), combined.causes) + assertTrue(combined.message?.contains("Multiple errors occurred") == true) + } + + @Test + fun `plus merges causes when both sides are aggregates`() { + val firstCause = IllegalStateException("first") + val secondCause = IllegalArgumentException("second") + val thirdCause = IllegalArgumentException("third") + val firstAgg = StreamAggregateException("left", listOf(firstCause, secondCause)) + val secondAgg = StreamAggregateException("right", listOf(thirdCause)) + + val combined = firstAgg + secondAgg + + assertEquals(listOf(firstCause, secondCause, thirdCause), combined.causes) + } + + @Test + fun `plus appends plain throwable to existing aggregate on left`() { + val existing = IllegalStateException("existing") + val other = IllegalArgumentException("other") + val aggregate = StreamAggregateException("agg", listOf(existing)) + + val combined = aggregate + other + + assertEquals(listOf(existing, other), combined.causes) + } + + @Test + fun `plus prepends plain throwable to aggregate on right`() { + val first = IllegalStateException("first") + val second = IllegalArgumentException("second") + val third = IllegalArgumentException("third") + val rightAggregate = StreamAggregateException("agg", listOf(second, third)) + + val combined = first + rightAggregate + + assertEquals(listOf(first, second, third), combined.causes) + } + + @Test + fun `times returns pair when both results succeed`() { + val left = Result.success(4) + val right = Result.success("value") + + val combined = left * right + + assertEquals(4 to "value", combined.getOrThrow()) + } + + @Test + fun `times propagates failure from left result`() { + val failure = IllegalStateException("failed") + val left = Result.failure(failure) + val right = Result.success("value") + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `times propagates failure from right result`() { + val left = Result.success(1) + val failure = IllegalArgumentException("broken") + val right = Result.failure(failure) + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 5106ca1..0b2fd91 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -96,6 +96,7 @@ class StreamClientIImplTest { mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, + networkMonitor = mockk(relaxed = true), ) @Test diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt index a37c238..a64dfe6 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt @@ -102,7 +102,7 @@ class StreamSocketSessionTest { every { socket.close(any(), any()) } returns Result.success(Unit) every { debounce.stop() } returns Result.success(Unit) - every { health.stop() } just Runs + every { health.stop() } returns Result.success(Unit) session = StreamSocketSession( From 6cee404b603d93530a1808af90ce859ed851f49a Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:18:28 +0200 Subject: [PATCH 02/23] Spotless --- .../android/core/sample/SampleActivity.kt | 3 +- .../core/sample/client/StreamClient.kt | 2 +- .../core/sample/ui/ConnectionStateCard.kt | 34 ++++--------------- .../android/core/sample/ui/NetworkInfoCard.kt | 24 +++---------- .../core/sample/ui/NetworkInfoComponents.kt | 28 ++++----------- .../android/core/api/StreamClient.kt | 4 +-- .../core/internal/client/StreamClientImpl.kt | 5 ++- .../network/StreamNetworkMonitorUtils.kt | 3 +- .../network/StreamNetworkSnapshotBuilder.kt | 3 +- 9 files changed, 26 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index f5bc4b2..ec66e23 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -98,8 +98,7 @@ class SampleActivity : ComponentActivity() { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = - Modifier - .fillMaxSize() + Modifier.fillMaxSize() .padding(innerPadding) .verticalScroll(scrollState) .padding(16.dp), diff --git a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt index 8b5dcc3..baf3908 100644 --- a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt +++ b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt @@ -135,6 +135,6 @@ fun createStreamClient( override fun deserialize(raw: String): Result = Result.success(Unit) } ), - batcher = batcher + batcher = batcher, ) } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt index e55ce03..52b28b9 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt @@ -40,9 +40,7 @@ public fun ConnectionStateCard(state: StreamConnectionState) { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column( modifier = Modifier.padding(20.dp), @@ -82,30 +80,14 @@ public fun ConnectionStateCard(state: StreamConnectionState) { is StreamConnectionState.Connecting.Opening -> { Divider() - NetworkFactRow( - label = "Stage", - value = "Opening socket", - state = null, - ) - NetworkFactRow( - label = "User", - value = state.userId, - state = null, - ) + NetworkFactRow(label = "Stage", value = "Opening socket", state = null) + NetworkFactRow(label = "User", value = state.userId, state = null) } is StreamConnectionState.Connecting.Authenticating -> { Divider() - NetworkFactRow( - label = "Stage", - value = "Authenticating", - state = null, - ) - NetworkFactRow( - label = "User", - value = state.userId, - state = null, - ) + NetworkFactRow(label = "Stage", value = "Authenticating", state = null) + NetworkFactRow(label = "User", value = state.userId, state = null) } is StreamConnectionState.Disconnected -> { @@ -120,11 +102,7 @@ public fun ConnectionStateCard(state: StreamConnectionState) { StreamConnectionState.Idle -> { Divider() - NetworkFactRow( - label = "Details", - value = "Client idle", - state = null, - ) + NetworkFactRow(label = "Details", value = "Client idle", state = null) } } } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt index 25f87c4..ed3b383 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt @@ -44,9 +44,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), ) { if (snapshot == null) { Column( @@ -101,15 +99,9 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) - Text( - text = signalData.description, - style = MaterialTheme.typography.bodyMedium, - ) + Text(text = signalData.description, style = MaterialTheme.typography.bodyMedium) signalData.progress?.let { progress -> - LinearProgressIndicator( - progress = progress, - modifier = Modifier.fillMaxWidth(), - ) + LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth()) } } @@ -147,11 +139,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { }, alert = snapshot.metered == StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, ) - NetworkFactRow( - label = "Priority", - value = snapshot.priority.label, - state = null, - ) + NetworkFactRow(label = "Priority", value = snapshot.priority.label, state = null) } Divider() @@ -206,9 +194,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { @Preview(showBackground = true) @Composable private fun NetworkInfoCardPreview() { - StreamandroidcoreTheme { - NetworkInfoCard(sampleSnapshot()) - } + StreamandroidcoreTheme { NetworkInfoCard(sampleSnapshot()) } } @OptIn(ExperimentalTime::class) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt index 40090e5..8e7d7f1 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState @@ -41,12 +40,11 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo internal fun TransportChip(label: String) { Box( modifier = - Modifier - .background( + Modifier.background( color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(50), ) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(horizontal = 12.dp, vertical = 6.dp) ) { Text( text = label, @@ -57,12 +55,7 @@ internal fun TransportChip(label: String) { } @Composable -internal fun NetworkFactRow( - label: String, - value: String, - state: Boolean?, - alert: Boolean = false, -) { +internal fun NetworkFactRow(label: String, value: String, state: Boolean?, alert: Boolean = false) { val indicatorColor: Color = when (state) { true -> MaterialTheme.colorScheme.primary @@ -77,16 +70,8 @@ internal fun NetworkFactRow( } val valueColor = if (alert) MaterialTheme.colorScheme.error else baseValueColor - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(10.dp) - .background(indicatorColor, CircleShape), - ) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(10.dp).background(indicatorColor, CircleShape)) Spacer(modifier = Modifier.width(12.dp)) Text(text = label, style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) @@ -111,8 +96,7 @@ private fun StreamNetworkInfo.Signal?.level(): Int? = private fun StreamNetworkInfo.Signal?.summary(): String = when (this) { - is StreamNetworkInfo.Signal.Wifi -> - "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Wifi -> "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" is StreamNetworkInfo.Signal.Cellular -> "Cellular ${rat ?: "Radio"} RSRP: ${rsrpDbm ?: "?"} dBm" is StreamNetworkInfo.Signal.Generic -> "Generic signal: $value" diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index f8ffea8..8ec0940 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -18,7 +18,6 @@ package io.getstream.android.core.api import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider -import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.http.StreamOkHttpInterceptors import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -118,8 +117,7 @@ public interface StreamClient { * - Hot & conflated: new collectors receive the latest value immediately. * - `null` if no network is available. */ - @StreamInternalApi - public val networkInfo: StateFlow + @StreamInternalApi public val networkInfo: StateFlow /** * Establishes a connection for the current user. diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 4595f58..4fe0fe3 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -64,7 +64,8 @@ internal class StreamClientImpl( override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() - private var internalNetworkInfo: MutableStateFlow = MutableStateFlow(null) + private var internalNetworkInfo: MutableStateFlow = + MutableStateFlow(null) @StreamInternalApi override val networkInfo: StateFlow @@ -86,13 +87,11 @@ internal class StreamClientImpl( ) { logger.v { "[connect] Network connected: $snapshot" } internalNetworkInfo.update { snapshot } - connect() } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } internalNetworkInfo.update { null } - disconnect() } override suspend fun onNetworkPropertiesChanged( diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt index 31af231..33afb9b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -33,7 +33,8 @@ internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = internal fun sanitizeSsid(info: WifiInfo): String? { val raw = info.ssid?.trim('"') ?: return null val isPlatformUnknown = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID else false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID + else false val isLegacyUnknown = raw == "" return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 054f5fa..ab41cdb 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -182,7 +182,8 @@ internal class StreamNetworkSnapshotBuilder( val addresses = linkAddresses.mapNotNull { it.address?.hostAddress } val dnsServers = dnsServers.mapNotNull { it.hostAddress } val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() - val mtuValue = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + val mtuValue = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } return Link( interfaceName = interfaceName, From 078cb6759c6d394e89dd7405802583d6c6a73b53 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:26:35 +0200 Subject: [PATCH 03/23] Update test --- .../getstream/android/core/api/StreamClientFactoryTest.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index 5cb7d39..93968cc 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -152,13 +152,7 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, - androidComponentsProvider = - mockk(relaxed = true) { - every { connectivityManager() } returns - Result.success(mockk(relaxed = true)) - every { wifiManager() } returns Result.success(mockk(relaxed = true)) - every { telephonyManager() } returns Result.success(mockk(relaxed = true)) - }, + networkMonitor = mockk(relaxed = true), ) return client to deps From edac873b91a1819ce996945ca3e5a6c6a210db28 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:34:24 +0200 Subject: [PATCH 04/23] Missing permission check --- .../observers/network/StreamNetworkSnapshotBuilder.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index ab41cdb..7bfaa98 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -76,9 +76,13 @@ internal class StreamNetworkSnapshotBuilder( } val congested = - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { - true -> false - else -> null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { + true -> false + else -> null + } + } else { + null } val suspended = when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { From b25c1a31ffebb571345cfcee800da5d5b2d9b2e8 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 14:04:31 +0200 Subject: [PATCH 05/23] Missing permission check --- .../observers/network/StreamNetworkSnapshotBuilder.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 7bfaa98..42c2642 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -85,6 +85,14 @@ internal class StreamNetworkSnapshotBuilder( null } val suspended = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } + } else { + null + } when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { true -> false else -> null From 4b5426569333b835586317c62feea737988d95e3 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 14:49:30 +0200 Subject: [PATCH 06/23] Add tests for the stream network monitor utils and processing --- .../network/StreamNetworkMonitorUtilsTest.kt | 79 +++++++++++ .../StreamNetworkSignalProcessingTest.kt | 127 ++++++++++++++++++ .../StreamNetworkSnapshotBuilderTest.kt | 105 +++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt new file mode 100644 index 0000000..32d7550 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.os.Build +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class StreamNetworkMonitorUtilsTest { + + @MockK(relaxed = true) lateinit var capabilities: NetworkCapabilities + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun `safeHasCapability returns value or null on error`() { + every { capabilities.hasCapability(1) } returns true + assertTrue(capabilities.safeHasCapability(1) == true) + + every { capabilities.hasCapability(2) } throws SecurityException("boom") + assertNull(capabilities.safeHasCapability(2)) + } + + @Test + fun `sanitizeSsid trims markers and ignores unknown`() { + val info = mockk { + every { ssid } returns "\"Stream\"" + } + assertEquals("Stream", sanitizeSsid(info)) + + every { info.ssid } returns "" + assertNull(sanitizeSsid(info)) + } + + @Test + fun `telephony helpers unwrap signal values`() { + val manager = mockk { + every { signalStrength } returns mockk(relaxed = true) + } + assertEquals(manager.signalStrength, telephonySignalStrength(manager)) + + val nrSignalStrength = mockk(relaxed = true) { + every { level } returns 3 + } + assertEquals(3, signalLevel(nrSignalStrength)) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt new file mode 100644 index 0000000..727a864 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class StreamNetworkSignalProcessingTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + processing = StreamNetworkSignalProcessing() + } + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `bestEffortSignal returns wifi signal when wifi transport available`() { + val wifiInfo = mockk { + every { rssi } returns -45 + every { ssid } returns "\"Stream\"" + every { bssid } returns "00:11:22:33:44:55" + every { frequency } returns 5200 + } + every { wifiManager.connectionInfo } returns wifiInfo + + val signal = + processing.bestEffortSignal( + wifiManager = wifiManager, + telephonyManager = telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI), + ) + + val wifiSignal = assertIs(signal) + assertEquals(-45, wifiSignal.rssiDbm) + assertEquals("Stream", wifiSignal.ssid) + assertEquals("00:11:22:33:44:55", wifiSignal.bssid) + assertEquals(5200, wifiSignal.frequencyMhz) + } + + @Test + fun `cellularSignal returns NR details when available`() { + val strength = mockk(relaxed = true) + val nrStrength = mockk(relaxed = true) { + every { ssRsrp } returns -95 + every { ssRsrq } returns -10 + every { ssSinr } returns 18 + } + + mockkStatic( + "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" + ) + every { telephonySignalStrength(telephonyManager) } returns strength + every { signalLevel(strength) } returns 3 + every { nrStrength(strength) } returns nrStrength + every { lteStrength(strength) } returns null + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertEquals("NR", cellular.rat) + assertEquals(3, cellular.level0to4) + } + + @Test + fun `bestEffortSignal returns null when no transports`() { + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = emptySet(), + ) + + assertNull(signal) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt new file mode 100644 index 0000000..d8981fc --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.PriorityHint +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.intArrayOf +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + + +@OptIn(ExperimentalTime::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +internal class StreamNetworkSnapshotBuilderTest { + + @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var builder: StreamNetworkSnapshotBuilder + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + } + + @Test + fun `build maps transports capabilities and link data`() { + val network = mockk() + val capabilities = mockk(relaxed = true) + val linkProperties = mockk(relaxed = true) + + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns true + every { capabilities.linkDownstreamBandwidthKbps } returns 50_000 + every { capabilities.linkUpstreamBandwidthKbps } returns 10_000 + + val signal = StreamNetworkInfo.Signal.Generic(40) + every { + signalProcessing.bestEffortSignal(wifiManager, telephonyManager, capabilities, any()) + } returns signal + + val snapshot = builder.build(network, capabilities, linkProperties).getOrThrow() + + assertNotNull(snapshot) + assertEquals(setOf(Transport.WIFI), snapshot.transports) + assertTrue(snapshot.internet ?: false) + assertTrue(snapshot.validated ?: false) + assertFalse(snapshot.vpn ?: true) + assertEquals(StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED, snapshot.metered) + assertEquals(PriorityHint.LATENCY, snapshot.priority) + assertEquals(signal, snapshot.signal) + assertEquals(50_000, snapshot.bandwidthKbps?.downKbps) + assertEquals(10_000, snapshot.bandwidthKbps?.upKbps) + assertEquals(false, snapshot.roaming) + assertNotNull(snapshot.link) + } +} From bf78bb964fc5e8368bd5d60149775aeccbbc5109 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 15:45:04 +0200 Subject: [PATCH 07/23] Move network monitor initialization after connection is established, add tests --- .../network/StreamNetworkMonitorListener.kt | 6 +- .../core/internal/client/StreamClientImpl.kt | 73 ++++++++++--------- .../network/StreamNetworkSnapshotBuilder.kt | 8 +- .../core/api/StreamClientFactoryTest.kt | 2 - .../internal/client/StreamClientIImplTest.kt | 7 +- .../network/StreamNetworkMonitorUtilsTest.kt | 19 ++--- .../StreamNetworkSignalProcessingTest.kt | 28 ++++--- .../StreamNetworkSnapshotBuilderTest.kt | 55 +++++++++----- 8 files changed, 107 insertions(+), 91 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt index a2c197c..040674b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt @@ -30,14 +30,14 @@ public interface StreamNetworkMonitorListener { * * @param snapshot A [StreamNetworkInfo.Snapshot] describing the newly connected network. */ - public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) + public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) {} /** * Called when the network is lost. * * @param permanent True if the network is lost permanently (e.g., due to airplane mode). */ - public suspend fun onNetworkLost(permanent: Boolean = false) + public suspend fun onNetworkLost(permanent: Boolean = false) {} /** * Called when the properties of the currently connected network change while the connection @@ -45,5 +45,5 @@ public interface StreamNetworkMonitorListener { * * @param snapshot A [StreamNetworkInfo.Snapshot] containing the updated properties. */ - public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) + public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) {} } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 4fe0fe3..3e8ca0a 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -76,41 +76,6 @@ internal class StreamClientImpl( override suspend fun connect(): Result = singleFlight.run(connectKey) { - if (networkMonitorHandle == null) { - logger.v { "[connect] Starting network monitor" } - networkMonitorHandle = - networkMonitor - .subscribe( - object : StreamNetworkMonitorListener { - override suspend fun onNetworkConnected( - snapshot: StreamNetworkInfo.Snapshot? - ) { - logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update { snapshot } - } - - override suspend fun onNetworkLost(permanent: Boolean) { - logger.v { "[connect] Network lost" } - internalNetworkInfo.update { null } - } - - override suspend fun onNetworkPropertiesChanged( - snapshot: StreamNetworkInfo.Snapshot - ) { - logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update { snapshot } - } - }, - StreamSubscriptionManager.Options( - retention = - StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED - ), - ) - .getOrThrow() - } - - networkMonitor.start() - val currentState = connectionState.value if (currentState is StreamConnectionState.Connected) { logger.w { "[connect] Already connected!" } @@ -124,7 +89,7 @@ internal class StreamClientImpl( object : StreamClientListener { override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } - mutableConnectionState.update(state) + mutableConnectionState.update { state } subscriptionManager.forEach { it.onState(state) } } @@ -158,6 +123,42 @@ internal class StreamClientImpl( .fold( onSuccess = { connected -> logger.d { "Connected to socket: $connected" } + if (networkMonitorHandle == null) { + logger.v { "[connect] Starting network monitor" } + networkMonitorHandle = + networkMonitor + .subscribe( + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected( + snapshot: StreamNetworkInfo.Snapshot? + ) { + logger.v { + "[connect] Network connected: $snapshot" + } + internalNetworkInfo.update { snapshot } + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "[connect] Network lost" } + internalNetworkInfo.update { null } + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + logger.v { "[connect] Network changed: $snapshot" } + internalNetworkInfo.update { snapshot } + } + }, + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention + .KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() + } + networkMonitor.start() mutableConnectionState.update(connected) connectionIdHolder.setConnectionId(connected.connectionId).map { connected.connectedUser diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 42c2642..ab40a30 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -93,10 +93,10 @@ internal class StreamNetworkSnapshotBuilder( } else { null } - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } val bandwidthConstrained = when ( networkCapabilities.flag( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index 93968cc..8e5c4be 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -17,7 +17,6 @@ package io.getstream.android.core.api -import android.net.ConnectivityManager import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider @@ -52,7 +51,6 @@ import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.StreamWebSocketImpl import io.getstream.android.core.testutil.assertFieldEquals import io.getstream.android.core.testutil.readPrivateField -import io.mockk.every import io.mockk.mockk import kotlin.test.assertEquals import kotlin.test.assertNotSame diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 0b2fd91..5bb56ed 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -96,7 +96,12 @@ class StreamClientIImplTest { mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, - networkMonitor = mockk(relaxed = true), + networkMonitor = + mockk(relaxed = true) { + every { start() } returns Result.success(Unit) + every { stop() } returns Result.success(Unit) + every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) + }, ) @Test diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 32d7550..49437c2 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -24,14 +24,14 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P]) @@ -55,9 +55,7 @@ internal class StreamNetworkMonitorUtilsTest { @Test fun `sanitizeSsid trims markers and ignores unknown`() { - val info = mockk { - every { ssid } returns "\"Stream\"" - } + val info = mockk { every { ssid } returns "\"Stream\"" } assertEquals("Stream", sanitizeSsid(info)) every { info.ssid } returns "" @@ -66,14 +64,11 @@ internal class StreamNetworkMonitorUtilsTest { @Test fun `telephony helpers unwrap signal values`() { - val manager = mockk { - every { signalStrength } returns mockk(relaxed = true) - } + val manager = + mockk { every { signalStrength } returns mockk(relaxed = true) } assertEquals(manager.signalStrength, telephonySignalStrength(manager)) - val nrSignalStrength = mockk(relaxed = true) { - every { level } returns 3 - } + val nrSignalStrength = mockk(relaxed = true) { every { level } returns 3 } assertEquals(3, signalLevel(nrSignalStrength)) } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt index 727a864..ac89e6d 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -15,7 +15,6 @@ */ package io.getstream.android.core.internal.observers.network -import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build @@ -28,7 +27,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.AfterTest @@ -37,8 +35,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Assert.assertTrue import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -65,12 +61,13 @@ internal class StreamNetworkSignalProcessingTest { @Test fun `bestEffortSignal returns wifi signal when wifi transport available`() { - val wifiInfo = mockk { - every { rssi } returns -45 - every { ssid } returns "\"Stream\"" - every { bssid } returns "00:11:22:33:44:55" - every { frequency } returns 5200 - } + val wifiInfo = + mockk { + every { rssi } returns -45 + every { ssid } returns "\"Stream\"" + every { bssid } returns "00:11:22:33:44:55" + every { frequency } returns 5200 + } every { wifiManager.connectionInfo } returns wifiInfo val signal = @@ -91,11 +88,12 @@ internal class StreamNetworkSignalProcessingTest { @Test fun `cellularSignal returns NR details when available`() { val strength = mockk(relaxed = true) - val nrStrength = mockk(relaxed = true) { - every { ssRsrp } returns -95 - every { ssRsrq } returns -10 - every { ssSinr } returns 18 - } + val nrStrength = + mockk(relaxed = true) { + every { ssRsrp } returns -95 + every { ssRsrq } returns -10 + every { ssSinr } returns 18 + } mockkStatic( "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt index d8981fc..134b719 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt @@ -28,10 +28,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.intArrayOf import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -39,7 +35,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.ExperimentalTime - +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @OptIn(ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @@ -66,19 +64,40 @@ internal class StreamNetworkSnapshotBuilderTest { every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns + true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns + true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns + true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns + true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) + } returns false + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) + } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns + true every { capabilities.linkDownstreamBandwidthKbps } returns 50_000 every { capabilities.linkUpstreamBandwidthKbps } returns 10_000 From c010b50aeb99a575a68465e4ef5c7d62c4b73915 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 15:46:25 +0200 Subject: [PATCH 08/23] Stop network monitor on Disconnect --- .../getstream/android/core/internal/client/StreamClientImpl.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 3e8ca0a..000b3ca 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -180,6 +180,7 @@ internal class StreamClientImpl( connectionIdHolder.clear() socketSession.disconnect() handle?.cancel() + networkMonitor.stop() networkMonitorHandle?.cancel() networkMonitorHandle = null handle = null From e945bfafa75dd222c46cc4dc2a11c84d55a5dbbd Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:07:15 +0200 Subject: [PATCH 09/23] Refactor the callback into separate delegate and add tests --- .../network/StreamNetworkMonitorCallback.kt | 221 ++++++++++ .../network/StreamNetworkMonitorImpl.kt | 205 +-------- .../StreamNetworkMonitorFactoryTest.kt | 51 +++ .../StreamNetworkMonitorCallbackTest.kt | 417 ++++++++++++++++++ .../network/StreamNetworkMonitorImplTest.kt | 204 +++++++++ .../network/StreamNetworkMonitorUtilsTest.kt | 12 +- 6 files changed, 911 insertions(+), 199 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt new file mode 100644 index 0000000..dba3500 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class StreamNetworkMonitorCallback( + private val logger: StreamLogger, + private val scope: CoroutineScope, + private val subscriptionManager: StreamSubscriptionManager, + private val snapshotBuilder: StreamNetworkSnapshotBuilder, + private val connectivityManager: ConnectivityManager, +) : ConnectivityManager.NetworkCallback() { + + private val activeState = AtomicReference() + + fun onRegistered() { + val initialState = resolveInitialState() + if (initialState != null) { + activeState.set(initialState) + notifyConnected(initialState.snapshot) + } + } + + fun onCleared() { + activeState.set(null) + } + + override fun onAvailable(network: Network) { + logger.v { "Network available: $network" } + handleUpdate(network, null, null, UpdateReason.AVAILABLE) + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + logger.v { "Network capabilities changed for $network" } + handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) + } + + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + logger.v { "Link properties changed for $network" } + handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) + } + + override fun onLost(network: Network) { + handleLoss(network, permanent = false) + } + + override fun onUnavailable() { + handleLoss(network = null, permanent = true) + } + + private fun resolveInitialState(): ActiveNetworkState? { + val defaultNetwork = resolveDefaultNetwork() ?: run { + logger.v { "No active network available at start" } + return null + } + val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) + val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) + val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null + return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) + } + + private fun resolveDefaultNetwork(): Network? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() + } else { + connectivityManager.allNetworks.firstOrNull() + } + + private fun buildSnapshot( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): StreamNetworkInfo.Snapshot? = + snapshotBuilder + .build(network, capabilities, linkProperties) + .getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } + + private fun handleUpdate( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + reason: UpdateReason, + ) { + if (!shouldProcessNetwork(network)) { + logger.v { "[handleUpdate] Ignoring network $network; not default." } + return + } + + val resolvedCapabilities = + capabilities ?: connectivityManager.getNetworkCapabilities(network) + val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) + val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) + if (snapshot == null) { + logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } + return + } + + val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) + val previousState = activeState.getAndSet(newState) + + val networkChanged = previousState?.network != network || previousState == null + val snapshotChanged = previousState?.snapshot != snapshot + + when { + reason == UpdateReason.AVAILABLE || networkChanged -> { + logger.v { "[handleUpdate] Active network set to $network" } + notifyConnected(snapshot) + } + + snapshotChanged -> { + logger.v { "[handleUpdate] Network properties updated for $network" } + notifyPropertiesChanged(snapshot) + } + + else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } + } + } + + private fun handleLoss(network: Network?, permanent: Boolean) { + val current = activeState.get() + if (current == null) { + logger.v { "[handleLoss] No active network to clear." } + return + } + + if (network != null && network != current.network) { + logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } + return + } + + if (activeState.compareAndSet(current, null)) { + logger.v { "[handleLoss] Network lost: ${current.network}" } + notifyLost(permanent) + } + } + + private fun shouldProcessNetwork(network: Network): Boolean { + val tracked = activeState.get()?.network + if (tracked != null && tracked == network) { + return true + } + return isDefaultNetwork(network) + } + + private fun isDefaultNetwork(network: Network): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork == network + } else { + connectivityManager.allNetworks.firstOrNull() == network + } + + private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { + if (snapshot == null) { + return + } + notifyListeners { listener -> listener.onNetworkConnected(snapshot) } + } + + private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { + notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } + } + + private fun notifyLost(permanent: Boolean) { + notifyListeners { listener -> listener.onNetworkLost(permanent) } + } + + private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { + subscriptionManager + .forEach { listener -> + scope.launch { + runCatching { block(listener) } + .onFailure { throwable -> + logger.e(throwable) { "Network monitor listener failure" } + } + } + } + .onFailure { throwable -> + logger.e(throwable) { "Failed to iterate network monitor listeners" } + } + } + + private data class ActiveNetworkState( + val network: Network, + val capabilities: NetworkCapabilities?, + val linkProperties: LinkProperties?, + val snapshot: StreamNetworkInfo.Snapshot, + ) + + private enum class UpdateReason { + AVAILABLE, + PROPERTIES, + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt index 12e325b..d76e787 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt @@ -17,14 +17,11 @@ package io.getstream.android.core.internal.observers.network import android.annotation.SuppressLint import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.log.StreamLogger -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.subscribe.StreamSubscription @@ -32,7 +29,6 @@ import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @StreamInternalApi internal class StreamNetworkMonitorImpl( @@ -42,9 +38,9 @@ internal class StreamNetworkMonitorImpl( private val snapshotBuilder: StreamNetworkSnapshotBuilder, private val connectivityManager: ConnectivityManager, ) : StreamNetworkMonitor { + private val started = AtomicBoolean(false) - private val networkCallbackRef = AtomicReference() - private val activeState = AtomicReference() + private val callbackRef = AtomicReference() override fun subscribe( listener: StreamNetworkMonitorListener, @@ -58,28 +54,32 @@ internal class StreamNetworkMonitorImpl( return@runCatching } - val callback = MonitorCallback() - networkCallbackRef.set(callback) + val callback = + StreamNetworkMonitorCallback( + logger = logger, + scope = scope, + subscriptionManager = streamSubscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + callbackRef.set(callback) try { registerCallback(callback) - resolveInitialState()?.also { initialState -> - activeState.set(initialState) - notifyConnected(initialState.snapshot) - } + callback.onRegistered() } catch (throwable: Throwable) { logger.e(throwable) { "Failed to start network monitor" } safeUnregister(callback) + callback.onCleared() cleanup() throw throwable } } override fun stop(): Result = runCatching { - val callback = networkCallbackRef.getAndSet(null) - if (callback != null) { - safeUnregister(callback) - } + val callback = callbackRef.getAndSet(null) ?: return@runCatching + safeUnregister(callback) + callback.onCleared() cleanup() } @@ -95,140 +95,7 @@ internal class StreamNetworkMonitorImpl( } } - private fun resolveInitialState(): ActiveNetworkState? { - val defaultNetwork = - resolveDefaultNetwork() - ?: run { - logger.v { "No active network available at start" } - return null - } - val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) - val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) - val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null - return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) - } - - private fun resolveDefaultNetwork(): Network? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() - } else { - connectivityManager.allNetworks.firstOrNull() - } - - private fun buildSnapshot( - network: Network, - capabilities: NetworkCapabilities?, - linkProperties: LinkProperties?, - ): StreamNetworkInfo.Snapshot? = - snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> - logger.e(throwable) { "Failed to assemble network snapshot" } - null - } - - private fun handleUpdate( - network: Network, - capabilities: NetworkCapabilities?, - linkProperties: LinkProperties?, - reason: UpdateReason, - ) { - if (!shouldProcessNetwork(network)) { - logger.v { "[handleUpdate] Ignoring network $network; not default." } - return - } - - val resolvedCapabilities = - capabilities ?: connectivityManager.getNetworkCapabilities(network) - val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) - val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) - if (snapshot == null) { - logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } - return - } - - val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) - val previousState = activeState.getAndSet(newState) - - val networkChanged = previousState?.network != network || previousState == null - val snapshotChanged = previousState?.snapshot != snapshot - - when { - reason == UpdateReason.AVAILABLE || networkChanged -> { - logger.v { "[handleUpdate] Active network set to $network" } - notifyConnected(snapshot) - } - - snapshotChanged -> { - logger.v { "[handleUpdate] Network properties updated for $network" } - notifyPropertiesChanged(snapshot) - } - - else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } - } - } - - private fun handleLoss(network: Network?, permanent: Boolean) { - val current = activeState.get() - if (current == null) { - logger.v { "[handleLoss] No active network to clear." } - return - } - - if (network != null && network != current.network) { - logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } - return - } - - if (activeState.compareAndSet(current, null)) { - logger.v { "[handleLoss] Network lost: ${current.network}" } - notifyLost(permanent) - } - } - - private fun shouldProcessNetwork(network: Network): Boolean { - val tracked = activeState.get()?.network - if (tracked != null && tracked == network) return true - return isDefaultNetwork(network) - } - - private fun isDefaultNetwork(network: Network): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.activeNetwork == network - } else { - connectivityManager.allNetworks.firstOrNull() == network - } - - private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { - if (snapshot == null) { - return - } - notifyListeners { listener -> listener.onNetworkConnected(snapshot) } - } - - private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { - notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } - } - - private fun notifyLost(permanent: Boolean = false) { - notifyListeners { listener -> listener.onNetworkLost(permanent) } - } - - private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { - streamSubscriptionManager - .forEach { listener -> - scope.launch { - runCatching { block(listener) } - .onFailure { throwable -> - logger.e(throwable) { "Network monitor listener failure" } - } - } - } - .onFailure { throwable -> - logger.e(throwable) { "Failed to iterate network monitor listeners" } - } - } - private fun cleanup() { - activeState.set(null) started.set(false) } @@ -236,44 +103,4 @@ internal class StreamNetworkMonitorImpl( runCatching { connectivityManager.unregisterNetworkCallback(callback) } .onFailure { logger.w { "Failed to unregister network callback: ${it.message}" } } } - - private inner class MonitorCallback : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - logger.v { "Network available: $network" } - handleUpdate(network, null, null, UpdateReason.AVAILABLE) - } - - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities, - ) { - logger.v { "Network capabilities changed for $network" } - handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) - } - - override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { - logger.v { "Link properties changed for $network" } - handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) - } - - override fun onLost(network: Network) { - handleLoss(network, permanent = false) - } - - override fun onUnavailable() { - handleLoss(network = null, permanent = true) - } - } - - private data class ActiveNetworkState( - val network: Network, - val capabilities: NetworkCapabilities?, - val linkProperties: LinkProperties?, - val snapshot: StreamNetworkInfo.Snapshot, - ) - - private enum class UpdateReason { - AVAILABLE, - PROPERTIES, - } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt new file mode 100644 index 0000000..d1047c0 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import android.net.ConnectivityManager +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.network.StreamNetworkMonitorImpl +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertTrue + +internal class StreamNetworkMonitorFactoryTest { + + @Test + fun `factory creates monitor instance`() { + val logger = mockk(relaxed = true) + val subscriptionManager = mockk>(relaxed = true) + val scope = TestScope(StandardTestDispatcher()) + val connectivityManager = mockk() + + val monitor = + StreamNetworkMonitor( + logger = logger, + scope = scope, + subscriptionManager = subscriptionManager, + wifiManager = mockk(relaxed = true), + telephonyManager = mockk(relaxed = true), + connectivityManager = connectivityManager, + ) + + assertTrue(monitor is StreamNetworkMonitorImpl) + assertNotNull(monitor) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt new file mode 100644 index 0000000..12307be --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +internal class StreamNetworkMonitorCallbackTest { + + @MockK(relaxed = true) lateinit var logger: StreamLogger + @MockK(relaxed = true) lateinit var connectivityManager: ConnectivityManager + @MockK(relaxed = true) lateinit var snapshotBuilder: StreamNetworkSnapshotBuilder + + private lateinit var scope: TestScope + private lateinit var subscriptionManager: RecordingSubscriptionManager + private lateinit var callback: StreamNetworkMonitorCallback + private lateinit var primaryListener: StreamNetworkMonitorListener + private lateinit var secondaryListener: StreamNetworkMonitorListener + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + scope = TestScope(StandardTestDispatcher()) + subscriptionManager = RecordingSubscriptionManager() + primaryListener = mockk(relaxed = true) + secondaryListener = mockk(relaxed = true) + subscriptionManager.subscribe(primaryListener).getOrThrow() + subscriptionManager.subscribe(secondaryListener).getOrThrow() + + callback = + StreamNetworkMonitorCallback( + logger = logger, + scope = scope, + subscriptionManager = subscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + } + + @AfterTest + fun tearDown() { + clearMocks( + logger, + connectivityManager, + snapshotBuilder, + primaryListener, + secondaryListener, + answers = false, + ) + } + + @Test + fun `onRegistered emits initial snapshot when default network available`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onRegistered() + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(snapshot) } + coVerify { secondaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `onRegistered skips when no default network`() { + every { connectivityManager.activeNetwork } returns null + every { connectivityManager.allNetworks } returns emptyArray() + + callback.onRegistered() + scope.advanceUntilIdle() + + verify(exactly = 0) { connectivityManager.getNetworkCapabilities(any()) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + coVerify(exactly = 0) { secondaryListener.onNetworkConnected(any()) } + } + + @Test + fun `onAvailable ignores non-default network`() { + val defaultNetwork = mockk() + val otherNetwork = mockk() + + every { connectivityManager.activeNetwork } returns defaultNetwork + + callback.onAvailable(otherNetwork) + scope.advanceUntilIdle() + + verify(exactly = 0) { connectivityManager.getNetworkCapabilities(otherNetwork) } + verify(exactly = 0) { snapshotBuilder.build(any(), any(), any()) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + @Test + fun `onAvailable publishes snapshot for default network`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(snapshot) } + coVerify { secondaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `onAvailable logs snapshot build failure`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val error = IllegalStateException("boom") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.failure(error) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + @Test + fun `capabilities change triggers properties update when snapshot differs`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val initialSnapshot = mockk() + val updatedSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(initialSnapshot) } + coVerify { primaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + coVerify { secondaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + } + + @Test + fun `capabilities change with identical snapshot does nothing`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(snapshot), Result.success(snapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify(exactly = 0) { primaryListener.onNetworkPropertiesChanged(any()) } + coVerify(exactly = 1) { primaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `link properties change triggers properties update`() { + val network = mockk() + val capabilities = mockk() + val initialLink = mockk() + val updatedLink = mockk() + val initialSnapshot = mockk() + val updatedSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returnsMany listOf(initialLink, updatedLink) + every { + snapshotBuilder.build(network, capabilities, any()) + } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLinkPropertiesChanged(network, updatedLink) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + } + + @Test + fun `lost network clears state and notifies listeners`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkLost(false) } + coVerify { secondaryListener.onNetworkLost(false) } + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(false) } + } + + @Test + fun `lost event for different network is ignored`() { + val network = mockk() + val other = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(other) + scope.advanceUntilIdle() + + coVerify(exactly = 0) { primaryListener.onNetworkLost(any()) } + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(false) } + } + + @Test + fun `onUnavailable reports permanent loss once`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onUnavailable() + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkLost(true) } + coVerify { secondaryListener.onNetworkLost(true) } + + callback.onUnavailable() + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(true) } + } + + @Test + fun `listener failure is logged but other listeners still notified`() { + val failingListener = primaryListener + val healthyListener = secondaryListener + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + val error = IllegalArgumentException("listener crash") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + coEvery { failingListener.onNetworkConnected(snapshot) } throws error + coEvery { healthyListener.onNetworkConnected(snapshot) } just runs + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Network monitor listener failure") == true }) } + coVerify { healthyListener.onNetworkConnected(snapshot) } + } + + @Test + fun `subscription iteration failure is logged`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + val error = IllegalStateException("iteration error") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + subscriptionManager.forEachFailure = error + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Failed to iterate network monitor listeners") == true }) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + private class RecordingSubscriptionManager : StreamSubscriptionManager { + private val listeners = linkedSetOf() + var forEachFailure: Throwable? = null + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result { + listeners += listener + return Result.success(object : StreamSubscription { + private var cancelled = false + override fun cancel() { + if (!cancelled) { + cancelled = true + listeners -= listener + } + } + }) + } + + override fun clear(): Result { + listeners.clear() + return Result.success(Unit) + } + + override fun forEach(block: (StreamNetworkMonitorListener) -> Unit): Result { + val failure = forEachFailure + if (failure != null) { + return Result.failure(failure) + } + listeners.forEach(block) + return Result.success(Unit) + } + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt new file mode 100644 index 0000000..42bd200 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +internal class StreamNetworkMonitorImplTest { + + @MockK(relaxed = true) lateinit var logger: StreamLogger + @MockK(relaxed = true) lateinit var connectivityManager: ConnectivityManager + @MockK(relaxed = true) lateinit var snapshotBuilder: StreamNetworkSnapshotBuilder + + private lateinit var scope: TestScope + private lateinit var subscriptionManager: RecordingSubscriptionManager + private lateinit var monitor: StreamNetworkMonitorImpl + + private val listener = mockk(relaxed = true) + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this) + scope = TestScope(StandardTestDispatcher()) + subscriptionManager = RecordingSubscriptionManager() + monitor = + StreamNetworkMonitorImpl( + logger = logger, + scope = scope, + streamSubscriptionManager = subscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + monitor.subscribe(listener) + } + + @Test + fun `start registers callback and emits initial snapshot`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start().getOrThrow() + callbackSlot.captured.onAvailable(network) + scope.advanceUntilIdle() + + verify { connectivityManager.registerDefaultNetworkCallback(any()) } + coVerify { listener.onNetworkConnected(snapshot) } + } + + @Test + fun `snapshot failure during update does not notify`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val initialSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start() + callbackSlot.captured.onAvailable(network) + scope.advanceUntilIdle() + + callbackSlot.captured.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { listener.onNetworkConnected(initialSnapshot) } + } + + @Test + fun `network loss notifies listeners`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start() + val callback = callbackSlot.captured + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(network) + scope.advanceUntilIdle() + coVerify { listener.onNetworkLost(false) } + + callback.onAvailable(network) + scope.advanceUntilIdle() + callback.onUnavailable() + scope.advanceUntilIdle() + coVerify { listener.onNetworkLost(true) } + } + + @Test + fun `stop unregisters callback`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.unregisterNetworkCallback(any()) } just runs + + monitor.start() + val callback = callbackSlot.captured + monitor.stop().getOrThrow() + + verify { connectivityManager.unregisterNetworkCallback(callback) } + } + + private class RecordingSubscriptionManager : StreamSubscriptionManager { + private val listeners = mutableSetOf() + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result { + listeners += listener + return Result.success(mockk(relaxed = true)) + } + + override fun clear(): Result { + listeners.clear() + return Result.success(Unit) + } + + override fun forEach(block: (StreamNetworkMonitorListener) -> Unit): Result { + listeners.forEach(block) + return Result.success(Unit) + } + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 49437c2..3c9b765 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -18,6 +18,8 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.mockk.MockKAnnotations @@ -61,14 +63,4 @@ internal class StreamNetworkMonitorUtilsTest { every { info.ssid } returns "" assertNull(sanitizeSsid(info)) } - - @Test - fun `telephony helpers unwrap signal values`() { - val manager = - mockk { every { signalStrength } returns mockk(relaxed = true) } - assertEquals(manager.signalStrength, telephonySignalStrength(manager)) - - val nrSignalStrength = mockk(relaxed = true) { every { level } returns 3 } - assertEquals(3, signalLevel(nrSignalStrength)) - } } From fce3cfa4c328a38117bd85ceb8153a4b705edffd Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:10:15 +0200 Subject: [PATCH 10/23] Fix lint errors and run spotless --- .../network/StreamNetworkMonitorCallback.kt | 20 ++--- .../network/StreamNetworkSnapshotBuilder.kt | 28 +++--- .../StreamNetworkMonitorFactoryTest.kt | 3 +- .../StreamNetworkMonitorCallbackTest.kt | 87 ++++++++++++------- .../network/StreamNetworkMonitorImplTest.kt | 37 +++++--- .../network/StreamNetworkMonitorUtilsTest.kt | 4 - 6 files changed, 111 insertions(+), 68 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt index dba3500..97b44b5 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt @@ -74,10 +74,12 @@ internal class StreamNetworkMonitorCallback( } private fun resolveInitialState(): ActiveNetworkState? { - val defaultNetwork = resolveDefaultNetwork() ?: run { - logger.v { "No active network available at start" } - return null - } + val defaultNetwork = + resolveDefaultNetwork() + ?: run { + logger.v { "No active network available at start" } + return null + } val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null @@ -96,12 +98,10 @@ internal class StreamNetworkMonitorCallback( capabilities: NetworkCapabilities?, linkProperties: LinkProperties?, ): StreamNetworkInfo.Snapshot? = - snapshotBuilder - .build(network, capabilities, linkProperties) - .getOrElse { throwable -> - logger.e(throwable) { "Failed to assemble network snapshot" } - null - } + snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } private fun handleUpdate( network: Network, diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index ab40a30..2289d99 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -20,6 +20,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.wifi.WifiManager import android.os.Build +import android.os.ext.SdkExtensions import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Bandwidth @@ -69,9 +70,11 @@ internal class StreamNetworkSnapshotBuilder( when { networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> Metered.NOT_METERED + networkCapabilities.flag( NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED ) == true -> Metered.TEMPORARILY_NOT_METERED + else -> Metered.UNKNOWN_OR_METERED } @@ -93,26 +96,31 @@ internal class StreamNetworkSnapshotBuilder( } else { null } - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } val bandwidthConstrained = - when ( - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED - ) + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 ) { - true -> false - else -> null + when ( + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED + ) + ) { + true -> false + else -> null + } + } else { + null } val priority = when { networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == true -> PriorityHint.LATENCY + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == true -> PriorityHint.BANDWIDTH + else -> PriorityHint.NONE } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt index d1047c0..e9056f1 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt @@ -31,7 +31,8 @@ internal class StreamNetworkMonitorFactoryTest { @Test fun `factory creates monitor instance`() { val logger = mockk(relaxed = true) - val subscriptionManager = mockk>(relaxed = true) + val subscriptionManager = + mockk>(relaxed = true) val scope = TestScope(StandardTestDispatcher()) val connectivityManager = mockk() diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt index 12307be..282418a 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt @@ -26,9 +26,9 @@ import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListe import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.mockk.MockKAnnotations -import io.mockk.coVerify import io.mockk.clearMocks import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just @@ -103,7 +103,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onRegistered() scope.advanceUntilIdle() @@ -150,7 +151,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -169,12 +171,18 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.failure(error) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.failure(error) callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }) } + verify { + logger.e( + error, + match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }, + ) + } coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } } @@ -189,9 +197,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -214,9 +221,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(snapshot), Result.success(snapshot)) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(snapshot), Result.success(snapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -239,10 +245,10 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities - every { connectivityManager.getLinkProperties(network) } returnsMany listOf(initialLink, updatedLink) - every { - snapshotBuilder.build(network, capabilities, any()) - } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + every { connectivityManager.getLinkProperties(network) } returnsMany + listOf(initialLink, updatedLink) + every { snapshotBuilder.build(network, capabilities, any()) } returnsMany + listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -263,7 +269,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -291,7 +298,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -317,7 +325,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -347,14 +356,20 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) coEvery { failingListener.onNetworkConnected(snapshot) } throws error coEvery { healthyListener.onNetworkConnected(snapshot) } just runs callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Network monitor listener failure") == true }) } + verify { + logger.e( + error, + match { it?.invoke()?.contains("Network monitor listener failure") == true }, + ) + } coVerify { healthyListener.onNetworkConnected(snapshot) } } @@ -369,18 +384,27 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) subscriptionManager.forEachFailure = error callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Failed to iterate network monitor listeners") == true }) } + verify { + logger.e( + error, + match { + it?.invoke()?.contains("Failed to iterate network monitor listeners") == true + }, + ) + } coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } } - private class RecordingSubscriptionManager : StreamSubscriptionManager { + private class RecordingSubscriptionManager : + StreamSubscriptionManager { private val listeners = linkedSetOf() var forEachFailure: Throwable? = null @@ -389,15 +413,18 @@ internal class StreamNetworkMonitorCallbackTest { options: StreamSubscriptionManager.Options, ): Result { listeners += listener - return Result.success(object : StreamSubscription { - private var cancelled = false - override fun cancel() { - if (!cancelled) { - cancelled = true - listeners -= listener + return Result.success( + object : StreamSubscription { + private var cancelled = false + + override fun cancel() { + if (!cancelled) { + cancelled = true + listeners -= listener + } } } - }) + ) } override fun clear(): Result { diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt index 42bd200..8f909a6 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt @@ -41,9 +41,9 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.junit.runner.RunWith @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -86,10 +86,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start().getOrThrow() callbackSlot.captured.onAvailable(network) @@ -109,12 +111,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start() callbackSlot.captured.onAvailable(network) @@ -136,10 +138,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start() val callback = callbackSlot.captured @@ -167,11 +171,17 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs - every { connectivityManager.unregisterNetworkCallback(any()) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs + every { + connectivityManager.unregisterNetworkCallback( + any() + ) + } just runs monitor.start() val callback = callbackSlot.captured @@ -180,7 +190,8 @@ internal class StreamNetworkMonitorImplTest { verify { connectivityManager.unregisterNetworkCallback(callback) } } - private class RecordingSubscriptionManager : StreamSubscriptionManager { + private class RecordingSubscriptionManager : + StreamSubscriptionManager { private val listeners = mutableSetOf() override fun subscribe( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 3c9b765..3791f08 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -18,10 +18,6 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.os.Build -import android.telephony.CellSignalStrengthLte -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength -import android.telephony.TelephonyManager import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK From 3ff9bb1f8d4b74f99b069bb79e9eea4a01739437 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:29:20 +0200 Subject: [PATCH 11/23] Refactor snapshot builder and add tests --- .../network/StreamNetworkSnapshotBuilder.kt | 296 +++++++++--------- ...eamNetworkSnapshotBuilderLatestApiTest.kt} | 9 +- ...reamNetworkSnapshotBuilderLegacyApiTest.kt | 60 ++++ 3 files changed, 215 insertions(+), 150 deletions(-) rename stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/{StreamNetworkSnapshotBuilderTest.kt => StreamNetworkSnapshotBuilderLatestApiTest.kt} (93%) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 2289d99..b9fe8bf 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -30,10 +30,12 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo. import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport import kotlin.time.ExperimentalTime +@Suppress("NewApi") internal class StreamNetworkSnapshotBuilder( private val signalProcessing: StreamNetworkSignalProcessing, private val wifiManager: WifiManager, private val telephonyManager: TelephonyManager, + private val extensionVersionProvider: (Int) -> Int = DEFAULT_EXTENSION_PROVIDER, ) { @OptIn(ExperimentalTime::class) fun build( @@ -41,161 +43,78 @@ internal class StreamNetworkSnapshotBuilder( networkCapabilities: NetworkCapabilities?, linkProperties: LinkProperties?, ): Result = runCatching { - val transports = transportsFor(networkCapabilities) - val internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val validated = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } else { - null - } - val captivePortal = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) - } else { - null - } - val notVpn = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - val vpn = - networkCapabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || (notVpn == false) - val trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED) - val localOnly = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) - } else { - null - } - - val metered = - when { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> - Metered.NOT_METERED - - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED - ) == true -> Metered.TEMPORARILY_NOT_METERED - - else -> Metered.UNKNOWN_OR_METERED - } - - val congested = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { - true -> false - else -> null - } - } else { - null - } - val suspended = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } - } else { - null - } - val bandwidthConstrained = - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 - ) { - when ( - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED - ) - ) { - true -> false - else -> null - } - } else { - null - } - - val priority = - when { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == - true -> PriorityHint.LATENCY - - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == - true -> PriorityHint.BANDWIDTH - - else -> PriorityHint.NONE - } - - val bandwidth = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Bandwidth( - downKbps = networkCapabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, - upKbps = networkCapabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 }, - ) - } else { - null - } - - val signal = - signalProcessing.bestEffortSignal( - wifiManager, - telephonyManager, - networkCapabilities, - transports, - ) + if (!supportsSnapshots()) { + return@runCatching null + } - val link = linkProperties?.toLink() - - val notRoaming = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) - } else { - null - } + val transports = transportsFor(networkCapabilities) + val metered = networkCapabilities.resolveMetered() StreamNetworkInfo.Snapshot( transports = transports, - internet = internet, - validated = validated, - captivePortal = captivePortal, - vpn = vpn, - trusted = trusted, - localOnly = localOnly, + internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET), + validated = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + Build.VERSION_CODES.M, + ), + captivePortal = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL, + Build.VERSION_CODES.M, + ), + vpn = isVpn(networkCapabilities), + trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED), + localOnly = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK, + Build.VERSION_CODES.VANILLA_ICE_CREAM, + ), metered = metered, - roaming = notRoaming?.not(), - congested = congested, - suspended = suspended, - bandwidthConstrained = bandwidthConstrained, - bandwidthKbps = bandwidth, - priority = priority, - signal = signal, - link = link, + roaming = + networkCapabilities + .flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING, + Build.VERSION_CODES.P, + ) + ?.not(), + congested = + networkCapabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED, + Build.VERSION_CODES.P, + ), + suspended = + networkCapabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED, + Build.VERSION_CODES.P, + ), + bandwidthConstrained = bandwidthConstraintHint(networkCapabilities), + bandwidthKbps = bandwidthFor(networkCapabilities), + priority = resolvePriority(networkCapabilities), + signal = + signalProcessing.bestEffortSignal( + wifiManager, + telephonyManager, + networkCapabilities, + transports, + ), + link = linkProperties?.toLink(), ) } private fun transportsFor(capabilities: NetworkCapabilities?): Set { - if (capabilities == null) return emptySet() - val out = mutableSetOf() - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) - out += Transport.WIFI - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) - out += Transport.CELLULAR - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true) - out += Transport.ETHERNET - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) == true) - out += Transport.BLUETOOTH - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) == true) - out += Transport.WIFI_AWARE - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_LOWPAN) == true) - out += Transport.LOW_PAN - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_USB) == true) - out += Transport.USB - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_THREAD) == true) - out += Transport.THREAD - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_SATELLITE) == true) - out += Transport.SATELLITE - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_VPN) == true) - out += Transport.VPN - if (out.isEmpty()) out += Transport.UNKNOWN - return out + if (capabilities == null) { + return emptySet() + } + val transports = + KNOWN_TRANSPORTS.mapNotNull { (id, transport) -> + transport.takeIf { capabilities.safeHasTransport(id) == true } + } + .toMutableSet() + if (transports.isEmpty()) { + transports += Transport.UNKNOWN + } + return transports } private fun LinkProperties.toLink(): Link? { @@ -203,7 +122,11 @@ internal class StreamNetworkSnapshotBuilder( val dnsServers = dnsServers.mapNotNull { it.hostAddress } val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() val mtuValue = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + mtu.takeIf { it > 0 } + } else { + null + } val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } return Link( interfaceName = interfaceName, @@ -220,4 +143,83 @@ internal class StreamNetworkSnapshotBuilder( private fun NetworkCapabilities?.transport(transport: Int): Boolean = this?.safeHasTransport(transport) == true + + private fun supportsSnapshots(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + private fun NetworkCapabilities?.flagIfAtLeast(capability: Int, minSdk: Int): Boolean? = + if (Build.VERSION.SDK_INT >= minSdk) flag(capability) else null + + private fun NetworkCapabilities?.negatedCapabilityAsFalse( + capability: Int, + minSdk: Int, + ): Boolean? = + when (flagIfAtLeast(capability, minSdk)) { + true -> false + else -> null + } + + private fun NetworkCapabilities?.resolveMetered(): Metered = + when { + flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> Metered.NOT_METERED + flag(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) == true -> + Metered.TEMPORARILY_NOT_METERED + + else -> Metered.UNKNOWN_OR_METERED + } + + private fun isVpn(capabilities: NetworkCapabilities?): Boolean = + capabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false + + private fun bandwidthConstraintHint(capabilities: NetworkCapabilities?): Boolean? = + if (isBandwidthConstraintSupported()) { + capabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED, + Build.VERSION_CODES.R, + ) + } else { + null + } + + private fun isBandwidthConstraintSupported(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + extensionVersionProvider(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 + + private fun bandwidthFor(capabilities: NetworkCapabilities?): Bandwidth? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null + val down = capabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 } + val up = capabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 } + return if (down != null || up != null) Bandwidth(downKbps = down, upKbps = up) else null + } + + private fun resolvePriority(capabilities: NetworkCapabilities?): PriorityHint = + when { + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == true -> + PriorityHint.LATENCY + + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == true -> + PriorityHint.BANDWIDTH + + else -> PriorityHint.NONE + } + + private companion object { + private val DEFAULT_EXTENSION_PROVIDER: (Int) -> Int = { extension -> + SdkExtensions.getExtensionVersion(extension) + } + + private val KNOWN_TRANSPORTS = + listOf( + NetworkCapabilities.TRANSPORT_WIFI to Transport.WIFI, + NetworkCapabilities.TRANSPORT_CELLULAR to Transport.CELLULAR, + NetworkCapabilities.TRANSPORT_ETHERNET to Transport.ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH to Transport.BLUETOOTH, + NetworkCapabilities.TRANSPORT_WIFI_AWARE to Transport.WIFI_AWARE, + NetworkCapabilities.TRANSPORT_LOWPAN to Transport.LOW_PAN, + NetworkCapabilities.TRANSPORT_USB to Transport.USB, + NetworkCapabilities.TRANSPORT_THREAD to Transport.THREAD, + NetworkCapabilities.TRANSPORT_SATELLITE to Transport.SATELLITE, + NetworkCapabilities.TRANSPORT_VPN to Transport.VPN, + ) + } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt similarity index 93% rename from stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt rename to stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt index 134b719..d8431e5 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt @@ -41,8 +41,8 @@ import org.robolectric.annotation.Config @OptIn(ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.Q]) -internal class StreamNetworkSnapshotBuilderTest { +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamNetworkSnapshotBuilderLatestApiTest { @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing @MockK(relaxed = true) lateinit var wifiManager: WifiManager @@ -53,7 +53,8 @@ internal class StreamNetworkSnapshotBuilderTest { @BeforeTest fun setup() { MockKAnnotations.init(this) - builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + builder = + StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) { 20 } } @Test @@ -78,6 +79,8 @@ internal class StreamNetworkSnapshotBuilderTest { every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns + false every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt new file mode 100644 index 0000000..5bba942 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.time.ExperimentalTime +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalTime::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamNetworkSnapshotBuilderLegacyApiTest { + + @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var builder: StreamNetworkSnapshotBuilder + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + } + + @Test + fun `build returns null for legacy api`() { + val network = mockk() + val capabilities = mockk() + + val snapshot = builder.build(network, capabilities, linkProperties = null).getOrThrow() + + assertNull(snapshot) + } +} From dd9922636c34f5ac462edf32766aab27ac4a14a6 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:45:09 +0200 Subject: [PATCH 12/23] Refactor signal processing and add tests --- .../network/StreamNetworkMonitorUtils.kt | 39 ---- .../network/StreamNetworkSignalProcessing.kt | 103 ++++++++--- .../network/StreamNetworkMonitorUtilsTest.kt | 13 +- ...eamNetworkSignalProcessingLatestApiTest.kt | 170 ++++++++++++++++++ ...eamNetworkSignalProcessingLegacyApiTest.kt | 60 +++++++ .../StreamNetworkSignalProcessingTest.kt | 28 --- 6 files changed, 311 insertions(+), 102 deletions(-) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt index 33afb9b..e7e807c 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -16,48 +16,9 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities -import android.net.wifi.WifiInfo -import android.net.wifi.WifiManager -import android.os.Build -import android.telephony.CellSignalStrengthLte -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength -import android.telephony.TelephonyManager internal fun NetworkCapabilities.safeHasCapability(capability: Int): Boolean? = runCatching { hasCapability(capability) }.getOrNull() internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = runCatching { hasTransport(transport) }.getOrNull() - -internal fun sanitizeSsid(info: WifiInfo): String? { - val raw = info.ssid?.trim('"') ?: return null - val isPlatformUnknown = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID - else false - val isLegacyUnknown = raw == "" - return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } -} - -internal fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = - runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() - -internal fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null - -internal fun signalLevel(strength: SignalStrength?): Int? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null - -internal fun nrStrength(strength: SignalStrength?): CellSignalStrengthNr? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() - } else { - null - } - -internal fun lteStrength(strength: SignalStrength?): CellSignalStrengthLte? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() - } else { - null - } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt index 872fd94..bb40a59 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt @@ -15,30 +15,28 @@ */ package io.getstream.android.core.internal.observers.network -import android.annotation.SuppressLint import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport internal class StreamNetworkSignalProcessing { - @SuppressLint("MissingPermission") fun bestEffortSignal( wifiManager: WifiManager, telephonyManager: TelephonyManager, capabilities: NetworkCapabilities?, transports: Set, ): Signal? { - val genericValue = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } - } else { - null - } - if (genericValue != null) return Signal.Generic(genericValue) + genericSignal(capabilities)?.let { + return it + } return when { Transport.WIFI in transports -> wifiSignal(wifiManager) @@ -47,7 +45,6 @@ internal class StreamNetworkSignalProcessing { } } - @SuppressLint("MissingPermission") fun wifiSignal(wifiManager: WifiManager): Signal.Wifi? { val info = wifiManager.connectionInfo ?: return null val rssi = info.rssi @@ -60,36 +57,27 @@ internal class StreamNetworkSignalProcessing { ) } - @SuppressLint("MissingPermission") fun cellularSignal(telephonyManager: TelephonyManager): Signal.Cellular? { val strength = telephonySignalStrength(telephonyManager) ?: return null val level = signalLevel(strength) - val nr = nrStrength(strength) - if (nr != null) { - val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrp else null - val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrq else null - val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssSinr else null + nrReport(strength)?.let { nr -> return Signal.Cellular( rat = "NR", level0to4 = level, - rsrpDbm = rsrp, - rsrqDb = rsrq, - sinrDb = sinr, + rsrpDbm = nr.ssRsrpValue(), + rsrqDb = nr.ssRsrqValue(), + sinrDb = nr.ssSinrValue(), ) } - val lte = lteStrength(strength) - if (lte != null) { - val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrp else null - val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrq else null - val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rssnr else null + lteReport(strength)?.let { lte -> return Signal.Cellular( rat = "LTE", level0to4 = level, - rsrpDbm = rsrp, - rsrqDb = rsrq, - sinrDb = sinr, + rsrpDbm = lte.rsrpValue(), + rsrqDb = lte.rsrqValue(), + sinrDb = lte.rssnrValue(), ) } @@ -101,4 +89,65 @@ internal class StreamNetworkSignalProcessing { sinrDb = null, ) } + + private fun genericSignal(capabilities: NetworkCapabilities?): Signal.Generic? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return null + } + val strength = capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } ?: return null + return Signal.Generic(strength) + } + + private fun sanitizeSsid(info: WifiInfo): String? { + val raw = info.ssid?.trim('"') ?: return null + val isPlatformUnknown = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + raw == WifiManager.UNKNOWN_SSID + } else { + false + } + val isLegacyUnknown = raw == "" + return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } + } + + private fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = + runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() + + private fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null + + private fun signalLevel(strength: SignalStrength?): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null + + private fun nrReport(strength: SignalStrength?): CellSignalStrengthNr? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + + private fun lteReport(strength: SignalStrength?): CellSignalStrengthLte? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + + private fun CellSignalStrengthNr.ssRsrpValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssRsrp else null + + private fun CellSignalStrengthNr.ssRsrqValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssRsrq else null + + private fun CellSignalStrengthNr.ssSinrValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssSinr else null + + private fun CellSignalStrengthLte.rsrpValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rsrp else null + + private fun CellSignalStrengthLte.rsrqValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rsrq else null + + private fun CellSignalStrengthLte.rssnrValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rssnr else null } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 3791f08..965e564 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -16,15 +16,12 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities -import android.net.wifi.WifiInfo import android.os.Build import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith @@ -52,11 +49,11 @@ internal class StreamNetworkMonitorUtilsTest { } @Test - fun `sanitizeSsid trims markers and ignores unknown`() { - val info = mockk { every { ssid } returns "\"Stream\"" } - assertEquals("Stream", sanitizeSsid(info)) + fun `safeHasTransport returns value or null on error`() { + every { capabilities.hasTransport(1) } returns true + assertTrue(capabilities.safeHasTransport(1) == true) - every { info.ssid } returns "" - assertNull(sanitizeSsid(info)) + every { capabilities.hasTransport(2) } throws SecurityException("boom") + assertNull(capabilities.safeHasTransport(2)) } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt new file mode 100644 index 0000000..87dcdaa --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamNetworkSignalProcessingLatestApiTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + processing = StreamNetworkSignalProcessing() + } + + @AfterTest + fun tearDown() { + clearMocks(wifiManager, telephonyManager) + } + + @Test + fun `best effort prefers generic signal strength when available`() { + val capabilities = mockk(relaxed = true) + every { capabilities.signalStrength } returns 120 + + val signal = + processing.bestEffortSignal(wifiManager, telephonyManager, capabilities, emptySet()) + + assertIs(signal) + assertEquals(120, signal.value) + } + + @Test + fun `wifi signal is mapped when wifi transport present`() { + val wifiInfo = mockk(relaxed = true) + every { wifiInfo.rssi } returns -50 + every { wifiInfo.ssid } returns "\"TestWifi\"" + every { wifiInfo.bssid } returns "01:23:45:67:89:ab" + every { wifiInfo.frequency } returns 2412 + every { wifiManager.connectionInfo } returns wifiInfo + + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI), + ) + + val wifi = assertIs(signal) + assertEquals(-50, wifi.rssiDbm) + assertEquals("TestWifi", wifi.ssid) + assertEquals("01:23:45:67:89:ab", wifi.bssid) + assertNotNull(wifi.level0to4) + assertEquals(2412, wifi.frequencyMhz) + } + + @Test + fun `cellular signal prefers NR metrics and falls back to LTE`() { + val nrStrength = mockk(relaxed = true) + every { nrStrength.ssRsrp } returns -95 + every { nrStrength.ssRsrq } returns -10 + every { nrStrength.ssSinr } returns 20 + + val lteStrength = mockk(relaxed = true) + every { lteStrength.rsrp } returns -110 + every { lteStrength.rsrq } returns -12 + every { lteStrength.rssnr } returns 5 + + val strength = mockk(relaxed = true) + every { strength.level } returns 3 + every { strength.cellSignalStrengths } returns listOf(nrStrength, lteStrength) + every { telephonyManager.signalStrength } returns strength + + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.CELLULAR), + ) + + val cellular = assertIs(signal) + assertEquals("NR", cellular.rat) + assertEquals(3, cellular.level0to4) + assertEquals(-95, cellular.rsrpDbm) + assertEquals(-10, cellular.rsrqDb) + assertEquals(20, cellular.sinrDb) + } + + @Test + fun `cellular signal falls back when only LTE metrics available`() { + val lteStrength = mockk(relaxed = true) + every { lteStrength.rsrp } returns -105 + every { lteStrength.rsrq } returns -11 + every { lteStrength.rssnr } returns 6 + + val strength = mockk(relaxed = true) + every { strength.level } returns 2 + every { strength.cellSignalStrengths } returns listOf(lteStrength) + every { telephonyManager.signalStrength } returns strength + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertEquals("LTE", cellular.rat) + assertEquals(2, cellular.level0to4) + assertEquals(-105, cellular.rsrpDbm) + assertEquals(-11, cellular.rsrqDb) + assertEquals(6, cellular.sinrDb) + } + + @Test + fun `cellular signal returns generic level when no specific RAT present`() { + val strength = mockk(relaxed = true) + every { strength.level } returns 1 + every { strength.cellSignalStrengths } returns emptyList() + every { telephonyManager.signalStrength } returns strength + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertNull(cellular.rat) + assertEquals(1, cellular.level0to4) + assertEquals(null, cellular.rsrpDbm) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt new file mode 100644 index 0000000..36de51b --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamNetworkSignalProcessingLegacyApiTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + every { wifiManager.connectionInfo } returns null + processing = StreamNetworkSignalProcessing() + } + + @Test + fun `best effort signal returns null on legacy devices`() { + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI, Transport.CELLULAR), + ) + + assertNull(signal) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt index ac89e6d..ea2936f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -18,8 +18,6 @@ package io.getstream.android.core.internal.observers.network import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport @@ -27,7 +25,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -85,31 +82,6 @@ internal class StreamNetworkSignalProcessingTest { assertEquals(5200, wifiSignal.frequencyMhz) } - @Test - fun `cellularSignal returns NR details when available`() { - val strength = mockk(relaxed = true) - val nrStrength = - mockk(relaxed = true) { - every { ssRsrp } returns -95 - every { ssRsrq } returns -10 - every { ssSinr } returns 18 - } - - mockkStatic( - "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" - ) - every { telephonySignalStrength(telephonyManager) } returns strength - every { signalLevel(strength) } returns 3 - every { nrStrength(strength) } returns nrStrength - every { lteStrength(strength) } returns null - - val signal = processing.cellularSignal(telephonyManager) - - val cellular = assertIs(signal) - assertEquals("NR", cellular.rat) - assertEquals(3, cellular.level0to4) - } - @Test fun `bestEffortSignal returns null when no transports`() { val signal = From c9f8003a578d6ceb552f4ccc1fc704af26d6cff4 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 11:39:18 +0200 Subject: [PATCH 13/23] Add more tests --- ...mAndroidComponentsProviderLatestApiTest.kt | 71 +++++++++++++++++ ...mAndroidComponentsProviderLegacyApiTest.kt | 76 +++++++++++++++++++ .../StreamNetworkMonitorListenerTest.kt | 73 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt new file mode 100644 index 0000000..29dcd7e --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamAndroidComponentsProviderLatestApiTest { + + private lateinit var context: Context + private lateinit var provider: StreamAndroidComponentsProvider + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + provider = StreamAndroidComponentsProvider(context) + } + + @Test + fun `connectivity manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(ConnectivityManager::class.java) + val result = provider.connectivityManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `wifi manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(WifiManager::class.java) + val result = provider.wifiManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `telephony manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(TelephonyManager::class.java) + val result = provider.telephonyManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt new file mode 100644 index 0000000..de898e2 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamAndroidComponentsProviderLegacyApiTest { + + private lateinit var context: Context + private lateinit var provider: StreamAndroidComponentsProvider + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + provider = StreamAndroidComponentsProvider(context) + } + + @Test + fun `connectivity manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) + as ConnectivityManager + val result = provider.connectivityManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `wifi manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val result = provider.wifiManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `telephony manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.TELEPHONY_SERVICE) + as TelephonyManager + val result = provider.telephonyManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt new file mode 100644 index 0000000..f98bb68 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.assertContentEquals +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +internal class StreamNetworkMonitorListenerTest { + + @Test + fun `default listener implementations no-op`() = runTest { + val listener = object : StreamNetworkMonitorListener {} + + listener.onNetworkConnected(null) + listener.onNetworkLost() + listener.onNetworkPropertiesChanged( + StreamNetworkInfo.Snapshot(transports = emptySet()), + ) + } + + @Test + fun `overrides receive the expected payloads`() = runTest { + val snapshots = mutableListOf() + var lostFlag: Boolean? = null + + val listener = + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) { + snapshots += snapshot + } + + override suspend fun onNetworkLost(permanent: Boolean) { + lostFlag = permanent + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + snapshots += snapshot + } + } + + val connected = StreamNetworkInfo.Snapshot(transports = emptySet()) + val updated = connected.copy(priority = StreamNetworkInfo.PriorityHint.LATENCY) + + listener.onNetworkConnected(connected) + listener.onNetworkPropertiesChanged(updated) + listener.onNetworkLost(permanent = true) + + assertContentEquals(listOf(connected, updated), snapshots) + assertTrue(lostFlag == true) + } +} From d1188f63ddf2027c9b9c7113e71eb4df260675d4 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 11:40:03 +0200 Subject: [PATCH 14/23] Spotless --- .../StreamAndroidComponentsProviderLatestApiTest.kt | 2 +- .../StreamAndroidComponentsProviderLegacyApiTest.kt | 2 +- .../network/StreamNetworkMonitorListenerTest.kt | 9 +++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt index 29dcd7e..1ec22a2 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt @@ -2,7 +2,7 @@ * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. * * Licensed under the Stream License; - * You may not use this file except in compliance with the License. + * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://github.com/GetStream/stream-core-android/blob/main/LICENSE diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt index de898e2..45f6c3e 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt @@ -2,7 +2,7 @@ * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. * * Licensed under the Stream License; - * You may not use this file except in compliance with the License. + * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://github.com/GetStream/stream-core-android/blob/main/LICENSE diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt index f98bb68..59fa6e5 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt @@ -17,12 +17,11 @@ package io.getstream.android.core.api.observers.network import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.assertContentEquals import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import kotlin.test.assertContentEquals -import kotlin.time.ExperimentalTime @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) internal class StreamNetworkMonitorListenerTest { @@ -33,9 +32,7 @@ internal class StreamNetworkMonitorListenerTest { listener.onNetworkConnected(null) listener.onNetworkLost() - listener.onNetworkPropertiesChanged( - StreamNetworkInfo.Snapshot(transports = emptySet()), - ) + listener.onNetworkPropertiesChanged(StreamNetworkInfo.Snapshot(transports = emptySet())) } @Test From b91fcdc2d669eb5ff3560e15a79546b11e6c7c3a Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 13:40:11 +0200 Subject: [PATCH 15/23] Fix algebra --- .../io/getstream/android/core/api/utils/Algebra.kt | 13 +++++++++++-- .../android/core/api/utils/AlgebraTest.kt | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt index 033bd19..6a5e614 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -47,5 +47,14 @@ public operator fun Throwable.plus(other: Throwable): StreamAggregateException { * [StreamAggregateException] if either [Result] is a failure. */ @StreamInternalApi -public operator fun Result.times(other: Result): Result> = - this.flatMap { first -> other.map { second -> first to second } } +public operator fun Result.times(other: Result): Result> { + when { + this.isFailure && other.isFailure -> { + return Result.failure(this.exceptionOrNull()!! + other.exceptionOrNull()!!) + } + + this.isFailure -> return Result.failure(this.exceptionOrNull()!!) + other.isFailure -> return Result.failure(other.exceptionOrNull()!!) + } + return Result.success(this.getOrThrow() to other.getOrThrow()) +} \ No newline at end of file diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt index 3ecbef3..cf8fe8f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -103,4 +103,18 @@ class AlgebraTest { assertTrue(combined.isFailure) assertSame(failure, combined.exceptionOrNull()) } + + @Test + fun `times propagates both results when both fail`() { + val failure = IllegalArgumentException("broken") + val failure2 = IllegalArgumentException("broken2") + val left = Result.failure(failure) + val right = Result.failure(failure2) + + val combined = left * right + + assertTrue(combined.isFailure) + val exception = combined.exceptionOrNull() as StreamAggregateException + assertEquals(listOf(failure, failure2), exception.causes) + } } From 77f24ef427bc73ea2021e8c7d097e6a3e658df32 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 13:43:55 +0200 Subject: [PATCH 16/23] Update wrapper is now more generic --- .../android/core/internal/client/StreamClientImpl.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 000b3ca..a3e2d2b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -89,7 +89,7 @@ internal class StreamClientImpl( object : StreamClientListener { override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } - mutableConnectionState.update { state } + mutableConnectionState.update(state) subscriptionManager.forEach { it.onState(state) } } @@ -135,19 +135,19 @@ internal class StreamClientImpl( logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update { snapshot } + internalNetworkInfo.update(snapshot) } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - internalNetworkInfo.update { null } + internalNetworkInfo.update(null) } override suspend fun onNetworkPropertiesChanged( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update { snapshot } + internalNetworkInfo.update(snapshot) } }, StreamSubscriptionManager.Options( @@ -189,7 +189,7 @@ internal class StreamClientImpl( singleFlight.clear(true) } - private fun MutableStateFlow.update(state: StreamConnectionState) { + private fun MutableStateFlow.update(state: T) { this.update { state } } } From 693328ecfd9af9b407d5a26b239b04236533c891 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:00:30 +0200 Subject: [PATCH 17/23] Spotless --- .../main/java/io/getstream/android/core/api/utils/Algebra.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt index 6a5e614..4fb8c10 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -57,4 +57,4 @@ public operator fun Result.times(other: Result): Result> other.isFailure -> return Result.failure(other.exceptionOrNull()!!) } return Result.success(this.getOrThrow() to other.getOrThrow()) -} \ No newline at end of file +} From e0e234c708cfc0281cf1e2f6bc0a1af1e168956c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:32:01 +0200 Subject: [PATCH 18/23] Change state update mechanism --- .../android/core/api/StreamClient.kt | 5 +- .../connection/network/StreamNetworkState.kt | 63 +++++++++++++++++++ .../socket/listeners/StreamClientListener.kt | 9 +++ .../core/internal/client/StreamClientImpl.kt | 31 ++++++--- .../StreamListenersDefaultImplsTest.kt | 6 ++ 5 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index 8ec0940..14ee9fd 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -25,7 +25,7 @@ import io.getstream.android.core.api.model.config.StreamHttpConfig import io.getstream.android.core.api.model.config.StreamSocketConfig import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId @@ -117,7 +117,7 @@ public interface StreamClient { * - Hot & conflated: new collectors receive the latest value immediately. * - `null` if no network is available. */ - @StreamInternalApi public val networkInfo: StateFlow + @StreamInternalApi public val networkState: StateFlow /** * Establishes a connection for the current user. @@ -291,6 +291,7 @@ public fun StreamClient( serialQueue = serialQueue, connectionIdHolder = connectionIdHolder, logger = clientLogger, + mutableNetworkState = MutableStateFlow(StreamNetworkState.Unknown), mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle), subscriptionManager = clientSubscriptionManager, networkMonitor = networkMonitor, diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt new file mode 100644 index 0000000..545728a --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt @@ -0,0 +1,63 @@ +package io.getstream.android.core.api.model.connection.network + +import io.getstream.android.core.annotations.StreamInternalApi + +@StreamInternalApi +public sealed class StreamNetworkState { + + /** + * Signals that the platform reported a permanent loss of network connectivity. + * + * This state mirrors the `ConnectivityManager.NetworkCallback.onUnavailable` callback, which + * indicates no viable network path exists. Applications should back off from network work and + * surface an offline UI until a different state is received. + * + * ### Example + * ```kotlin + * when (state) { + * StreamNetworkState.Unavailable -> showOfflineBanner("No connection available") + * else -> hideOfflineBanner() + * } + * ``` + */ + public data object Unavailable : StreamNetworkState() + + /** + * Represents the initial, indeterminate state before any network callbacks have fired. + * + * Use this as a cue to defer UI decisions until more definitive information arrives. The state + * will transition to one of the other variants once the monitor observes connectivity events. + */ + public data object Unknown : StreamNetworkState() + + /** + * Indicates that a network was previously tracked but has been lost. + * + * This corresponds to `ConnectivityManager.NetworkCallback.onLost`. Stream monitors emit this + * when the active network disconnects but the system may still attempt reconnection, so you can + * show transient offline messaging or pause network-heavy tasks. + */ + public data object Disconnected : StreamNetworkState() + + /** + * A network path is currently active and considered connected. + * + * This state maps to `ConnectivityManager.NetworkCallback.onAvailable` and carries the most + * recent [StreamNetworkInfo.Snapshot], allowing callers to inspect transports, metering, or + * other network characteristics before resuming work. + * + * ### Example + * ```kotlin + * when (state) { + * is StreamNetworkState.Available -> + * logger.i { "Connected via ${state.snapshot?.transports}" } + * StreamNetworkState.Disconnected -> logger.w { "Network dropped" } + * StreamNetworkState.Unavailable -> logger.e { "No connection" } + * StreamNetworkState.Unknown -> logger.d { "Awaiting first update" } + * } + * ``` + * + * @property snapshot Latest network snapshot, or `null` if collection failed. + */ + public data class Available(val snapshot: StreamNetworkInfo.Snapshot?) : StreamNetworkState() +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt index d3f64fd..ab92108 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt @@ -17,6 +17,8 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState /** * Listener interface for Feeds socket events. @@ -46,4 +48,11 @@ public interface StreamClientListener { * @param err The error that occurred. */ public fun onError(err: Throwable) {} + + /** + * Called when the network connection changes. + * + * @param state The new network state. + */ + public fun onNetworkState(state: StreamNetworkState) {} } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index a3e2d2b..06130c9 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -15,7 +15,6 @@ */ package io.getstream.android.core.internal.client -import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger @@ -23,6 +22,7 @@ import io.getstream.android.core.api.model.StreamTypedKey.Companion.randomExecut import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener @@ -48,6 +48,7 @@ internal class StreamClientImpl( private val serialQueue: StreamSerialProcessingQueue, private val connectionIdHolder: StreamConnectionIdHolder, private val socketSession: StreamSocketSession, + private var mutableNetworkState: MutableStateFlow, private val mutableConnectionState: MutableStateFlow, private val logger: StreamLogger, private val subscriptionManager: StreamSubscriptionManager, @@ -64,12 +65,9 @@ internal class StreamClientImpl( override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() - private var internalNetworkInfo: MutableStateFlow = - MutableStateFlow(null) + override val networkState: StateFlow + get() = mutableNetworkState.asStateFlow() - @StreamInternalApi - override val networkInfo: StateFlow - get() = internalNetworkInfo.asStateFlow() override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) @@ -135,19 +133,34 @@ internal class StreamClientImpl( logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update(snapshot) + val state = StreamNetworkState.Available(snapshot) + mutableNetworkState.update(state) + subscriptionManager.forEach { + it.onNetworkState(state) + } } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - internalNetworkInfo.update(null) + val state = if (permanent) { + StreamNetworkState.Unavailable + } else { + StreamNetworkState.Disconnected + } + mutableNetworkState.update(state) + subscriptionManager.forEach { + it.onNetworkState(state) + } } override suspend fun onNetworkPropertiesChanged( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update(snapshot) + mutableNetworkState.update(StreamNetworkState.Available(snapshot)) + subscriptionManager.forEach { + it.onNetworkState(StreamNetworkState.Available(snapshot)) + } } }, StreamSubscriptionManager.Options( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt index 5b96b56..aacc977 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt @@ -19,6 +19,7 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import kotlin.test.assertEquals import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch @@ -50,6 +51,7 @@ internal class StreamListenersDefaultImplsTest { val stateChannel = Channel(capacity = 1) val eventChannel = Channel(capacity = 1) val errorChannel = Channel(capacity = 1) + val networkChannel = Channel(capacity = 1) val listener = object : StreamClientListener { @@ -64,6 +66,10 @@ internal class StreamListenersDefaultImplsTest { override fun onError(err: Throwable) { errorChannel.trySend(err) } + + override fun onNetworkState(state: StreamNetworkState) { + networkChannel.trySend(state) + } } val state = StreamConnectionState.Connecting.Opening("user") From c2c3cef5e72e3b5e54ecf2374818b7c6c06da82d Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:32:35 +0200 Subject: [PATCH 19/23] Spotless --- .../connection/network/StreamNetworkState.kt | 17 ++++++++++++++++ .../socket/listeners/StreamClientListener.kt | 1 - .../core/internal/client/StreamClientImpl.kt | 20 +++++++++++-------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt index 545728a..3c6b1ce 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.getstream.android.core.api.model.connection.network import io.getstream.android.core.annotations.StreamInternalApi @@ -13,6 +28,7 @@ public sealed class StreamNetworkState { * surface an offline UI until a different state is received. * * ### Example + * * ```kotlin * when (state) { * StreamNetworkState.Unavailable -> showOfflineBanner("No connection available") @@ -47,6 +63,7 @@ public sealed class StreamNetworkState { * other network characteristics before resuming work. * * ### Example + * * ```kotlin * when (state) { * is StreamNetworkState.Available -> diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt index ab92108..1693c01 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt @@ -17,7 +17,6 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.connection.network.StreamNetworkState /** diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 06130c9..f1dd2c5 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -68,7 +68,6 @@ internal class StreamClientImpl( override val networkState: StateFlow get() = mutableNetworkState.asStateFlow() - override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) @@ -142,11 +141,12 @@ internal class StreamClientImpl( override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - val state = if (permanent) { - StreamNetworkState.Unavailable - } else { - StreamNetworkState.Disconnected - } + val state = + if (permanent) { + StreamNetworkState.Unavailable + } else { + StreamNetworkState.Disconnected + } mutableNetworkState.update(state) subscriptionManager.forEach { it.onNetworkState(state) @@ -157,9 +157,13 @@ internal class StreamClientImpl( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - mutableNetworkState.update(StreamNetworkState.Available(snapshot)) + mutableNetworkState.update( + StreamNetworkState.Available(snapshot) + ) subscriptionManager.forEach { - it.onNetworkState(StreamNetworkState.Available(snapshot)) + it.onNetworkState( + StreamNetworkState.Available(snapshot) + ) } } }, From 3b30b033b73f073f0afbd4527d9a6a645871d231 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:42:30 +0200 Subject: [PATCH 20/23] Update network info UI in sample --- .../getstream/android/core/sample/SampleActivity.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index ec66e23..b88a4f3 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamToken @@ -132,10 +133,18 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() - val networkSnapshot = streamClient.networkInfo.collectAsStateWithLifecycle() + val networkSnapshot = streamClient.networkState.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") + val networkState = networkSnapshot.value Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ConnectionStateCard(state = state.value) - NetworkInfoCard(snapshot = networkSnapshot.value) + when (networkState) { + is StreamNetworkState.Available -> { + NetworkInfoCard(snapshot = networkState.snapshot) + } + else -> { + NetworkInfoCard(snapshot = null) + } + } } } From bace61440ec8c708cb22aed351abc2d29d0c1815 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:44:03 +0200 Subject: [PATCH 21/23] Update client test --- .../internal/client/StreamClientIImplTest.kt | 115 ++++++++++++++++-- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 5bb56ed..3320a4f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -21,6 +21,8 @@ import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.event.StreamClientWsEvent import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId @@ -30,6 +32,8 @@ import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* import kotlinx.coroutines.CoroutineScope @@ -37,10 +41,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.bouncycastle.util.test.SimpleTest.runTest import org.junit.Assert.* import org.junit.Before import org.junit.Test +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class StreamClientIImplTest { private var userId: StreamUserId = StreamUserId.fromString("u1") @@ -53,10 +60,9 @@ class StreamClientIImplTest { private lateinit var subscriptionManager: StreamSubscriptionManager - // private lateinit var client: StreamClient - // Backing state flow for MutableStreamClientState.connectionState private lateinit var connFlow: MutableStateFlow + private lateinit var networkFlow: MutableStateFlow @Before fun setUp() { @@ -78,13 +84,17 @@ class StreamClientIImplTest { Result.success(block()) } - // Mutable client state: expose a real StateFlow that update() mutates + // Mutable client state: expose real StateFlows that update() mutates connFlow = MutableStateFlow(StreamConnectionState.Disconnected()) + networkFlow = MutableStateFlow(StreamNetworkState.Unknown) every { connectionIdHolder.clear() } returns Result.success(Unit) } - private fun createClient(scope: CoroutineScope) = + private fun createClient( + scope: CoroutineScope, + networkMonitor: StreamNetworkMonitor = mockNetworkMonitor(), + ) = StreamClientImpl( userId = userId, tokenManager = tokenManager, @@ -93,17 +103,20 @@ class StreamClientIImplTest { connectionIdHolder = connectionIdHolder, socketSession = socketSession, logger = logger, + mutableNetworkState = networkFlow, mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, - networkMonitor = - mockk(relaxed = true) { - every { start() } returns Result.success(Unit) - every { stop() } returns Result.success(Unit) - every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - }, + networkMonitor = networkMonitor, ) + private fun mockNetworkMonitor(): StreamNetworkMonitor = + mockk(relaxed = true) { + every { start() } returns Result.success(Unit) + every { stop() } returns Result.success(Unit) + every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) + } + @Test fun `connect short-circuits when already connected`() = runTest { backgroundScope @@ -125,7 +138,8 @@ class StreamClientIImplTest { @Test fun `disconnect performs cleanup - updates state, clears ids, cancels handle, stops processors`() = runTest { - val client = createClient(backgroundScope) + val networkMonitor = mockNetworkMonitor() + val client = createClient(backgroundScope, networkMonitor) // Make singleFlight actually run the provided block and return success coEvery { singleFlight.run(any(), any Any>()) } coAnswers { @@ -141,6 +155,13 @@ class StreamClientIImplTest { client.javaClass.getDeclaredField("handle").apply { isAccessible = true } handleField.set(client, fakeHandle) + val networkHandle = mockk(relaxed = true) + val networkHandleField = + client.javaClass + .getDeclaredField("networkMonitorHandle") + .apply { isAccessible = true } + networkHandleField.set(client, networkHandle) + every { connectionIdHolder.clear() } returns Result.success(Unit) every { socketSession.disconnect() } returns Result.success(Unit) coEvery { serialQueue.stop(any()) } returns Result.success(Unit) // default-arg path @@ -159,11 +180,83 @@ class StreamClientIImplTest { verify { tokenManager.invalidate() } coVerify { serialQueue.stop(any()) } coVerify { singleFlight.clear(true) } + verify { networkMonitor.stop() } + verify { networkHandle.cancel() } // Handle is nulled assertNull(handleField.get(client)) + assertNull(networkHandleField.get(client)) } + @Test + fun `network monitor updates state and notifies subscribers`() = runTest { + val forwardedStates = mutableListOf() + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + every { external.onNetworkState(any()) } answers + { + forwardedStates += firstArg() + } + block(external) + Result.success(Unit) + } + + val networkHandle = mockk(relaxed = true) + var capturedListener: StreamNetworkMonitorListener? = null + val networkMonitor = mockk() + every { networkMonitor.start() } returns Result.success(Unit) + every { networkMonitor.stop() } returns Result.success(Unit) + every { networkMonitor.subscribe(any(), any()) } answers + { + capturedListener = firstArg() + Result.success(networkHandle) + } + + val client = createClient(backgroundScope, networkMonitor) + + val socketHandle = mockk(relaxed = true) + every { socketSession.subscribe(any(), any()) } returns + Result.success(socketHandle) + val token = StreamToken.fromString("tok") + coEvery { tokenManager.loadIfAbsent() } returns Result.success(token) + val connectedUser = mockk(relaxed = true) + val connectedState = StreamConnectionState.Connected(connectedUser, "conn-1") + coEvery { socketSession.connect(any()) } returns Result.success(connectedState) + every { connectionIdHolder.setConnectionId("conn-1") } returns Result.success("conn-1") + + val result = client.connect() + + assertTrue(result.isSuccess) + verify(exactly = 1) { networkMonitor.subscribe(any(), any()) } + verify(exactly = 1) { networkMonitor.start() } + val listener = capturedListener ?: error("Expected network monitor listener") + + val connectedSnapshot = StreamNetworkInfo.Snapshot(transports = emptySet()) + listener.onNetworkConnected(connectedSnapshot) + assertEquals(StreamNetworkState.Available(connectedSnapshot), networkFlow.value) + + listener.onNetworkLost(permanent = false) + assertEquals(StreamNetworkState.Disconnected, networkFlow.value) + + listener.onNetworkLost(permanent = true) + assertEquals(StreamNetworkState.Unavailable, networkFlow.value) + + val updatedSnapshot = + connectedSnapshot.copy(priority = StreamNetworkInfo.PriorityHint.LATENCY) + listener.onNetworkPropertiesChanged(updatedSnapshot) + assertEquals(StreamNetworkState.Available(updatedSnapshot), networkFlow.value) + + val expectedStates = listOf( + StreamNetworkState.Available(connectedSnapshot), + StreamNetworkState.Disconnected, + StreamNetworkState.Unavailable, + StreamNetworkState.Available(updatedSnapshot), + ) + assertTrue(forwardedStates.containsAll(expectedStates)) + } + @Test fun `subscribe delegates to subscriptionManager`() = runTest { val listener = mockk(relaxed = true) From f484eb4a838d3e9e54af34f19d5ba6944199d45c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:47:04 +0200 Subject: [PATCH 22/23] Spotless --- .../internal/client/StreamClientIImplTest.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 3320a4f..5a4466c 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -26,16 +26,17 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.event.StreamClientWsEvent import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager -import io.getstream.android.core.api.observers.network.StreamNetworkMonitor -import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* +import kotlin.time.ExperimentalTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -45,7 +46,6 @@ import org.bouncycastle.util.test.SimpleTest.runTest import org.junit.Assert.* import org.junit.Before import org.junit.Test -import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) class StreamClientIImplTest { @@ -157,9 +157,9 @@ class StreamClientIImplTest { val networkHandle = mockk(relaxed = true) val networkHandleField = - client.javaClass - .getDeclaredField("networkMonitorHandle") - .apply { isAccessible = true } + client.javaClass.getDeclaredField("networkMonitorHandle").apply { + isAccessible = true + } networkHandleField.set(client, networkHandle) every { connectionIdHolder.clear() } returns Result.success(Unit) @@ -248,12 +248,13 @@ class StreamClientIImplTest { listener.onNetworkPropertiesChanged(updatedSnapshot) assertEquals(StreamNetworkState.Available(updatedSnapshot), networkFlow.value) - val expectedStates = listOf( - StreamNetworkState.Available(connectedSnapshot), - StreamNetworkState.Disconnected, - StreamNetworkState.Unavailable, - StreamNetworkState.Available(updatedSnapshot), - ) + val expectedStates = + listOf( + StreamNetworkState.Available(connectedSnapshot), + StreamNetworkState.Disconnected, + StreamNetworkState.Unavailable, + StreamNetworkState.Available(updatedSnapshot), + ) assertTrue(forwardedStates.containsAll(expectedStates)) } From 76f90b807ca5d0afcfb728123c3ed80daf9aa448 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 23 Oct 2025 13:09:03 +0200 Subject: [PATCH 23/23] Update stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com> --- .../internal/observers/network/StreamNetworkMonitorCallback.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt index 97b44b5..e3f6c18 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt @@ -126,7 +126,7 @@ internal class StreamNetworkMonitorCallback( val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) val previousState = activeState.getAndSet(newState) - val networkChanged = previousState?.network != network || previousState == null + val networkChanged = previousState?.network != network val snapshotChanged = previousState?.snapshot != snapshot when {