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..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 @@ -21,26 +21,33 @@ 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 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 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 +55,67 @@ 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,18 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() + val networkSnapshot = streamClient.networkState.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") - Text(text = "Client state: ${state.value}") + val networkState = networkSnapshot.value + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ConnectionStateCard(state = state.value) + when (networkState) { + is StreamNetworkState.Available -> { + NetworkInfoCard(snapshot = networkState.snapshot) + } + else -> { + NetworkInfoCard(snapshot = null) + } + } + } } 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..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 @@ -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 { 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..52b28b9 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt @@ -0,0 +1,134 @@ +/* + * 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..ed3b383 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt @@ -0,0 +1,231 @@ +/* + * 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..8e7d7f1 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt @@ -0,0 +1,149 @@ +/* + * 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.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..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,10 +25,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.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 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 +109,16 @@ 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 networkState: StateFlow + /** * Establishes a connection for the current user. * @@ -219,8 +231,10 @@ public fun StreamClient( // Socket connectionIdHolder: StreamConnectionIdHolder, socketFactory: StreamWebSocketFactory, - healthMonitor: StreamHealthMonitor, batcher: StreamBatcher, + // Monitoring + healthMonitor: StreamHealthMonitor, + networkMonitor: StreamNetworkMonitor, // Http httpConfig: StreamHttpConfig? = null, // Serialization @@ -277,8 +291,10 @@ public fun StreamClient( serialQueue = serialQueue, connectionIdHolder = connectionIdHolder, logger = clientLogger, + mutableNetworkState = MutableStateFlow(StreamNetworkState.Unknown), 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/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..3c6b1ce --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt @@ -0,0 +1,80 @@ +/* + * 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 + +@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/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..040674b --- /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/listeners/StreamClientListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt index d3f64fd..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,6 +17,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 /** * Listener interface for Feeds socket events. @@ -46,4 +47,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/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..4fb8c10 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.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.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> { + 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()) +} 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..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 @@ -21,7 +21,11 @@ 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.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 import io.getstream.android.core.api.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.api.socket.StreamConnectionIdHolder @@ -44,9 +48,11 @@ 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, + private val networkMonitor: StreamNetworkMonitor, private val scope: CoroutineScope, ) : StreamClient { companion object { @@ -55,9 +61,13 @@ internal class StreamClientImpl( } private var handle: StreamSubscription? = null + private var networkMonitorHandle: StreamSubscription? = null override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() + override val networkState: StateFlow + get() = mutableNetworkState.asStateFlow() + override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) @@ -74,7 +84,6 @@ internal class StreamClientImpl( socketSession .subscribe( object : StreamClientListener { - override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } mutableConnectionState.update(state) @@ -111,6 +120,62 @@ 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" + } + val state = StreamNetworkState.Available(snapshot) + mutableNetworkState.update(state) + subscriptionManager.forEach { + it.onNetworkState(state) + } + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "[connect] Network lost" } + 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" } + mutableNetworkState.update( + StreamNetworkState.Available(snapshot) + ) + subscriptionManager.forEach { + it.onNetworkState( + StreamNetworkState.Available(snapshot) + ) + } + } + }, + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention + .KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() + } + networkMonitor.start() mutableConnectionState.update(connected) connectionIdHolder.setConnectionId(connected.connectionId).map { connected.connectedUser @@ -132,13 +197,16 @@ internal class StreamClientImpl( connectionIdHolder.clear() socketSession.disconnect() handle?.cancel() + networkMonitor.stop() + networkMonitorHandle?.cancel() + networkMonitorHandle = null handle = null tokenManager.invalidate() serialQueue.stop() singleFlight.clear(true) } - private fun MutableStateFlow.update(state: StreamConnectionState) { + private fun MutableStateFlow.update(state: T) { this.update { state } } } 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/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt new file mode 100644 index 0000000..e3f6c18 --- /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 + 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 new file mode 100644 index 0000000..d76e787 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.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.internal.observers.network + +import android.annotation.SuppressLint +import android.net.ConnectivityManager +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.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 + +@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 callbackRef = 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 = + StreamNetworkMonitorCallback( + logger = logger, + scope = scope, + subscriptionManager = streamSubscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + callbackRef.set(callback) + + try { + registerCallback(callback) + 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 = callbackRef.getAndSet(null) ?: return@runCatching + safeUnregister(callback) + callback.onCleared() + 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 cleanup() { + started.set(false) + } + + private fun safeUnregister(callback: ConnectivityManager.NetworkCallback) { + runCatching { connectivityManager.unregisterNetworkCallback(callback) } + .onFailure { logger.w { "Failed to unregister network callback: ${it.message}" } } + } +} 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..e7e807c --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -0,0 +1,24 @@ +/* + * 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 + +internal fun NetworkCapabilities.safeHasCapability(capability: Int): Boolean? = + runCatching { hasCapability(capability) }.getOrNull() + +internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = + runCatching { hasTransport(transport) }.getOrNull() 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..bb40a59 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt @@ -0,0 +1,153 @@ +/* + * 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 + +internal class StreamNetworkSignalProcessing { + + fun bestEffortSignal( + wifiManager: WifiManager, + telephonyManager: TelephonyManager, + capabilities: NetworkCapabilities?, + transports: Set, + ): Signal? { + genericSignal(capabilities)?.let { + return it + } + + return when { + Transport.WIFI in transports -> wifiSignal(wifiManager) + Transport.CELLULAR in transports -> cellularSignal(telephonyManager) + else -> null + } + } + + 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 }, + ) + } + + fun cellularSignal(telephonyManager: TelephonyManager): Signal.Cellular? { + val strength = telephonySignalStrength(telephonyManager) ?: return null + val level = signalLevel(strength) + + nrReport(strength)?.let { nr -> + return Signal.Cellular( + rat = "NR", + level0to4 = level, + rsrpDbm = nr.ssRsrpValue(), + rsrqDb = nr.ssRsrqValue(), + sinrDb = nr.ssSinrValue(), + ) + } + + lteReport(strength)?.let { lte -> + return Signal.Cellular( + rat = "LTE", + level0to4 = level, + rsrpDbm = lte.rsrpValue(), + rsrqDb = lte.rsrqValue(), + sinrDb = lte.rssnrValue(), + ) + } + + return Signal.Cellular( + rat = null, + level0to4 = level, + rsrpDbm = null, + rsrqDb = null, + 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/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..b9fe8bf --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -0,0 +1,225 @@ +/* + * 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.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 +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 + +@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( + network: Network, + networkCapabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): Result = runCatching { + if (!supportsSnapshots()) { + return@runCatching null + } + + val transports = transportsFor(networkCapabilities) + val metered = networkCapabilities.resolveMetered() + + StreamNetworkInfo.Snapshot( + transports = transports, + 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 = + 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 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? { + 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 + + 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/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..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 @@ -150,6 +150,7 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, + networkMonitor = mockk(relaxed = true), ) return client to deps 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..1ec22a2 --- /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..45f6c3e --- /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/StreamNetworkMonitorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt new file mode 100644 index 0000000..e9056f1 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt @@ -0,0 +1,52 @@ +/* + * 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/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..59fa6e5 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt @@ -0,0 +1,70 @@ +/* + * 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.assertContentEquals +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest + +@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) + } +} 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") 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..cf8fe8f --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -0,0 +1,120 @@ +/* + * 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()) + } + + @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) + } +} 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..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 @@ -21,9 +21,13 @@ 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 +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 @@ -32,15 +36,18 @@ import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager 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 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 +@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,11 +103,20 @@ class StreamClientIImplTest { connectionIdHolder = connectionIdHolder, socketSession = socketSession, logger = logger, + mutableNetworkState = networkFlow, mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, + 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 @@ -119,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 { @@ -135,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 @@ -153,11 +180,84 @@ 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) 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..282418a --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt @@ -0,0 +1,444 @@ +/* + * 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.clearMocks +import io.mockk.coEvery +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.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..8f909a6 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt @@ -0,0 +1,215 @@ +/* + * 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.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@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 new file mode 100644 index 0000000..965e564 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -0,0 +1,59 @@ +/* + * 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.os.Build +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 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]) +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 `safeHasTransport returns value or null on error`() { + every { capabilities.hasTransport(1) } returns true + assertTrue(capabilities.safeHasTransport(1) == true) + + 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 new file mode 100644 index 0000000..ea2936f --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -0,0 +1,97 @@ +/* + * 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.WifiInfo +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 +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +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 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 `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/StreamNetworkSnapshotBuilderLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt new file mode 100644 index 0000000..d8431e5 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.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.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 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 +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.VANILLA_ICE_CREAM]) +internal class StreamNetworkSnapshotBuilderLatestApiTest { + + @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) { 20 } + } + + @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_NOT_METERED) } 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) + } +} 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) + } +} 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(