From a8c599ed4c8d0aa8d6e426a3eb2f29ec9313562b Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:15:38 +0200 Subject: [PATCH 01/42] Add new network monitor and network sample UI in core --- .../android/core/sample/SampleActivity.kt | 94 +++--- .../core/sample/client/StreamClient.kt | 24 +- .../core/sample/ui/ConnectionStateCard.kt | 156 ++++++++++ .../android/core/sample/ui/NetworkInfoCard.kt | 245 +++++++++++++++ .../core/sample/ui/NetworkInfoComponents.kt | 165 +++++++++++ gradle/libs.versions.toml | 2 + stream-android-core/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 3 + .../android/core/api/StreamClient.kt | 19 +- .../StreamAndroidComponentsProvider.kt | 67 +++++ .../connection/network/StreamNetworkInfo.kt | 206 +++++++++++++ .../observers/network/StreamNetworkMonitor.kt | 79 +++++ .../network/StreamNetworkMonitorListener.kt | 49 +++ .../api/socket/monitor/StreamHealthMonitor.kt | 13 +- .../android/core/api/utils/Algebra.kt | 51 ++++ .../core/internal/client/StreamClientImpl.kt | 52 +++- .../StreamAndroidComponentsProviderImpl.kt | 55 ++++ .../network/StreamNetworkMonitorImpl.kt | 279 ++++++++++++++++++ .../network/StreamNetworkMonitorUtils.kt | 62 ++++ .../network/StreamNetworkSignalProcessing.kt | 104 +++++++ .../network/StreamNetworkSnapshotBuilder.kt | 202 +++++++++++++ .../socket/monitor/StreamHealthMonitorImpl.kt | 7 +- .../core/api/StreamClientFactoryTest.kt | 9 + .../android/core/api/utils/AlgebraTest.kt | 106 +++++++ .../internal/client/StreamClientIImplTest.kt | 1 + .../socket/StreamSocketSessionTest.kt | 2 +- 26 files changed, 2010 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt create mode 100644 app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index 81a2a45..f5bc4b2 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -21,14 +21,18 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -41,6 +45,8 @@ import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl import io.getstream.android.core.sample.client.createStreamClient +import io.getstream.android.core.sample.ui.ConnectionStateCard +import io.getstream.android.core.sample.ui.NetworkInfoCard import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -48,56 +54,68 @@ import kotlinx.coroutines.runBlocking class SampleActivity : ComponentActivity() { val userId = StreamUserId.fromString("petar") - val streamClient = - createStreamClient( - scope = lifecycleScope, - apiKey = StreamApiKey.fromString("pd67s34fzpgw"), - userId = userId, - wsUrl = - StreamWsUrl.fromString( - "wss://chat-edge-frankfurt-ce1.stream-io-api.com/api/v2/connect" - ), - clientInfoHeader = - StreamHttpClientInfoHeader.create( - product = "android-core", - productVersion = "1.0.0", - os = "Android", - apiLevel = Build.VERSION.SDK_INT, - deviceModel = "Pixel 7 Pro", - app = "Stream Android Core Sample", - appVersion = "1.0.0", - ), - tokenProvider = - object : StreamTokenProvider { - override suspend fun loadToken(userId: StreamUserId): StreamToken { - return StreamToken.fromString( - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicGV0YXIifQ.mZFi4iSblaIoyo9JDdcxIkGkwI-tuApeSBawxpz42rs" - ) - } - }, - ) + var streamClient: StreamClient? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val streamClient2 = + createStreamClient( + context = this.applicationContext, + scope = lifecycleScope, + apiKey = StreamApiKey.fromString("pd67s34fzpgw"), + userId = userId, + wsUrl = + StreamWsUrl.fromString( + "wss://chat-edge-frankfurt-ce1.stream-io-api.com/api/v2/connect" + ), + clientInfoHeader = + StreamHttpClientInfoHeader.create( + product = "android-core", + productVersion = "1.0.0", + os = "Android", + apiLevel = Build.VERSION.SDK_INT, + deviceModel = "Pixel 7 Pro", + app = "Stream Android Core Sample", + appVersion = "1.0.0", + ), + tokenProvider = + object : StreamTokenProvider { + override suspend fun loadToken(userId: StreamUserId): StreamToken { + return StreamToken.fromString( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicGV0YXIifQ.mZFi4iSblaIoyo9JDdcxIkGkwI-tuApeSBawxpz42rs" + ) + } + }, + ) + streamClient = streamClient2 lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { streamClient.connect() } + repeatOnLifecycle(Lifecycle.State.RESUMED) { streamClient?.connect() } } enableEdgeToEdge() setContent { StreamandroidcoreTheme { + val scrollState = rememberScrollState() Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Column { - Greeting(name = "Android", modifier = Modifier.padding(innerPadding)) - ClientInfo(streamClient = streamClient) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Greeting(name = "Android") + ClientInfo(streamClient = streamClient2) } } } } } - override fun onPause() { - runBlocking { streamClient.disconnect() } - super.onPause() + override fun onStop() { + runBlocking { streamClient?.disconnect() } + super.onStop() } } @@ -115,6 +133,10 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() + val networkSnapshot = streamClient.networkInfo.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") - Text(text = "Client state: ${state.value}") + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ConnectionStateCard(state = state.value) + NetworkInfoCard(snapshot = networkSnapshot.value) + } } diff --git a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt index c13e859..8b5dcc3 100644 --- a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt +++ b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt @@ -15,9 +15,11 @@ */ package io.getstream.android.core.sample.client +import android.content.Context import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -25,6 +27,7 @@ import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.processing.StreamBatcher import io.getstream.android.core.api.processing.StreamRetryProcessor import io.getstream.android.core.api.processing.StreamSerialProcessingQueue @@ -49,6 +52,7 @@ import kotlinx.coroutines.CoroutineScope * @return A new [createStreamClient] instance. */ fun createStreamClient( + context: Context, scope: CoroutineScope, apiKey: StreamApiKey, userId: StreamUserId, @@ -88,6 +92,23 @@ fun createStreamClient( maxDelayMs = 1_000L, ) + val androidComponentsProvider = StreamAndroidComponentsProvider(context) + val connectivityManager = androidComponentsProvider.connectivityManager().getOrThrow() + val wifiManager = androidComponentsProvider.wifiManager().getOrThrow() + val telephonyManager = androidComponentsProvider.telephonyManager().getOrThrow() + val networkMonitor = + StreamNetworkMonitor( + logger = logProvider.taggedLogger("SCNetworkMonitor"), + scope = scope, + connectivityManager = connectivityManager, + wifiManager = wifiManager, + telephonyManager = telephonyManager, + subscriptionManager = + StreamSubscriptionManager( + logger = logProvider.taggedLogger("SCNetworkMonitorSubscriptions") + ), + ) + return StreamClient( scope = scope, apiKey = apiKey, @@ -105,6 +126,7 @@ fun createStreamClient( connectionIdHolder = connectionIdHolder, socketFactory = socketFactory, healthMonitor = healthMonitor, + networkMonitor = networkMonitor, serializationConfig = StreamClientSerializationConfig.default( object : StreamEventSerialization { @@ -113,6 +135,6 @@ fun createStreamClient( override fun deserialize(raw: String): Result = Result.success(Unit) } ), - batcher = batcher, + batcher = batcher ) } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt new file mode 100644 index 0000000..e55ce03 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.StreamConnectedUser +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme +import java.util.Date + +@Composable +public fun ConnectionStateCard(state: StreamConnectionState) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Connection", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + val statusLabel = connectionStatusLabel(state) + val statusState = connectionStatusState(state) + val statusAlert = statusState == false + + NetworkFactRow( + label = "Status", + value = statusLabel, + state = statusState, + alert = statusAlert, + ) + + when (state) { + is StreamConnectionState.Connected -> { + Divider() + NetworkFactRow( + label = "User", + value = state.connectedUser.displayName(), + state = null, + ) + NetworkFactRow( + label = "Connection ID", + value = state.connectionId, + state = null, + ) + } + + is StreamConnectionState.Connecting.Opening -> { + Divider() + NetworkFactRow( + label = "Stage", + value = "Opening socket", + state = null, + ) + NetworkFactRow( + label = "User", + value = state.userId, + state = null, + ) + } + + is StreamConnectionState.Connecting.Authenticating -> { + Divider() + NetworkFactRow( + label = "Stage", + value = "Authenticating", + state = null, + ) + NetworkFactRow( + label = "User", + value = state.userId, + state = null, + ) + } + + is StreamConnectionState.Disconnected -> { + Divider() + NetworkFactRow( + label = "Cause", + value = state.cause?.localizedMessage ?: "No details", + state = false, + alert = state.cause != null, + ) + } + + StreamConnectionState.Idle -> { + Divider() + NetworkFactRow( + label = "Details", + value = "Client idle", + state = null, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectionStateCardPreview() { + StreamandroidcoreTheme { + ConnectionStateCard( + StreamConnectionState.Connected( + connectedUser = sampleConnectedUser(), + connectionId = "conn-1234", + ) + ) + } +} + +private fun sampleConnectedUser(): StreamConnectedUser = + StreamConnectedUser( + createdAt = Date(), + id = "petar", + language = "en", + role = "user", + updatedAt = Date(), + teams = emptyList(), + name = "Petar", + ) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt new file mode 100644 index 0000000..25f87c4 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme +import kotlin.time.ExperimentalTime + +@Composable +@OptIn(ExperimentalTime::class) +public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + if (snapshot == null) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Network", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "No active network detected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return@OutlinedCard + } + + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = "Network", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Last update: ${snapshot.timestamp}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(snapshot.transports.toList()) { transport -> + TransportChip(label = transport.label()) + } + } + + Divider() + + val signalData = snapshot.signalSummary() + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Signal", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + Text( + text = signalData.description, + style = MaterialTheme.typography.bodyMedium, + ) + signalData.progress?.let { progress -> + LinearProgressIndicator( + progress = progress, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Divider() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Status", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + NetworkFactRow( + label = "Internet", + value = snapshot.internet.toStatusValue("Available", "Unavailable"), + state = snapshot.internet, + ) + NetworkFactRow( + label = "Validated", + value = snapshot.validated.toStatusValue("Validated", "Pending"), + state = snapshot.validated, + ) + NetworkFactRow( + label = "VPN", + value = snapshot.vpn.toStatusValue("Enabled", "Disabled"), + state = snapshot.vpn, + ) + NetworkFactRow( + label = "Metered", + value = snapshot.metered.label, + state = + when (snapshot.metered) { + StreamNetworkInfo.Metered.NOT_METERED, + StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED -> true + StreamNetworkInfo.Metered.UNKNOWN_OR_METERED -> false + }, + alert = snapshot.metered == StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, + ) + NetworkFactRow( + label = "Priority", + value = snapshot.priority.label, + state = null, + ) + } + + Divider() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Throughput", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + val bandwidth = snapshot.bandwidthKbps + val downText = bandwidth?.downKbps?.let { "$it kbps" } ?: "Unknown" + val upText = bandwidth?.upKbps?.let { "$it kbps" } ?: "Unknown" + NetworkFactRow(label = "Downlink", value = downText, state = null) + NetworkFactRow(label = "Uplink", value = upText, state = null) + } + + snapshot.link?.let { link -> + Divider() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Link", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + ) + link.interfaceName?.let { + NetworkFactRow(label = "Interface", value = it, state = null) + } + if (link.addresses.isNotEmpty()) { + NetworkFactRow( + label = "Address", + value = link.addresses.first(), + state = null, + ) + } + if (link.dnsServers.isNotEmpty()) { + NetworkFactRow( + label = "DNS", + value = link.dnsServers.joinToString(), + state = null, + ) + } + link.httpProxy?.let { + NetworkFactRow(label = "Proxy", value = it, state = null) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun NetworkInfoCardPreview() { + StreamandroidcoreTheme { + NetworkInfoCard(sampleSnapshot()) + } +} + +@OptIn(ExperimentalTime::class) +private fun sampleSnapshot(): StreamNetworkInfo.Snapshot = + StreamNetworkInfo.Snapshot( + transports = setOf(StreamNetworkInfo.Transport.WIFI, StreamNetworkInfo.Transport.VPN), + internet = true, + validated = true, + captivePortal = false, + vpn = true, + trusted = true, + localOnly = false, + metered = StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, + roaming = false, + bandwidthKbps = StreamNetworkInfo.Bandwidth(downKbps = 12_000, upKbps = 2_500), + priority = StreamNetworkInfo.PriorityHint.LATENCY, + signal = + StreamNetworkInfo.Signal.Wifi( + rssiDbm = -55, + level0to4 = 4, + ssid = "Stream Guest", + bssid = "AA:BB:CC:00:11:22", + frequencyMhz = 5220, + ), + link = + StreamNetworkInfo.Link( + interfaceName = "wlan0", + addresses = listOf("192.168.0.12"), + dnsServers = listOf("1.1.1.1", "8.8.8.8"), + domains = listOf("getstream.io"), + mtu = 1500, + httpProxy = "proxy.local:8080", + ), + ) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt new file mode 100644 index 0000000..40090e5 --- /dev/null +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.sample.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.getstream.android.core.api.model.connection.StreamConnectedUser +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo + +@Composable +internal fun TransportChip(label: String) { + Box( + modifier = + Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +internal fun NetworkFactRow( + label: String, + value: String, + state: Boolean?, + alert: Boolean = false, +) { + val indicatorColor: Color = + when (state) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.outline + } + val baseValueColor = + when (state) { + true -> MaterialTheme.colorScheme.primary + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurfaceVariant + } + val valueColor = if (alert) MaterialTheme.colorScheme.error else baseValueColor + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(10.dp) + .background(indicatorColor, CircleShape), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = label, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.weight(1f)) + Text(text = value, style = MaterialTheme.typography.bodyMedium, color = valueColor) + } +} + +internal data class SignalViewData(val description: String, val progress: Float?) + +internal fun StreamNetworkInfo.Snapshot.signalSummary(): SignalViewData { + val level = signal.level()?.coerceIn(0, 4) + val progress = level?.let { it / 4f } + return SignalViewData(signal.summary(), progress) +} + +private fun StreamNetworkInfo.Signal?.level(): Int? = + when (this) { + is StreamNetworkInfo.Signal.Wifi -> level0to4 + is StreamNetworkInfo.Signal.Cellular -> level0to4 + else -> null + } + +private fun StreamNetworkInfo.Signal?.summary(): String = + when (this) { + is StreamNetworkInfo.Signal.Wifi -> + "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Cellular -> + "Cellular ${rat ?: "Radio"} RSRP: ${rsrpDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Generic -> "Generic signal: $value" + null -> "Signal data unavailable" + } + +internal fun Boolean?.toStatusValue(trueText: String, falseText: String): String = + when (this) { + true -> trueText + false -> falseText + null -> "Unknown" + } + +internal val StreamNetworkInfo.Metered.label: String + get() = + when (this) { + StreamNetworkInfo.Metered.NOT_METERED -> "Unmetered" + StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED -> "Temporarily unmetered" + StreamNetworkInfo.Metered.UNKNOWN_OR_METERED -> "Metered" + } + +internal val StreamNetworkInfo.PriorityHint.label: String + get() = + when (this) { + StreamNetworkInfo.PriorityHint.NONE -> "Balanced" + StreamNetworkInfo.PriorityHint.LATENCY -> "Latency" + StreamNetworkInfo.PriorityHint.BANDWIDTH -> "Bandwidth" + } + +internal fun StreamNetworkInfo.Transport.label(): String = + name.lowercase().replace('_', ' ').replaceFirstChar { it.uppercaseChar() } + +internal fun connectionStatusLabel(state: StreamConnectionState): String = + when (state) { + StreamConnectionState.Idle -> "Idle" + is StreamConnectionState.Connecting.Opening -> "Connecting" + is StreamConnectionState.Connecting.Authenticating -> "Authenticating" + is StreamConnectionState.Connected -> "Connected" + is StreamConnectionState.Disconnected -> "Disconnected" + } + +internal fun connectionStatusState(state: StreamConnectionState): Boolean? = + when (state) { + StreamConnectionState.Idle -> null + is StreamConnectionState.Connecting -> null + is StreamConnectionState.Connected -> true + is StreamConnectionState.Disconnected -> false + } + +internal fun StreamConnectedUser.displayName(): String = name ?: id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c204b8d..7dc10e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ kover = "0.9.1" sonarqube = "6.0.1.5171" kotlinDokka = "1.9.20" nexusPlugin = "1.3.0" +annotationJvm = "1.9.1" [libraries] androidx-core = { module = "androidx.test:core", version.ref = "core" } @@ -64,6 +65,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } retrofit-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-annotation-jvm = { group = "androidx.annotation", name = "annotation-jvm", version.ref = "annotationJvm" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/stream-android-core/build.gradle.kts b/stream-android-core/build.gradle.kts index 83bffe4..fa6e385 100644 --- a/stream-android-core/build.gradle.kts +++ b/stream-android-core/build.gradle.kts @@ -80,6 +80,9 @@ dependencies { detektPlugins(libs.detekt.formatting) + // Android + implementation(libs.androidx.annotation.jvm) + // Network implementation(libs.moshi) implementation(libs.moshi.kotlin) diff --git a/stream-android-core/src/main/AndroidManifest.xml b/stream-android-core/src/main/AndroidManifest.xml index a5918e6..48e9a84 100644 --- a/stream-android-core/src/main/AndroidManifest.xml +++ b/stream-android-core/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + \ No newline at end of file diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index d4d2868..f8ffea8 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -18,6 +18,7 @@ package io.getstream.android.core.api import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.http.StreamOkHttpInterceptors import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -25,10 +26,12 @@ import io.getstream.android.core.api.model.config.StreamHttpConfig import io.getstream.android.core.api.model.config.StreamSocketConfig import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.processing.StreamBatcher import io.getstream.android.core.api.processing.StreamRetryProcessor import io.getstream.android.core.api.processing.StreamSerialProcessingQueue @@ -107,6 +110,17 @@ public interface StreamClient { */ public val connectionState: StateFlow + /** + * Read-only, hot state holder for the current network snapshot. + * + * **Semantics** + * - Emits the latest network snapshot whenever it changes. + * - Hot & conflated: new collectors receive the latest value immediately. + * - `null` if no network is available. + */ + @StreamInternalApi + public val networkInfo: StateFlow + /** * Establishes a connection for the current user. * @@ -219,8 +233,10 @@ public fun StreamClient( // Socket connectionIdHolder: StreamConnectionIdHolder, socketFactory: StreamWebSocketFactory, - healthMonitor: StreamHealthMonitor, batcher: StreamBatcher, + // Monitoring + healthMonitor: StreamHealthMonitor, + networkMonitor: StreamNetworkMonitor, // Http httpConfig: StreamHttpConfig? = null, // Serialization @@ -279,6 +295,7 @@ public fun StreamClient( logger = clientLogger, mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle), subscriptionManager = clientSubscriptionManager, + networkMonitor = networkMonitor, socketSession = StreamSocketSession( logger = logProvider.taggedLogger("SCSocketSession"), diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt new file mode 100644 index 0000000..6a692d6 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/components/StreamAndroidComponentsProvider.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.telephony.TelephonyManager +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.internal.components.StreamAndroidComponentsProviderImpl + +/** + * Provides access to Android system services. + * + * This interface abstracts away the details of accessing Android system services, allowing the SDK + * to work with different versions of Android and different build environments. + */ +@StreamInternalApi +public interface StreamAndroidComponentsProvider { + + /** + * Retrieves the [ConnectivityManager] system service. + * + * @return A [Result] containing the [ConnectivityManager] if successful, or an error if the + * service cannot be retrieved. + */ + public fun connectivityManager(): Result + + /** + * Retrieves the [WifiManager] system service. + * + * @return A [Result] containing the [WifiManager] if successful, or an error if the service + * cannot be retrieved. + */ + public fun wifiManager(): Result + + /** + * Retrieves the [TelephonyManager] system service. + * + * @return A [Result] containing the [TelephonyManager] if successful, or an error if the + * service cannot be retrieved. + */ + public fun telephonyManager(): Result +} + +/** + * Creates a new [StreamAndroidComponentsProvider] instance. + * + * @param context The application context. + * @return A new [StreamAndroidComponentsProvider] instance. + */ +@StreamInternalApi +public fun StreamAndroidComponentsProvider(context: Context): StreamAndroidComponentsProvider = + StreamAndroidComponentsProviderImpl(context) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt new file mode 100644 index 0000000..aa3087c --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkInfo.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.model.connection.network + +import io.getstream.android.core.annotations.StreamInternalApi +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Container for the strongly typed connectivity metadata that Stream surfaces to observers. + * + * The nested data structures mirror the information exposed by Android's connectivity stack while + * normalising names and value ranges so they remain stable across API levels. + */ +@StreamInternalApi +public class StreamNetworkInfo { + + /** + * Immutable capture of the device's currently selected network at a specific point in time. + * + * Every field maps back to values provided by [android.net.NetworkCapabilities] or + * [android.net.LinkProperties]. Nullable fields either were not reported by the platform or are + * hidden due to missing runtime permissions (for example, fine location for Wi-Fi SSID). + * + * @property timestamp When the snapshot was assembled. Defaults to [Clock.System.now]. + * @property transports Physical or virtual mediums (Wi-Fi, cellular, VPN, …) backing the + * connection. + * @property internet Whether the OS believes the network offers internet reachability. + * @property validated Whether the OS completed captive portal / validation checks. + * @property captivePortal True when the network is gated behind a captive portal login flow. + * @property vpn Indicates that the network represents, or is routed through, a VPN. + * @property trusted True when the network is marked as trusted by the system. + * @property localOnly True for local-only networks without internet routing (e.g., Wi-Fi + * Direct). + * @property metered Billing hint describing if traffic may incur charges. + * @property roaming True when the cellular connection is roaming, `null` when unknown. + * @property congested False when the OS explicitly reports the network is *not* congested. + * @property suspended False when the OS explicitly reports the network is *not* suspended. + * @property bandwidthConstrained False when the OS explicitly reports the network is *not* + * bandwidth constrained. + * @property bandwidthKbps Platform-estimated downstream and upstream throughput in kilobits per + * second. + * @property priority Platform hint for whether latency or bandwidth should be prioritised. + * @property signal Normalised radio-level signal information when exposed. + * @property link Link-layer metadata such as interface name, IP addresses, DNS servers, and + * MTU. + */ + @OptIn(ExperimentalTime::class) + @StreamInternalApi + public data class Snapshot( + val timestamp: Instant = Clock.System.now(), + val transports: Set = emptySet(), + val internet: Boolean? = null, + val validated: Boolean? = null, + val captivePortal: Boolean? = null, + val vpn: Boolean? = null, + val trusted: Boolean? = null, + val localOnly: Boolean? = null, + val metered: Metered = Metered.UNKNOWN_OR_METERED, + val roaming: Boolean? = null, + val congested: Boolean? = null, + val suspended: Boolean? = null, + val bandwidthConstrained: Boolean? = null, + val bandwidthKbps: Bandwidth? = null, + val priority: PriorityHint = PriorityHint.NONE, + val signal: Signal? = null, + val link: Link? = null, + ) + + /** + * Enumerates the logical transport mediums that Android associates with a network. + * + * Values correspond to the `TRANSPORT_*` constants from [android.net.NetworkCapabilities]. When + * the system reports no recognised transports, [UNKNOWN] is used as a defensive fallback. + */ + public enum class Transport { + WIFI, + CELLULAR, + ETHERNET, + BLUETOOTH, + WIFI_AWARE, + LOW_PAN, + USB, + THREAD, + SATELLITE, + VPN, + UNKNOWN, + } + + /** Expresses the OS-level priority hint for the current network when multiplexing is needed. */ + public enum class PriorityHint { + /** No explicit hint was published by the platform. */ + NONE, + + /** Low latency should be favoured over throughput (e.g., for real-time media). */ + LATENCY, + + /** Throughput should be prioritised over latency (e.g., large downloads). */ + BANDWIDTH, + } + + /** Billing and quota hint for the connection. */ + public enum class Metered { + /** The platform guarantees the connection is currently unmetered. */ + NOT_METERED, + + /** Temporarily unmetered (e.g., carrier promotion) but should not be relied upon. */ + TEMPORARILY_NOT_METERED, + + /** Unknown or treated as metered to stay on the safe side. */ + UNKNOWN_OR_METERED, + } + + /** + * Platform-supplied bandwidth estimate in kilobits per second. Values are positive when + * available; `null` indicates the estimate is missing or deemed unreliable. + */ + public data class Bandwidth(val downKbps: Int?, val upKbps: Int?) + + /** + * Link-layer metadata for the network interface backing the connection. + * + * Includes identifiers and addressing information that can be used for diagnostics or to tailor + * behaviour (for example, adapting timeouts when behind a known proxy). + */ + public data class Link( + val interfaceName: String?, + val addresses: List, + val dnsServers: List, + val domains: List, + val mtu: Int?, + val httpProxy: String?, + ) + + /** + * Normalised radio signal readings for different transport technologies. + * + * Only the values surfaced by the platform are exposed; absent metrics stay `null`. Use + * [strengthDbm] and [level0to4] as generic helpers when the underlying transport is unknown. + */ + public sealed interface Signal { + /** + * Raw signal strength in dBm when the transport exposes it. Larger (less negative) values + * generally indicate a stronger signal. + */ + public val strengthDbm: Int? + get() = null + + /** Canonical 0..4 "bars" representation when available from the platform APIs. */ + public val level0to4: Int? + get() = null + + /** + * Wi-Fi specific signal details reported by [android.net.wifi.WifiManager]. + * + * @property rssiDbm Received signal strength indicator in dBm, `null` if withheld. + * @property level0to4 Normalised signal level (0 weakest – 4 strongest). + * @property ssid Network SSID when permissions allow disclosure. + * @property bssid Access point BSSID when available. + * @property frequencyMhz Operating frequency of the access point. + */ + public data class Wifi( + val rssiDbm: Int?, + override val level0to4: Int?, + val ssid: String?, + val bssid: String?, + val frequencyMhz: Int?, + override val strengthDbm: Int? = rssiDbm, + ) : Signal + + /** + * Cellular radio measurements aggregated from [android.telephony.SignalStrength]. + * + * @property rat Radio access technology label (NR, LTE, WCDMA, …) when determined. + * @property level0to4 Normalised signal level (0 weakest – 4 strongest). + * @property rsrpDbm Reference signal received power for LTE/NR, in dBm. + * @property rsrqDb Reference signal received quality for LTE/NR, in dB. + * @property sinrDb Signal-to-interference-plus-noise ratio for LTE/NR, in dB. + */ + public data class Cellular( + val rat: String?, + override val level0to4: Int?, + val rsrpDbm: Int?, + val rsrqDb: Int?, + val sinrDb: Int?, + override val strengthDbm: Int? = rsrpDbm, + ) : Signal + + /** Fallback signal bucket used when the platform only exposes a generic strength value. */ + public data class Generic(val value: Int, override val strengthDbm: Int? = value) : Signal + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt new file mode 100644 index 0000000..3f0e857 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitor.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.telephony.TelephonyManager +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.network.StreamNetworkMonitorImpl +import io.getstream.android.core.internal.observers.network.StreamNetworkSignalProcessing +import io.getstream.android.core.internal.observers.network.StreamNetworkSnapshotBuilder +import kotlinx.coroutines.CoroutineScope + +/** + * Observes changes to the device's active network and provides snapshots of its capabilities. + * + * Implementations are expected to be life-cycle aware and safe to invoke from any thread. + */ +@StreamInternalApi +public interface StreamNetworkMonitor { + + /** Registers [listener] to receive network updates. */ + public fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options = StreamSubscriptionManager.Options(), + ): Result + + /** Starts monitoring connectivity changes. Safe to call multiple times. */ + public fun start(): Result + + /** Stops monitoring and releases platform callbacks. Safe to call multiple times. */ + public fun stop(): Result +} + +/** + * Creates a [StreamNetworkMonitor] instance. + * + * @param logger The logger to use for logging. + * @param scope The coroutine scope to use for running the monitor. + * @param subscriptionManager The subscription manager to use for managing listeners. + * @param componentsProvider Provides access to Android system services used for monitoring. + */ +@StreamInternalApi +public fun StreamNetworkMonitor( + logger: StreamLogger, + scope: CoroutineScope, + subscriptionManager: StreamSubscriptionManager, + wifiManager: WifiManager, + telephonyManager: TelephonyManager, + connectivityManager: ConnectivityManager, +): StreamNetworkMonitor = + StreamNetworkMonitorImpl( + logger = logger, + scope = scope, + streamSubscriptionManager = subscriptionManager, + snapshotBuilder = + StreamNetworkSnapshotBuilder( + signalProcessing = StreamNetworkSignalProcessing(), + wifiManager = wifiManager, + telephonyManager = telephonyManager, + ), + connectivityManager = connectivityManager, + ) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt new file mode 100644 index 0000000..a2c197c --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo + +/** + * Listener interface for network state changes. + * + * Implement this interface to receive updates about network state changes. + */ +@StreamInternalApi +public interface StreamNetworkMonitorListener { + /** + * Called when the network is connected. + * + * @param snapshot A [StreamNetworkInfo.Snapshot] describing the newly connected network. + */ + public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) + + /** + * Called when the network is lost. + * + * @param permanent True if the network is lost permanently (e.g., due to airplane mode). + */ + public suspend fun onNetworkLost(permanent: Boolean = false) + + /** + * Called when the properties of the currently connected network change while the connection + * remains active. + * + * @param snapshot A [StreamNetworkInfo.Snapshot] containing the updated properties. + */ + public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt index 322cd6d..95658a8 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/monitor/StreamHealthMonitor.kt @@ -62,12 +62,21 @@ public interface StreamHealthMonitor { public fun acknowledgeHeartbeat() /** Starts the health monitor, beginning the heartbeat and liveness checks. */ - public fun start() + public fun start(): Result /** Stops the health monitor, halting heartbeat and liveness checks. */ - public fun stop() + public fun stop(): Result } +/** + * Creates a new [StreamHealthMonitor] instance. + * + * @param logger The logger to use for logging. + * @param scope The coroutine scope to use for running the health monitor. + * @param interval The interval between heartbeats in milliseconds. + * @param livenessThreshold The liveness threshold in milliseconds. + * @return A new [StreamHealthMonitor] instance. + */ @OptIn(ExperimentalTime::class) @StreamInternalApi public fun StreamHealthMonitor( diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt new file mode 100644 index 0000000..033bd19 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.utils + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.annotations.StreamPublishedApi +import io.getstream.android.core.api.model.exceptions.StreamAggregateException + +/** + * Operator overload for creating a [StreamAggregateException] from two [Throwable]s. + * + * @param other The other [Throwable] to combine with this one. + * @return A [StreamAggregateException] containing both [Throwable]s. + */ +@StreamPublishedApi +public operator fun Throwable.plus(other: Throwable): StreamAggregateException { + val message = "Multiple errors occurred. (${this.message}, ${other.message})" + return if (this is StreamAggregateException && other is StreamAggregateException) { + StreamAggregateException(message, causes + other.causes) + } else if (this is StreamAggregateException) { + StreamAggregateException(message, causes + other) + } else if (other is StreamAggregateException) { + StreamAggregateException(message, listOf(this) + other.causes) + } else { + StreamAggregateException(message, listOf(this, other)) + } +} + +/** + * Operator overload for creating a [Result] of a pair from two [Result]s. + * + * @param other The other [Result] to combine with this one. + * @return A [Result] containing a pair of the values from this and the other [Result], or a + * [StreamAggregateException] if either [Result] is a failure. + */ +@StreamInternalApi +public operator fun Result.times(other: Result): Result> = + this.flatMap { first -> other.map { second -> first to second } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index cccb6ea..4595f58 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -15,13 +15,17 @@ */ package io.getstream.android.core.internal.client +import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.model.StreamTypedKey.Companion.randomExecutionKey import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.api.socket.StreamConnectionIdHolder @@ -47,6 +51,7 @@ internal class StreamClientImpl( private val mutableConnectionState: MutableStateFlow, private val logger: StreamLogger, private val subscriptionManager: StreamSubscriptionManager, + private val networkMonitor: StreamNetworkMonitor, private val scope: CoroutineScope, ) : StreamClient { companion object { @@ -55,14 +60,58 @@ internal class StreamClientImpl( } private var handle: StreamSubscription? = null + private var networkMonitorHandle: StreamSubscription? = null override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() + private var internalNetworkInfo: MutableStateFlow = MutableStateFlow(null) + + @StreamInternalApi + override val networkInfo: StateFlow + get() = internalNetworkInfo.asStateFlow() + override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) override suspend fun connect(): Result = singleFlight.run(connectKey) { + if (networkMonitorHandle == null) { + logger.v { "[connect] Starting network monitor" } + networkMonitorHandle = + networkMonitor + .subscribe( + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected( + snapshot: StreamNetworkInfo.Snapshot? + ) { + logger.v { "[connect] Network connected: $snapshot" } + internalNetworkInfo.update { snapshot } + connect() + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "[connect] Network lost" } + internalNetworkInfo.update { null } + disconnect() + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + logger.v { "[connect] Network changed: $snapshot" } + internalNetworkInfo.update { snapshot } + } + }, + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() + } + + networkMonitor.start() + val currentState = connectionState.value if (currentState is StreamConnectionState.Connected) { logger.w { "[connect] Already connected!" } @@ -74,7 +123,6 @@ internal class StreamClientImpl( socketSession .subscribe( object : StreamClientListener { - override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } mutableConnectionState.update(state) @@ -132,6 +180,8 @@ internal class StreamClientImpl( connectionIdHolder.clear() socketSession.disconnect() handle?.cancel() + networkMonitorHandle?.cancel() + networkMonitorHandle = null handle = null tokenManager.invalidate() serialQueue.stop() diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt new file mode 100644 index 0000000..9cce078 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/components/StreamAndroidComponentsProviderImpl.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.components + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.components.StreamAndroidComponentsProvider + +internal class StreamAndroidComponentsProviderImpl(context: Context) : + StreamAndroidComponentsProvider { + + private val applicationContext = context.applicationContext + + override fun connectivityManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(ConnectivityManager::class.java) + } else { + applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + } + + @SuppressLint("WifiManagerPotentialLeak") + override fun wifiManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(WifiManager::class.java) + } else { + applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + } + } + + override fun telephonyManager(): Result = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + applicationContext.getSystemService(TelephonyManager::class.java) + } else { + applicationContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + } + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt new file mode 100644 index 0000000..12e325b --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt @@ -0,0 +1,279 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.annotation.SuppressLint +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@StreamInternalApi +internal class StreamNetworkMonitorImpl( + private val logger: StreamLogger, + private val scope: CoroutineScope, + private val streamSubscriptionManager: StreamSubscriptionManager, + private val snapshotBuilder: StreamNetworkSnapshotBuilder, + private val connectivityManager: ConnectivityManager, +) : StreamNetworkMonitor { + private val started = AtomicBoolean(false) + private val networkCallbackRef = AtomicReference() + private val activeState = AtomicReference() + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result = streamSubscriptionManager.subscribe(listener, options) + + @SuppressLint("MissingPermission") + override fun start(): Result = runCatching { + if (!started.compareAndSet(false, true)) { + logger.v { "StreamNetworkMonitor already started" } + return@runCatching + } + + val callback = MonitorCallback() + networkCallbackRef.set(callback) + + try { + registerCallback(callback) + resolveInitialState()?.also { initialState -> + activeState.set(initialState) + notifyConnected(initialState.snapshot) + } + } catch (throwable: Throwable) { + logger.e(throwable) { "Failed to start network monitor" } + safeUnregister(callback) + cleanup() + throw throwable + } + } + + override fun stop(): Result = runCatching { + val callback = networkCallbackRef.getAndSet(null) + if (callback != null) { + safeUnregister(callback) + } + cleanup() + } + + private fun registerCallback(callback: ConnectivityManager.NetworkCallback) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(callback) + } else { + val request = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(request, callback) + } + } + + private fun resolveInitialState(): ActiveNetworkState? { + val defaultNetwork = + resolveDefaultNetwork() + ?: run { + logger.v { "No active network available at start" } + return null + } + val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) + val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) + val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null + return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) + } + + private fun resolveDefaultNetwork(): Network? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() + } else { + connectivityManager.allNetworks.firstOrNull() + } + + private fun buildSnapshot( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): StreamNetworkInfo.Snapshot? = + snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } + + private fun handleUpdate( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + reason: UpdateReason, + ) { + if (!shouldProcessNetwork(network)) { + logger.v { "[handleUpdate] Ignoring network $network; not default." } + return + } + + val resolvedCapabilities = + capabilities ?: connectivityManager.getNetworkCapabilities(network) + val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) + val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) + if (snapshot == null) { + logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } + return + } + + val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) + val previousState = activeState.getAndSet(newState) + + val networkChanged = previousState?.network != network || previousState == null + val snapshotChanged = previousState?.snapshot != snapshot + + when { + reason == UpdateReason.AVAILABLE || networkChanged -> { + logger.v { "[handleUpdate] Active network set to $network" } + notifyConnected(snapshot) + } + + snapshotChanged -> { + logger.v { "[handleUpdate] Network properties updated for $network" } + notifyPropertiesChanged(snapshot) + } + + else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } + } + } + + private fun handleLoss(network: Network?, permanent: Boolean) { + val current = activeState.get() + if (current == null) { + logger.v { "[handleLoss] No active network to clear." } + return + } + + if (network != null && network != current.network) { + logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } + return + } + + if (activeState.compareAndSet(current, null)) { + logger.v { "[handleLoss] Network lost: ${current.network}" } + notifyLost(permanent) + } + } + + private fun shouldProcessNetwork(network: Network): Boolean { + val tracked = activeState.get()?.network + if (tracked != null && tracked == network) return true + return isDefaultNetwork(network) + } + + private fun isDefaultNetwork(network: Network): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork == network + } else { + connectivityManager.allNetworks.firstOrNull() == network + } + + private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { + if (snapshot == null) { + return + } + notifyListeners { listener -> listener.onNetworkConnected(snapshot) } + } + + private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { + notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } + } + + private fun notifyLost(permanent: Boolean = false) { + notifyListeners { listener -> listener.onNetworkLost(permanent) } + } + + private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { + streamSubscriptionManager + .forEach { listener -> + scope.launch { + runCatching { block(listener) } + .onFailure { throwable -> + logger.e(throwable) { "Network monitor listener failure" } + } + } + } + .onFailure { throwable -> + logger.e(throwable) { "Failed to iterate network monitor listeners" } + } + } + + private fun cleanup() { + activeState.set(null) + started.set(false) + } + + private fun safeUnregister(callback: ConnectivityManager.NetworkCallback) { + runCatching { connectivityManager.unregisterNetworkCallback(callback) } + .onFailure { logger.w { "Failed to unregister network callback: ${it.message}" } } + } + + private inner class MonitorCallback : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + logger.v { "Network available: $network" } + handleUpdate(network, null, null, UpdateReason.AVAILABLE) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + logger.v { "Network capabilities changed for $network" } + handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) + } + + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + logger.v { "Link properties changed for $network" } + handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) + } + + override fun onLost(network: Network) { + handleLoss(network, permanent = false) + } + + override fun onUnavailable() { + handleLoss(network = null, permanent = true) + } + } + + private data class ActiveNetworkState( + val network: Network, + val capabilities: NetworkCapabilities?, + val linkProperties: LinkProperties?, + val snapshot: StreamNetworkInfo.Snapshot, + ) + + private enum class UpdateReason { + AVAILABLE, + PROPERTIES, + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt new file mode 100644 index 0000000..31af231 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager + +internal fun NetworkCapabilities.safeHasCapability(capability: Int): Boolean? = + runCatching { hasCapability(capability) }.getOrNull() + +internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = + runCatching { hasTransport(transport) }.getOrNull() + +internal fun sanitizeSsid(info: WifiInfo): String? { + val raw = info.ssid?.trim('"') ?: return null + val isPlatformUnknown = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID else false + val isLegacyUnknown = raw == "" + return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } +} + +internal fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = + runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() + +internal fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null + +internal fun signalLevel(strength: SignalStrength?): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null + +internal fun nrStrength(strength: SignalStrength?): CellSignalStrengthNr? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + +internal fun lteStrength(strength: SignalStrength?): CellSignalStrengthLte? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt new file mode 100644 index 0000000..872fd94 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.annotation.SuppressLint +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport + +internal class StreamNetworkSignalProcessing { + + @SuppressLint("MissingPermission") + fun bestEffortSignal( + wifiManager: WifiManager, + telephonyManager: TelephonyManager, + capabilities: NetworkCapabilities?, + transports: Set, + ): Signal? { + val genericValue = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } + } else { + null + } + if (genericValue != null) return Signal.Generic(genericValue) + + return when { + Transport.WIFI in transports -> wifiSignal(wifiManager) + Transport.CELLULAR in transports -> cellularSignal(telephonyManager) + else -> null + } + } + + @SuppressLint("MissingPermission") + fun wifiSignal(wifiManager: WifiManager): Signal.Wifi? { + val info = wifiManager.connectionInfo ?: return null + val rssi = info.rssi + return Signal.Wifi( + rssiDbm = rssi, + level0to4 = wifiSignalLevel(rssi), + ssid = sanitizeSsid(info), + bssid = info.bssid, + frequencyMhz = info.frequency.takeIf { it > 0 }, + ) + } + + @SuppressLint("MissingPermission") + fun cellularSignal(telephonyManager: TelephonyManager): Signal.Cellular? { + val strength = telephonySignalStrength(telephonyManager) ?: return null + val level = signalLevel(strength) + + val nr = nrStrength(strength) + if (nr != null) { + val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrp else null + val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrq else null + val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssSinr else null + return Signal.Cellular( + rat = "NR", + level0to4 = level, + rsrpDbm = rsrp, + rsrqDb = rsrq, + sinrDb = sinr, + ) + } + + val lte = lteStrength(strength) + if (lte != null) { + val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrp else null + val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrq else null + val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rssnr else null + return Signal.Cellular( + rat = "LTE", + level0to4 = level, + rsrpDbm = rsrp, + rsrqDb = rsrq, + sinrDb = sinr, + ) + } + + return Signal.Cellular( + rat = null, + level0to4 = level, + rsrpDbm = null, + rsrqDb = null, + sinrDb = null, + ) + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt new file mode 100644 index 0000000..054f5fa --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Bandwidth +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Link +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Metered +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.PriorityHint +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import kotlin.time.ExperimentalTime + +internal class StreamNetworkSnapshotBuilder( + private val signalProcessing: StreamNetworkSignalProcessing, + private val wifiManager: WifiManager, + private val telephonyManager: TelephonyManager, +) { + @OptIn(ExperimentalTime::class) + fun build( + network: Network, + networkCapabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): Result = runCatching { + val transports = transportsFor(networkCapabilities) + val internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val validated = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + null + } + val captivePortal = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } else { + null + } + val notVpn = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + val vpn = + networkCapabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || (notVpn == false) + val trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED) + val localOnly = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) + } else { + null + } + + val metered = + when { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> + Metered.NOT_METERED + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED + ) == true -> Metered.TEMPORARILY_NOT_METERED + else -> Metered.UNKNOWN_OR_METERED + } + + val congested = + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { + true -> false + else -> null + } + val suspended = + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } + val bandwidthConstrained = + when ( + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED + ) + ) { + true -> false + else -> null + } + + val priority = + when { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == + true -> PriorityHint.LATENCY + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == + true -> PriorityHint.BANDWIDTH + else -> PriorityHint.NONE + } + + val bandwidth = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Bandwidth( + downKbps = networkCapabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, + upKbps = networkCapabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 }, + ) + } else { + null + } + + val signal = + signalProcessing.bestEffortSignal( + wifiManager, + telephonyManager, + networkCapabilities, + transports, + ) + + val link = linkProperties?.toLink() + + val notRoaming = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) + } else { + null + } + + StreamNetworkInfo.Snapshot( + transports = transports, + internet = internet, + validated = validated, + captivePortal = captivePortal, + vpn = vpn, + trusted = trusted, + localOnly = localOnly, + metered = metered, + roaming = notRoaming?.not(), + congested = congested, + suspended = suspended, + bandwidthConstrained = bandwidthConstrained, + bandwidthKbps = bandwidth, + priority = priority, + signal = signal, + link = link, + ) + } + + private fun transportsFor(capabilities: NetworkCapabilities?): Set { + if (capabilities == null) return emptySet() + val out = mutableSetOf() + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) + out += Transport.WIFI + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) + out += Transport.CELLULAR + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true) + out += Transport.ETHERNET + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) == true) + out += Transport.BLUETOOTH + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) == true) + out += Transport.WIFI_AWARE + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_LOWPAN) == true) + out += Transport.LOW_PAN + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_USB) == true) + out += Transport.USB + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_THREAD) == true) + out += Transport.THREAD + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_SATELLITE) == true) + out += Transport.SATELLITE + if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_VPN) == true) + out += Transport.VPN + if (out.isEmpty()) out += Transport.UNKNOWN + return out + } + + private fun LinkProperties.toLink(): Link? { + val addresses = linkAddresses.mapNotNull { it.address?.hostAddress } + val dnsServers = dnsServers.mapNotNull { it.hostAddress } + val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() + val mtuValue = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } + return Link( + interfaceName = interfaceName, + addresses = addresses, + dnsServers = dnsServers, + domains = domains, + mtu = mtuValue, + httpProxy = httpProxyValue, + ) + } + + private fun NetworkCapabilities?.flag(capability: Int): Boolean? = + this?.safeHasCapability(capability) + + private fun NetworkCapabilities?.transport(transport: Int): Boolean = + this?.safeHasTransport(transport) == true +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt index b1ad898..85d3f71 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/socket/monitor/StreamHealthMonitorImpl.kt @@ -67,11 +67,11 @@ internal class StreamHealthMonitorImpl( } /** Starts (or restarts) the periodic health-check loop */ - override fun start() { + override fun start() = runCatching { logger.d { "[start] Staring health monitor" } if (monitorJob?.isActive == true) { logger.d { "Health monitor already running" } - return + return@runCatching } monitorJob = scope.launch { @@ -91,8 +91,9 @@ internal class StreamHealthMonitorImpl( } /** Stops the health-check loop */ - override fun stop() { + override fun stop() = runCatching { logger.d { "[stop] Stopping heath monitor" } monitorJob?.cancel() + Unit } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index e1aabeb..5cb7d39 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -17,6 +17,7 @@ package io.getstream.android.core.api +import android.net.ConnectivityManager import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider @@ -51,6 +52,7 @@ import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.StreamWebSocketImpl import io.getstream.android.core.testutil.assertFieldEquals import io.getstream.android.core.testutil.readPrivateField +import io.mockk.every import io.mockk.mockk import kotlin.test.assertEquals import kotlin.test.assertNotSame @@ -150,6 +152,13 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, + androidComponentsProvider = + mockk(relaxed = true) { + every { connectivityManager() } returns + Result.success(mockk(relaxed = true)) + every { wifiManager() } returns Result.success(mockk(relaxed = true)) + every { telephonyManager() } returns Result.success(mockk(relaxed = true)) + }, ) return client to deps diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt new file mode 100644 index 0000000..3ecbef3 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.utils + +import io.getstream.android.core.api.model.exceptions.StreamAggregateException +import kotlin.test.assertSame +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AlgebraTest { + + @Test + fun `plus combines plain throwables into aggregate`() { + val first = IllegalStateException("one") + val second = IllegalArgumentException("two") + + val combined = first + second + + assertEquals(listOf(first, second), combined.causes) + assertTrue(combined.message?.contains("Multiple errors occurred") == true) + } + + @Test + fun `plus merges causes when both sides are aggregates`() { + val firstCause = IllegalStateException("first") + val secondCause = IllegalArgumentException("second") + val thirdCause = IllegalArgumentException("third") + val firstAgg = StreamAggregateException("left", listOf(firstCause, secondCause)) + val secondAgg = StreamAggregateException("right", listOf(thirdCause)) + + val combined = firstAgg + secondAgg + + assertEquals(listOf(firstCause, secondCause, thirdCause), combined.causes) + } + + @Test + fun `plus appends plain throwable to existing aggregate on left`() { + val existing = IllegalStateException("existing") + val other = IllegalArgumentException("other") + val aggregate = StreamAggregateException("agg", listOf(existing)) + + val combined = aggregate + other + + assertEquals(listOf(existing, other), combined.causes) + } + + @Test + fun `plus prepends plain throwable to aggregate on right`() { + val first = IllegalStateException("first") + val second = IllegalArgumentException("second") + val third = IllegalArgumentException("third") + val rightAggregate = StreamAggregateException("agg", listOf(second, third)) + + val combined = first + rightAggregate + + assertEquals(listOf(first, second, third), combined.causes) + } + + @Test + fun `times returns pair when both results succeed`() { + val left = Result.success(4) + val right = Result.success("value") + + val combined = left * right + + assertEquals(4 to "value", combined.getOrThrow()) + } + + @Test + fun `times propagates failure from left result`() { + val failure = IllegalStateException("failed") + val left = Result.failure(failure) + val right = Result.success("value") + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `times propagates failure from right result`() { + val left = Result.success(1) + val failure = IllegalArgumentException("broken") + val right = Result.failure(failure) + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 5106ca1..0b2fd91 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -96,6 +96,7 @@ class StreamClientIImplTest { mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, + networkMonitor = mockk(relaxed = true), ) @Test diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt index a37c238..a64dfe6 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/socket/StreamSocketSessionTest.kt @@ -102,7 +102,7 @@ class StreamSocketSessionTest { every { socket.close(any(), any()) } returns Result.success(Unit) every { debounce.stop() } returns Result.success(Unit) - every { health.stop() } just Runs + every { health.stop() } returns Result.success(Unit) session = StreamSocketSession( From 6cee404b603d93530a1808af90ce859ed851f49a Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:18:28 +0200 Subject: [PATCH 02/42] Spotless --- .../android/core/sample/SampleActivity.kt | 3 +- .../core/sample/client/StreamClient.kt | 2 +- .../core/sample/ui/ConnectionStateCard.kt | 34 ++++--------------- .../android/core/sample/ui/NetworkInfoCard.kt | 24 +++---------- .../core/sample/ui/NetworkInfoComponents.kt | 28 ++++----------- .../android/core/api/StreamClient.kt | 4 +-- .../core/internal/client/StreamClientImpl.kt | 5 ++- .../network/StreamNetworkMonitorUtils.kt | 3 +- .../network/StreamNetworkSnapshotBuilder.kt | 3 +- 9 files changed, 26 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index f5bc4b2..ec66e23 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -98,8 +98,7 @@ class SampleActivity : ComponentActivity() { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = - Modifier - .fillMaxSize() + Modifier.fillMaxSize() .padding(innerPadding) .verticalScroll(scrollState) .padding(16.dp), diff --git a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt index 8b5dcc3..baf3908 100644 --- a/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt +++ b/app/src/main/java/io/getstream/android/core/sample/client/StreamClient.kt @@ -135,6 +135,6 @@ fun createStreamClient( override fun deserialize(raw: String): Result = Result.success(Unit) } ), - batcher = batcher + batcher = batcher, ) } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt index e55ce03..52b28b9 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/ConnectionStateCard.kt @@ -40,9 +40,7 @@ public fun ConnectionStateCard(state: StreamConnectionState) { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column( modifier = Modifier.padding(20.dp), @@ -82,30 +80,14 @@ public fun ConnectionStateCard(state: StreamConnectionState) { is StreamConnectionState.Connecting.Opening -> { Divider() - NetworkFactRow( - label = "Stage", - value = "Opening socket", - state = null, - ) - NetworkFactRow( - label = "User", - value = state.userId, - state = null, - ) + NetworkFactRow(label = "Stage", value = "Opening socket", state = null) + NetworkFactRow(label = "User", value = state.userId, state = null) } is StreamConnectionState.Connecting.Authenticating -> { Divider() - NetworkFactRow( - label = "Stage", - value = "Authenticating", - state = null, - ) - NetworkFactRow( - label = "User", - value = state.userId, - state = null, - ) + NetworkFactRow(label = "Stage", value = "Authenticating", state = null) + NetworkFactRow(label = "User", value = state.userId, state = null) } is StreamConnectionState.Disconnected -> { @@ -120,11 +102,7 @@ public fun ConnectionStateCard(state: StreamConnectionState) { StreamConnectionState.Idle -> { Divider() - NetworkFactRow( - label = "Details", - value = "Client idle", - state = null, - ) + NetworkFactRow(label = "Details", value = "Client idle", state = null) } } } diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt index 25f87c4..ed3b383 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoCard.kt @@ -44,9 +44,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.outlinedCardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), ) { if (snapshot == null) { Column( @@ -101,15 +99,9 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) - Text( - text = signalData.description, - style = MaterialTheme.typography.bodyMedium, - ) + Text(text = signalData.description, style = MaterialTheme.typography.bodyMedium) signalData.progress?.let { progress -> - LinearProgressIndicator( - progress = progress, - modifier = Modifier.fillMaxWidth(), - ) + LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth()) } } @@ -147,11 +139,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { }, alert = snapshot.metered == StreamNetworkInfo.Metered.UNKNOWN_OR_METERED, ) - NetworkFactRow( - label = "Priority", - value = snapshot.priority.label, - state = null, - ) + NetworkFactRow(label = "Priority", value = snapshot.priority.label, state = null) } Divider() @@ -206,9 +194,7 @@ public fun NetworkInfoCard(snapshot: StreamNetworkInfo.Snapshot?) { @Preview(showBackground = true) @Composable private fun NetworkInfoCardPreview() { - StreamandroidcoreTheme { - NetworkInfoCard(sampleSnapshot()) - } + StreamandroidcoreTheme { NetworkInfoCard(sampleSnapshot()) } } @OptIn(ExperimentalTime::class) diff --git a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt index 40090e5..8e7d7f1 100644 --- a/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt +++ b/app/src/main/java/io/getstream/android/core/sample/ui/NetworkInfoComponents.kt @@ -31,7 +31,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState @@ -41,12 +40,11 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo internal fun TransportChip(label: String) { Box( modifier = - Modifier - .background( + Modifier.background( color = MaterialTheme.colorScheme.secondaryContainer, shape = RoundedCornerShape(50), ) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(horizontal = 12.dp, vertical = 6.dp) ) { Text( text = label, @@ -57,12 +55,7 @@ internal fun TransportChip(label: String) { } @Composable -internal fun NetworkFactRow( - label: String, - value: String, - state: Boolean?, - alert: Boolean = false, -) { +internal fun NetworkFactRow(label: String, value: String, state: Boolean?, alert: Boolean = false) { val indicatorColor: Color = when (state) { true -> MaterialTheme.colorScheme.primary @@ -77,16 +70,8 @@ internal fun NetworkFactRow( } val valueColor = if (alert) MaterialTheme.colorScheme.error else baseValueColor - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(10.dp) - .background(indicatorColor, CircleShape), - ) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.size(10.dp).background(indicatorColor, CircleShape)) Spacer(modifier = Modifier.width(12.dp)) Text(text = label, style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.weight(1f)) @@ -111,8 +96,7 @@ private fun StreamNetworkInfo.Signal?.level(): Int? = private fun StreamNetworkInfo.Signal?.summary(): String = when (this) { - is StreamNetworkInfo.Signal.Wifi -> - "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" + is StreamNetworkInfo.Signal.Wifi -> "Wi-Fi RSSI: ${rssiDbm ?: "?"} dBm" is StreamNetworkInfo.Signal.Cellular -> "Cellular ${rat ?: "Radio"} RSRP: ${rsrpDbm ?: "?"} dBm" is StreamNetworkInfo.Signal.Generic -> "Generic signal: $value" diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index f8ffea8..8ec0940 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -18,7 +18,6 @@ package io.getstream.android.core.api import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider -import io.getstream.android.core.api.components.StreamAndroidComponentsProvider import io.getstream.android.core.api.http.StreamOkHttpInterceptors import io.getstream.android.core.api.log.StreamLoggerProvider import io.getstream.android.core.api.model.config.StreamClientSerializationConfig @@ -118,8 +117,7 @@ public interface StreamClient { * - Hot & conflated: new collectors receive the latest value immediately. * - `null` if no network is available. */ - @StreamInternalApi - public val networkInfo: StateFlow + @StreamInternalApi public val networkInfo: StateFlow /** * Establishes a connection for the current user. diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 4595f58..4fe0fe3 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -64,7 +64,8 @@ internal class StreamClientImpl( override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() - private var internalNetworkInfo: MutableStateFlow = MutableStateFlow(null) + private var internalNetworkInfo: MutableStateFlow = + MutableStateFlow(null) @StreamInternalApi override val networkInfo: StateFlow @@ -86,13 +87,11 @@ internal class StreamClientImpl( ) { logger.v { "[connect] Network connected: $snapshot" } internalNetworkInfo.update { snapshot } - connect() } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } internalNetworkInfo.update { null } - disconnect() } override suspend fun onNetworkPropertiesChanged( diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt index 31af231..33afb9b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -33,7 +33,8 @@ internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = internal fun sanitizeSsid(info: WifiInfo): String? { val raw = info.ssid?.trim('"') ?: return null val isPlatformUnknown = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID else false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID + else false val isLegacyUnknown = raw == "" return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 054f5fa..ab41cdb 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -182,7 +182,8 @@ internal class StreamNetworkSnapshotBuilder( val addresses = linkAddresses.mapNotNull { it.address?.hostAddress } val dnsServers = dnsServers.mapNotNull { it.hostAddress } val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() - val mtuValue = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + val mtuValue = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } return Link( interfaceName = interfaceName, From 078cb6759c6d394e89dd7405802583d6c6a73b53 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:26:35 +0200 Subject: [PATCH 03/42] Update test --- .../getstream/android/core/api/StreamClientFactoryTest.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index 5cb7d39..93968cc 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -152,13 +152,7 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, - androidComponentsProvider = - mockk(relaxed = true) { - every { connectivityManager() } returns - Result.success(mockk(relaxed = true)) - every { wifiManager() } returns Result.success(mockk(relaxed = true)) - every { telephonyManager() } returns Result.success(mockk(relaxed = true)) - }, + networkMonitor = mockk(relaxed = true), ) return client to deps From edac873b91a1819ce996945ca3e5a6c6a210db28 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 11:34:24 +0200 Subject: [PATCH 04/42] Missing permission check --- .../observers/network/StreamNetworkSnapshotBuilder.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index ab41cdb..7bfaa98 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -76,9 +76,13 @@ internal class StreamNetworkSnapshotBuilder( } val congested = - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { - true -> false - else -> null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { + true -> false + else -> null + } + } else { + null } val suspended = when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { From b25c1a31ffebb571345cfcee800da5d5b2d9b2e8 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 14:04:31 +0200 Subject: [PATCH 05/42] Missing permission check --- .../observers/network/StreamNetworkSnapshotBuilder.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 7bfaa98..42c2642 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -85,6 +85,14 @@ internal class StreamNetworkSnapshotBuilder( null } val suspended = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } + } else { + null + } when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { true -> false else -> null From 4b5426569333b835586317c62feea737988d95e3 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 14:49:30 +0200 Subject: [PATCH 06/42] Add tests for the stream network monitor utils and processing --- .../network/StreamNetworkMonitorUtilsTest.kt | 79 +++++++++++ .../StreamNetworkSignalProcessingTest.kt | 127 ++++++++++++++++++ .../StreamNetworkSnapshotBuilderTest.kt | 105 +++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt new file mode 100644 index 0000000..32d7550 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.os.Build +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class StreamNetworkMonitorUtilsTest { + + @MockK(relaxed = true) lateinit var capabilities: NetworkCapabilities + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun `safeHasCapability returns value or null on error`() { + every { capabilities.hasCapability(1) } returns true + assertTrue(capabilities.safeHasCapability(1) == true) + + every { capabilities.hasCapability(2) } throws SecurityException("boom") + assertNull(capabilities.safeHasCapability(2)) + } + + @Test + fun `sanitizeSsid trims markers and ignores unknown`() { + val info = mockk { + every { ssid } returns "\"Stream\"" + } + assertEquals("Stream", sanitizeSsid(info)) + + every { info.ssid } returns "" + assertNull(sanitizeSsid(info)) + } + + @Test + fun `telephony helpers unwrap signal values`() { + val manager = mockk { + every { signalStrength } returns mockk(relaxed = true) + } + assertEquals(manager.signalStrength, telephonySignalStrength(manager)) + + val nrSignalStrength = mockk(relaxed = true) { + every { level } returns 3 + } + assertEquals(3, signalLevel(nrSignalStrength)) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt new file mode 100644 index 0000000..727a864 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +internal class StreamNetworkSignalProcessingTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + processing = StreamNetworkSignalProcessing() + } + + @AfterTest + fun teardown() { + unmockkAll() + } + + @Test + fun `bestEffortSignal returns wifi signal when wifi transport available`() { + val wifiInfo = mockk { + every { rssi } returns -45 + every { ssid } returns "\"Stream\"" + every { bssid } returns "00:11:22:33:44:55" + every { frequency } returns 5200 + } + every { wifiManager.connectionInfo } returns wifiInfo + + val signal = + processing.bestEffortSignal( + wifiManager = wifiManager, + telephonyManager = telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI), + ) + + val wifiSignal = assertIs(signal) + assertEquals(-45, wifiSignal.rssiDbm) + assertEquals("Stream", wifiSignal.ssid) + assertEquals("00:11:22:33:44:55", wifiSignal.bssid) + assertEquals(5200, wifiSignal.frequencyMhz) + } + + @Test + fun `cellularSignal returns NR details when available`() { + val strength = mockk(relaxed = true) + val nrStrength = mockk(relaxed = true) { + every { ssRsrp } returns -95 + every { ssRsrq } returns -10 + every { ssSinr } returns 18 + } + + mockkStatic( + "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" + ) + every { telephonySignalStrength(telephonyManager) } returns strength + every { signalLevel(strength) } returns 3 + every { nrStrength(strength) } returns nrStrength + every { lteStrength(strength) } returns null + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertEquals("NR", cellular.rat) + assertEquals(3, cellular.level0to4) + } + + @Test + fun `bestEffortSignal returns null when no transports`() { + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = emptySet(), + ) + + assertNull(signal) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt new file mode 100644 index 0000000..d8981fc --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.PriorityHint +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.intArrayOf +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime + + +@OptIn(ExperimentalTime::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.Q]) +internal class StreamNetworkSnapshotBuilderTest { + + @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var builder: StreamNetworkSnapshotBuilder + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + } + + @Test + fun `build maps transports capabilities and link data`() { + val network = mockk() + val capabilities = mockk(relaxed = true) + val linkProperties = mockk(relaxed = true) + + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true + every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns true + every { capabilities.linkDownstreamBandwidthKbps } returns 50_000 + every { capabilities.linkUpstreamBandwidthKbps } returns 10_000 + + val signal = StreamNetworkInfo.Signal.Generic(40) + every { + signalProcessing.bestEffortSignal(wifiManager, telephonyManager, capabilities, any()) + } returns signal + + val snapshot = builder.build(network, capabilities, linkProperties).getOrThrow() + + assertNotNull(snapshot) + assertEquals(setOf(Transport.WIFI), snapshot.transports) + assertTrue(snapshot.internet ?: false) + assertTrue(snapshot.validated ?: false) + assertFalse(snapshot.vpn ?: true) + assertEquals(StreamNetworkInfo.Metered.TEMPORARILY_NOT_METERED, snapshot.metered) + assertEquals(PriorityHint.LATENCY, snapshot.priority) + assertEquals(signal, snapshot.signal) + assertEquals(50_000, snapshot.bandwidthKbps?.downKbps) + assertEquals(10_000, snapshot.bandwidthKbps?.upKbps) + assertEquals(false, snapshot.roaming) + assertNotNull(snapshot.link) + } +} From bf78bb964fc5e8368bd5d60149775aeccbbc5109 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 15:45:04 +0200 Subject: [PATCH 07/42] Move network monitor initialization after connection is established, add tests --- .../network/StreamNetworkMonitorListener.kt | 6 +- .../core/internal/client/StreamClientImpl.kt | 73 ++++++++++--------- .../network/StreamNetworkSnapshotBuilder.kt | 8 +- .../core/api/StreamClientFactoryTest.kt | 2 - .../internal/client/StreamClientIImplTest.kt | 7 +- .../network/StreamNetworkMonitorUtilsTest.kt | 19 ++--- .../StreamNetworkSignalProcessingTest.kt | 28 ++++--- .../StreamNetworkSnapshotBuilderTest.kt | 55 +++++++++----- 8 files changed, 107 insertions(+), 91 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt index a2c197c..040674b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt @@ -30,14 +30,14 @@ public interface StreamNetworkMonitorListener { * * @param snapshot A [StreamNetworkInfo.Snapshot] describing the newly connected network. */ - public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) + public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) {} /** * Called when the network is lost. * * @param permanent True if the network is lost permanently (e.g., due to airplane mode). */ - public suspend fun onNetworkLost(permanent: Boolean = false) + public suspend fun onNetworkLost(permanent: Boolean = false) {} /** * Called when the properties of the currently connected network change while the connection @@ -45,5 +45,5 @@ public interface StreamNetworkMonitorListener { * * @param snapshot A [StreamNetworkInfo.Snapshot] containing the updated properties. */ - public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) + public suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) {} } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 4fe0fe3..3e8ca0a 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -76,41 +76,6 @@ internal class StreamClientImpl( override suspend fun connect(): Result = singleFlight.run(connectKey) { - if (networkMonitorHandle == null) { - logger.v { "[connect] Starting network monitor" } - networkMonitorHandle = - networkMonitor - .subscribe( - object : StreamNetworkMonitorListener { - override suspend fun onNetworkConnected( - snapshot: StreamNetworkInfo.Snapshot? - ) { - logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update { snapshot } - } - - override suspend fun onNetworkLost(permanent: Boolean) { - logger.v { "[connect] Network lost" } - internalNetworkInfo.update { null } - } - - override suspend fun onNetworkPropertiesChanged( - snapshot: StreamNetworkInfo.Snapshot - ) { - logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update { snapshot } - } - }, - StreamSubscriptionManager.Options( - retention = - StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED - ), - ) - .getOrThrow() - } - - networkMonitor.start() - val currentState = connectionState.value if (currentState is StreamConnectionState.Connected) { logger.w { "[connect] Already connected!" } @@ -124,7 +89,7 @@ internal class StreamClientImpl( object : StreamClientListener { override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } - mutableConnectionState.update(state) + mutableConnectionState.update { state } subscriptionManager.forEach { it.onState(state) } } @@ -158,6 +123,42 @@ internal class StreamClientImpl( .fold( onSuccess = { connected -> logger.d { "Connected to socket: $connected" } + if (networkMonitorHandle == null) { + logger.v { "[connect] Starting network monitor" } + networkMonitorHandle = + networkMonitor + .subscribe( + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected( + snapshot: StreamNetworkInfo.Snapshot? + ) { + logger.v { + "[connect] Network connected: $snapshot" + } + internalNetworkInfo.update { snapshot } + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "[connect] Network lost" } + internalNetworkInfo.update { null } + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + logger.v { "[connect] Network changed: $snapshot" } + internalNetworkInfo.update { snapshot } + } + }, + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention + .KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() + } + networkMonitor.start() mutableConnectionState.update(connected) connectionIdHolder.setConnectionId(connected.connectionId).map { connected.connectedUser diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 42c2642..ab40a30 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -93,10 +93,10 @@ internal class StreamNetworkSnapshotBuilder( } else { null } - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } + when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { + true -> false + else -> null + } val bandwidthConstrained = when ( networkCapabilities.flag( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt index 93968cc..8e5c4be 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/StreamClientFactoryTest.kt @@ -17,7 +17,6 @@ package io.getstream.android.core.api -import android.net.ConnectivityManager import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider @@ -52,7 +51,6 @@ import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.StreamWebSocketImpl import io.getstream.android.core.testutil.assertFieldEquals import io.getstream.android.core.testutil.readPrivateField -import io.mockk.every import io.mockk.mockk import kotlin.test.assertEquals import kotlin.test.assertNotSame diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 0b2fd91..5bb56ed 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -96,7 +96,12 @@ class StreamClientIImplTest { mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, - networkMonitor = mockk(relaxed = true), + networkMonitor = + mockk(relaxed = true) { + every { start() } returns Result.success(Unit) + every { stop() } returns Result.success(Unit) + every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) + }, ) @Test diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 32d7550..49437c2 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -24,14 +24,14 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.P]) @@ -55,9 +55,7 @@ internal class StreamNetworkMonitorUtilsTest { @Test fun `sanitizeSsid trims markers and ignores unknown`() { - val info = mockk { - every { ssid } returns "\"Stream\"" - } + val info = mockk { every { ssid } returns "\"Stream\"" } assertEquals("Stream", sanitizeSsid(info)) every { info.ssid } returns "" @@ -66,14 +64,11 @@ internal class StreamNetworkMonitorUtilsTest { @Test fun `telephony helpers unwrap signal values`() { - val manager = mockk { - every { signalStrength } returns mockk(relaxed = true) - } + val manager = + mockk { every { signalStrength } returns mockk(relaxed = true) } assertEquals(manager.signalStrength, telephonySignalStrength(manager)) - val nrSignalStrength = mockk(relaxed = true) { - every { level } returns 3 - } + val nrSignalStrength = mockk(relaxed = true) { every { level } returns 3 } assertEquals(3, signalLevel(nrSignalStrength)) } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt index 727a864..ac89e6d 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -15,7 +15,6 @@ */ package io.getstream.android.core.internal.observers.network -import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build @@ -28,7 +27,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.AfterTest @@ -37,8 +35,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNull -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Assert.assertTrue import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -65,12 +61,13 @@ internal class StreamNetworkSignalProcessingTest { @Test fun `bestEffortSignal returns wifi signal when wifi transport available`() { - val wifiInfo = mockk { - every { rssi } returns -45 - every { ssid } returns "\"Stream\"" - every { bssid } returns "00:11:22:33:44:55" - every { frequency } returns 5200 - } + val wifiInfo = + mockk { + every { rssi } returns -45 + every { ssid } returns "\"Stream\"" + every { bssid } returns "00:11:22:33:44:55" + every { frequency } returns 5200 + } every { wifiManager.connectionInfo } returns wifiInfo val signal = @@ -91,11 +88,12 @@ internal class StreamNetworkSignalProcessingTest { @Test fun `cellularSignal returns NR details when available`() { val strength = mockk(relaxed = true) - val nrStrength = mockk(relaxed = true) { - every { ssRsrp } returns -95 - every { ssRsrq } returns -10 - every { ssSinr } returns 18 - } + val nrStrength = + mockk(relaxed = true) { + every { ssRsrp } returns -95 + every { ssRsrq } returns -10 + every { ssSinr } returns 18 + } mockkStatic( "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt index d8981fc..134b719 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt @@ -28,10 +28,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.intArrayOf import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -39,7 +35,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.ExperimentalTime - +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @OptIn(ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) @@ -66,19 +64,40 @@ internal class StreamNetworkSnapshotBuilderTest { every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } returns true every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) } returns true - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) } returns false - every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns + true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } returns + true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) } returns + true + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED) } returns + true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) + } returns false + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) + } returns true + every { + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) + } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) } returns + true every { capabilities.linkDownstreamBandwidthKbps } returns 50_000 every { capabilities.linkUpstreamBandwidthKbps } returns 10_000 From c010b50aeb99a575a68465e4ef5c7d62c4b73915 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 16 Oct 2025 15:46:25 +0200 Subject: [PATCH 08/42] Stop network monitor on Disconnect --- .../getstream/android/core/internal/client/StreamClientImpl.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 3e8ca0a..000b3ca 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -180,6 +180,7 @@ internal class StreamClientImpl( connectionIdHolder.clear() socketSession.disconnect() handle?.cancel() + networkMonitor.stop() networkMonitorHandle?.cancel() networkMonitorHandle = null handle = null From e945bfafa75dd222c46cc4dc2a11c84d55a5dbbd Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:07:15 +0200 Subject: [PATCH 09/42] Refactor the callback into separate delegate and add tests --- .../network/StreamNetworkMonitorCallback.kt | 221 ++++++++++ .../network/StreamNetworkMonitorImpl.kt | 205 +-------- .../StreamNetworkMonitorFactoryTest.kt | 51 +++ .../StreamNetworkMonitorCallbackTest.kt | 417 ++++++++++++++++++ .../network/StreamNetworkMonitorImplTest.kt | 204 +++++++++ .../network/StreamNetworkMonitorUtilsTest.kt | 12 +- 6 files changed, 911 insertions(+), 199 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt new file mode 100644 index 0000000..dba3500 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class StreamNetworkMonitorCallback( + private val logger: StreamLogger, + private val scope: CoroutineScope, + private val subscriptionManager: StreamSubscriptionManager, + private val snapshotBuilder: StreamNetworkSnapshotBuilder, + private val connectivityManager: ConnectivityManager, +) : ConnectivityManager.NetworkCallback() { + + private val activeState = AtomicReference() + + fun onRegistered() { + val initialState = resolveInitialState() + if (initialState != null) { + activeState.set(initialState) + notifyConnected(initialState.snapshot) + } + } + + fun onCleared() { + activeState.set(null) + } + + override fun onAvailable(network: Network) { + logger.v { "Network available: $network" } + handleUpdate(network, null, null, UpdateReason.AVAILABLE) + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + logger.v { "Network capabilities changed for $network" } + handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) + } + + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + logger.v { "Link properties changed for $network" } + handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) + } + + override fun onLost(network: Network) { + handleLoss(network, permanent = false) + } + + override fun onUnavailable() { + handleLoss(network = null, permanent = true) + } + + private fun resolveInitialState(): ActiveNetworkState? { + val defaultNetwork = resolveDefaultNetwork() ?: run { + logger.v { "No active network available at start" } + return null + } + val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) + val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) + val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null + return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) + } + + private fun resolveDefaultNetwork(): Network? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() + } else { + connectivityManager.allNetworks.firstOrNull() + } + + private fun buildSnapshot( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + ): StreamNetworkInfo.Snapshot? = + snapshotBuilder + .build(network, capabilities, linkProperties) + .getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } + + private fun handleUpdate( + network: Network, + capabilities: NetworkCapabilities?, + linkProperties: LinkProperties?, + reason: UpdateReason, + ) { + if (!shouldProcessNetwork(network)) { + logger.v { "[handleUpdate] Ignoring network $network; not default." } + return + } + + val resolvedCapabilities = + capabilities ?: connectivityManager.getNetworkCapabilities(network) + val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) + val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) + if (snapshot == null) { + logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } + return + } + + val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) + val previousState = activeState.getAndSet(newState) + + val networkChanged = previousState?.network != network || previousState == null + val snapshotChanged = previousState?.snapshot != snapshot + + when { + reason == UpdateReason.AVAILABLE || networkChanged -> { + logger.v { "[handleUpdate] Active network set to $network" } + notifyConnected(snapshot) + } + + snapshotChanged -> { + logger.v { "[handleUpdate] Network properties updated for $network" } + notifyPropertiesChanged(snapshot) + } + + else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } + } + } + + private fun handleLoss(network: Network?, permanent: Boolean) { + val current = activeState.get() + if (current == null) { + logger.v { "[handleLoss] No active network to clear." } + return + } + + if (network != null && network != current.network) { + logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } + return + } + + if (activeState.compareAndSet(current, null)) { + logger.v { "[handleLoss] Network lost: ${current.network}" } + notifyLost(permanent) + } + } + + private fun shouldProcessNetwork(network: Network): Boolean { + val tracked = activeState.get()?.network + if (tracked != null && tracked == network) { + return true + } + return isDefaultNetwork(network) + } + + private fun isDefaultNetwork(network: Network): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.activeNetwork == network + } else { + connectivityManager.allNetworks.firstOrNull() == network + } + + private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { + if (snapshot == null) { + return + } + notifyListeners { listener -> listener.onNetworkConnected(snapshot) } + } + + private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { + notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } + } + + private fun notifyLost(permanent: Boolean) { + notifyListeners { listener -> listener.onNetworkLost(permanent) } + } + + private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { + subscriptionManager + .forEach { listener -> + scope.launch { + runCatching { block(listener) } + .onFailure { throwable -> + logger.e(throwable) { "Network monitor listener failure" } + } + } + } + .onFailure { throwable -> + logger.e(throwable) { "Failed to iterate network monitor listeners" } + } + } + + private data class ActiveNetworkState( + val network: Network, + val capabilities: NetworkCapabilities?, + val linkProperties: LinkProperties?, + val snapshot: StreamNetworkInfo.Snapshot, + ) + + private enum class UpdateReason { + AVAILABLE, + PROPERTIES, + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt index 12e325b..d76e787 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImpl.kt @@ -17,14 +17,11 @@ package io.getstream.android.core.internal.observers.network import android.annotation.SuppressLint import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.Build import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.log.StreamLogger -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.subscribe.StreamSubscription @@ -32,7 +29,6 @@ import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch @StreamInternalApi internal class StreamNetworkMonitorImpl( @@ -42,9 +38,9 @@ internal class StreamNetworkMonitorImpl( private val snapshotBuilder: StreamNetworkSnapshotBuilder, private val connectivityManager: ConnectivityManager, ) : StreamNetworkMonitor { + private val started = AtomicBoolean(false) - private val networkCallbackRef = AtomicReference() - private val activeState = AtomicReference() + private val callbackRef = AtomicReference() override fun subscribe( listener: StreamNetworkMonitorListener, @@ -58,28 +54,32 @@ internal class StreamNetworkMonitorImpl( return@runCatching } - val callback = MonitorCallback() - networkCallbackRef.set(callback) + val callback = + StreamNetworkMonitorCallback( + logger = logger, + scope = scope, + subscriptionManager = streamSubscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + callbackRef.set(callback) try { registerCallback(callback) - resolveInitialState()?.also { initialState -> - activeState.set(initialState) - notifyConnected(initialState.snapshot) - } + callback.onRegistered() } catch (throwable: Throwable) { logger.e(throwable) { "Failed to start network monitor" } safeUnregister(callback) + callback.onCleared() cleanup() throw throwable } } override fun stop(): Result = runCatching { - val callback = networkCallbackRef.getAndSet(null) - if (callback != null) { - safeUnregister(callback) - } + val callback = callbackRef.getAndSet(null) ?: return@runCatching + safeUnregister(callback) + callback.onCleared() cleanup() } @@ -95,140 +95,7 @@ internal class StreamNetworkMonitorImpl( } } - private fun resolveInitialState(): ActiveNetworkState? { - val defaultNetwork = - resolveDefaultNetwork() - ?: run { - logger.v { "No active network available at start" } - return null - } - val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) - val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) - val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null - return ActiveNetworkState(defaultNetwork, capabilities, linkProperties, snapshot) - } - - private fun resolveDefaultNetwork(): Network? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.activeNetwork ?: connectivityManager.allNetworks.firstOrNull() - } else { - connectivityManager.allNetworks.firstOrNull() - } - - private fun buildSnapshot( - network: Network, - capabilities: NetworkCapabilities?, - linkProperties: LinkProperties?, - ): StreamNetworkInfo.Snapshot? = - snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> - logger.e(throwable) { "Failed to assemble network snapshot" } - null - } - - private fun handleUpdate( - network: Network, - capabilities: NetworkCapabilities?, - linkProperties: LinkProperties?, - reason: UpdateReason, - ) { - if (!shouldProcessNetwork(network)) { - logger.v { "[handleUpdate] Ignoring network $network; not default." } - return - } - - val resolvedCapabilities = - capabilities ?: connectivityManager.getNetworkCapabilities(network) - val resolvedLink = linkProperties ?: connectivityManager.getLinkProperties(network) - val snapshot = buildSnapshot(network, resolvedCapabilities, resolvedLink) - if (snapshot == null) { - logger.v { "[handleUpdate] Snapshot unavailable; skipping notification." } - return - } - - val newState = ActiveNetworkState(network, resolvedCapabilities, resolvedLink, snapshot) - val previousState = activeState.getAndSet(newState) - - val networkChanged = previousState?.network != network || previousState == null - val snapshotChanged = previousState?.snapshot != snapshot - - when { - reason == UpdateReason.AVAILABLE || networkChanged -> { - logger.v { "[handleUpdate] Active network set to $network" } - notifyConnected(snapshot) - } - - snapshotChanged -> { - logger.v { "[handleUpdate] Network properties updated for $network" } - notifyPropertiesChanged(snapshot) - } - - else -> logger.v { "[handleUpdate] No meaningful changes detected for $network" } - } - } - - private fun handleLoss(network: Network?, permanent: Boolean) { - val current = activeState.get() - if (current == null) { - logger.v { "[handleLoss] No active network to clear." } - return - } - - if (network != null && network != current.network) { - logger.v { "[handleLoss] Ignoring loss for non-active network: $network" } - return - } - - if (activeState.compareAndSet(current, null)) { - logger.v { "[handleLoss] Network lost: ${current.network}" } - notifyLost(permanent) - } - } - - private fun shouldProcessNetwork(network: Network): Boolean { - val tracked = activeState.get()?.network - if (tracked != null && tracked == network) return true - return isDefaultNetwork(network) - } - - private fun isDefaultNetwork(network: Network): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.activeNetwork == network - } else { - connectivityManager.allNetworks.firstOrNull() == network - } - - private fun notifyConnected(snapshot: StreamNetworkInfo.Snapshot?) { - if (snapshot == null) { - return - } - notifyListeners { listener -> listener.onNetworkConnected(snapshot) } - } - - private fun notifyPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { - notifyListeners { listener -> listener.onNetworkPropertiesChanged(snapshot) } - } - - private fun notifyLost(permanent: Boolean = false) { - notifyListeners { listener -> listener.onNetworkLost(permanent) } - } - - private fun notifyListeners(block: suspend (StreamNetworkMonitorListener) -> Unit) { - streamSubscriptionManager - .forEach { listener -> - scope.launch { - runCatching { block(listener) } - .onFailure { throwable -> - logger.e(throwable) { "Network monitor listener failure" } - } - } - } - .onFailure { throwable -> - logger.e(throwable) { "Failed to iterate network monitor listeners" } - } - } - private fun cleanup() { - activeState.set(null) started.set(false) } @@ -236,44 +103,4 @@ internal class StreamNetworkMonitorImpl( runCatching { connectivityManager.unregisterNetworkCallback(callback) } .onFailure { logger.w { "Failed to unregister network callback: ${it.message}" } } } - - private inner class MonitorCallback : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - logger.v { "Network available: $network" } - handleUpdate(network, null, null, UpdateReason.AVAILABLE) - } - - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities, - ) { - logger.v { "Network capabilities changed for $network" } - handleUpdate(network, networkCapabilities, null, UpdateReason.PROPERTIES) - } - - override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) { - logger.v { "Link properties changed for $network" } - handleUpdate(network, null, linkProperties, UpdateReason.PROPERTIES) - } - - override fun onLost(network: Network) { - handleLoss(network, permanent = false) - } - - override fun onUnavailable() { - handleLoss(network = null, permanent = true) - } - } - - private data class ActiveNetworkState( - val network: Network, - val capabilities: NetworkCapabilities?, - val linkProperties: LinkProperties?, - val snapshot: StreamNetworkInfo.Snapshot, - ) - - private enum class UpdateReason { - AVAILABLE, - PROPERTIES, - } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt new file mode 100644 index 0000000..d1047c0 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import android.net.ConnectivityManager +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.network.StreamNetworkMonitorImpl +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.Assert.assertTrue + +internal class StreamNetworkMonitorFactoryTest { + + @Test + fun `factory creates monitor instance`() { + val logger = mockk(relaxed = true) + val subscriptionManager = mockk>(relaxed = true) + val scope = TestScope(StandardTestDispatcher()) + val connectivityManager = mockk() + + val monitor = + StreamNetworkMonitor( + logger = logger, + scope = scope, + subscriptionManager = subscriptionManager, + wifiManager = mockk(relaxed = true), + telephonyManager = mockk(relaxed = true), + connectivityManager = connectivityManager, + ) + + assertTrue(monitor is StreamNetworkMonitorImpl) + assertNotNull(monitor) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt new file mode 100644 index 0000000..12307be --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.R]) +internal class StreamNetworkMonitorCallbackTest { + + @MockK(relaxed = true) lateinit var logger: StreamLogger + @MockK(relaxed = true) lateinit var connectivityManager: ConnectivityManager + @MockK(relaxed = true) lateinit var snapshotBuilder: StreamNetworkSnapshotBuilder + + private lateinit var scope: TestScope + private lateinit var subscriptionManager: RecordingSubscriptionManager + private lateinit var callback: StreamNetworkMonitorCallback + private lateinit var primaryListener: StreamNetworkMonitorListener + private lateinit var secondaryListener: StreamNetworkMonitorListener + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + scope = TestScope(StandardTestDispatcher()) + subscriptionManager = RecordingSubscriptionManager() + primaryListener = mockk(relaxed = true) + secondaryListener = mockk(relaxed = true) + subscriptionManager.subscribe(primaryListener).getOrThrow() + subscriptionManager.subscribe(secondaryListener).getOrThrow() + + callback = + StreamNetworkMonitorCallback( + logger = logger, + scope = scope, + subscriptionManager = subscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + } + + @AfterTest + fun tearDown() { + clearMocks( + logger, + connectivityManager, + snapshotBuilder, + primaryListener, + secondaryListener, + answers = false, + ) + } + + @Test + fun `onRegistered emits initial snapshot when default network available`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onRegistered() + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(snapshot) } + coVerify { secondaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `onRegistered skips when no default network`() { + every { connectivityManager.activeNetwork } returns null + every { connectivityManager.allNetworks } returns emptyArray() + + callback.onRegistered() + scope.advanceUntilIdle() + + verify(exactly = 0) { connectivityManager.getNetworkCapabilities(any()) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + coVerify(exactly = 0) { secondaryListener.onNetworkConnected(any()) } + } + + @Test + fun `onAvailable ignores non-default network`() { + val defaultNetwork = mockk() + val otherNetwork = mockk() + + every { connectivityManager.activeNetwork } returns defaultNetwork + + callback.onAvailable(otherNetwork) + scope.advanceUntilIdle() + + verify(exactly = 0) { connectivityManager.getNetworkCapabilities(otherNetwork) } + verify(exactly = 0) { snapshotBuilder.build(any(), any(), any()) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + @Test + fun `onAvailable publishes snapshot for default network`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(snapshot) } + coVerify { secondaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `onAvailable logs snapshot build failure`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val error = IllegalStateException("boom") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.failure(error) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + @Test + fun `capabilities change triggers properties update when snapshot differs`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val initialSnapshot = mockk() + val updatedSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkConnected(initialSnapshot) } + coVerify { primaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + coVerify { secondaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + } + + @Test + fun `capabilities change with identical snapshot does nothing`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(snapshot), Result.success(snapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify(exactly = 0) { primaryListener.onNetworkPropertiesChanged(any()) } + coVerify(exactly = 1) { primaryListener.onNetworkConnected(snapshot) } + } + + @Test + fun `link properties change triggers properties update`() { + val network = mockk() + val capabilities = mockk() + val initialLink = mockk() + val updatedLink = mockk() + val initialSnapshot = mockk() + val updatedSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returnsMany listOf(initialLink, updatedLink) + every { + snapshotBuilder.build(network, capabilities, any()) + } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLinkPropertiesChanged(network, updatedLink) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkPropertiesChanged(updatedSnapshot) } + } + + @Test + fun `lost network clears state and notifies listeners`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkLost(false) } + coVerify { secondaryListener.onNetworkLost(false) } + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(false) } + } + + @Test + fun `lost event for different network is ignored`() { + val network = mockk() + val other = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(other) + scope.advanceUntilIdle() + + coVerify(exactly = 0) { primaryListener.onNetworkLost(any()) } + + callback.onLost(network) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(false) } + } + + @Test + fun `onUnavailable reports permanent loss once`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onUnavailable() + scope.advanceUntilIdle() + + coVerify { primaryListener.onNetworkLost(true) } + coVerify { secondaryListener.onNetworkLost(true) } + + callback.onUnavailable() + scope.advanceUntilIdle() + + coVerify(exactly = 1) { primaryListener.onNetworkLost(true) } + } + + @Test + fun `listener failure is logged but other listeners still notified`() { + val failingListener = primaryListener + val healthyListener = secondaryListener + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + val error = IllegalArgumentException("listener crash") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + coEvery { failingListener.onNetworkConnected(snapshot) } throws error + coEvery { healthyListener.onNetworkConnected(snapshot) } just runs + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Network monitor listener failure") == true }) } + coVerify { healthyListener.onNetworkConnected(snapshot) } + } + + @Test + fun `subscription iteration failure is logged`() { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + val error = IllegalStateException("iteration error") + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + subscriptionManager.forEachFailure = error + + callback.onAvailable(network) + scope.advanceUntilIdle() + + verify { logger.e(error, match { it?.invoke()?.contains("Failed to iterate network monitor listeners") == true }) } + coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } + } + + private class RecordingSubscriptionManager : StreamSubscriptionManager { + private val listeners = linkedSetOf() + var forEachFailure: Throwable? = null + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result { + listeners += listener + return Result.success(object : StreamSubscription { + private var cancelled = false + override fun cancel() { + if (!cancelled) { + cancelled = true + listeners -= listener + } + } + }) + } + + override fun clear(): Result { + listeners.clear() + return Result.success(Unit) + } + + override fun forEach(block: (StreamNetworkMonitorListener) -> Unit): Result { + val failure = forEachFailure + if (failure != null) { + return Result.failure(failure) + } + listeners.forEach(block) + return Result.success(Unit) + } + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt new file mode 100644 index 0000000..42bd200 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Build +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.mockk.MockKAnnotations +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.junit.runner.RunWith + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.N]) +internal class StreamNetworkMonitorImplTest { + + @MockK(relaxed = true) lateinit var logger: StreamLogger + @MockK(relaxed = true) lateinit var connectivityManager: ConnectivityManager + @MockK(relaxed = true) lateinit var snapshotBuilder: StreamNetworkSnapshotBuilder + + private lateinit var scope: TestScope + private lateinit var subscriptionManager: RecordingSubscriptionManager + private lateinit var monitor: StreamNetworkMonitorImpl + + private val listener = mockk(relaxed = true) + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this) + scope = TestScope(StandardTestDispatcher()) + subscriptionManager = RecordingSubscriptionManager() + monitor = + StreamNetworkMonitorImpl( + logger = logger, + scope = scope, + streamSubscriptionManager = subscriptionManager, + snapshotBuilder = snapshotBuilder, + connectivityManager = connectivityManager, + ) + monitor.subscribe(listener) + } + + @Test + fun `start registers callback and emits initial snapshot`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start().getOrThrow() + callbackSlot.captured.onAvailable(network) + scope.advanceUntilIdle() + + verify { connectivityManager.registerDefaultNetworkCallback(any()) } + coVerify { listener.onNetworkConnected(snapshot) } + } + + @Test + fun `snapshot failure during update does not notify`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val initialSnapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { + snapshotBuilder.build(network, capabilities, linkProperties) + } returnsMany listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start() + callbackSlot.captured.onAvailable(network) + scope.advanceUntilIdle() + + callbackSlot.captured.onCapabilitiesChanged(network, capabilities) + scope.advanceUntilIdle() + + coVerify(exactly = 1) { listener.onNetworkConnected(initialSnapshot) } + } + + @Test + fun `network loss notifies listeners`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + + monitor.start() + val callback = callbackSlot.captured + callback.onAvailable(network) + scope.advanceUntilIdle() + + callback.onLost(network) + scope.advanceUntilIdle() + coVerify { listener.onNetworkLost(false) } + + callback.onAvailable(network) + scope.advanceUntilIdle() + callback.onUnavailable() + scope.advanceUntilIdle() + coVerify { listener.onNetworkLost(true) } + } + + @Test + fun `stop unregisters callback`() = runTest { + val network = mockk() + val capabilities = mockk() + val linkProperties = mockk() + val snapshot = mockk() + + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns capabilities + every { connectivityManager.getLinkProperties(network) } returns linkProperties + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + + val callbackSlot = slot() + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.unregisterNetworkCallback(any()) } just runs + + monitor.start() + val callback = callbackSlot.captured + monitor.stop().getOrThrow() + + verify { connectivityManager.unregisterNetworkCallback(callback) } + } + + private class RecordingSubscriptionManager : StreamSubscriptionManager { + private val listeners = mutableSetOf() + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result { + listeners += listener + return Result.success(mockk(relaxed = true)) + } + + override fun clear(): Result { + listeners.clear() + return Result.success(Unit) + } + + override fun forEach(block: (StreamNetworkMonitorListener) -> Unit): Result { + listeners.forEach(block) + return Result.success(Unit) + } + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 49437c2..3c9b765 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -18,6 +18,8 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.mockk.MockKAnnotations @@ -61,14 +63,4 @@ internal class StreamNetworkMonitorUtilsTest { every { info.ssid } returns "" assertNull(sanitizeSsid(info)) } - - @Test - fun `telephony helpers unwrap signal values`() { - val manager = - mockk { every { signalStrength } returns mockk(relaxed = true) } - assertEquals(manager.signalStrength, telephonySignalStrength(manager)) - - val nrSignalStrength = mockk(relaxed = true) { every { level } returns 3 } - assertEquals(3, signalLevel(nrSignalStrength)) - } } From fce3cfa4c328a38117bd85ceb8153a4b705edffd Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:10:15 +0200 Subject: [PATCH 10/42] Fix lint errors and run spotless --- .../network/StreamNetworkMonitorCallback.kt | 20 ++--- .../network/StreamNetworkSnapshotBuilder.kt | 28 +++--- .../StreamNetworkMonitorFactoryTest.kt | 3 +- .../StreamNetworkMonitorCallbackTest.kt | 87 ++++++++++++------- .../network/StreamNetworkMonitorImplTest.kt | 37 +++++--- .../network/StreamNetworkMonitorUtilsTest.kt | 4 - 6 files changed, 111 insertions(+), 68 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt index dba3500..97b44b5 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallback.kt @@ -74,10 +74,12 @@ internal class StreamNetworkMonitorCallback( } private fun resolveInitialState(): ActiveNetworkState? { - val defaultNetwork = resolveDefaultNetwork() ?: run { - logger.v { "No active network available at start" } - return null - } + val defaultNetwork = + resolveDefaultNetwork() + ?: run { + logger.v { "No active network available at start" } + return null + } val capabilities = connectivityManager.getNetworkCapabilities(defaultNetwork) val linkProperties = connectivityManager.getLinkProperties(defaultNetwork) val snapshot = buildSnapshot(defaultNetwork, capabilities, linkProperties) ?: return null @@ -96,12 +98,10 @@ internal class StreamNetworkMonitorCallback( capabilities: NetworkCapabilities?, linkProperties: LinkProperties?, ): StreamNetworkInfo.Snapshot? = - snapshotBuilder - .build(network, capabilities, linkProperties) - .getOrElse { throwable -> - logger.e(throwable) { "Failed to assemble network snapshot" } - null - } + snapshotBuilder.build(network, capabilities, linkProperties).getOrElse { throwable -> + logger.e(throwable) { "Failed to assemble network snapshot" } + null + } private fun handleUpdate( network: Network, diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index ab40a30..2289d99 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -20,6 +20,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.wifi.WifiManager import android.os.Build +import android.os.ext.SdkExtensions import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Bandwidth @@ -69,9 +70,11 @@ internal class StreamNetworkSnapshotBuilder( when { networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> Metered.NOT_METERED + networkCapabilities.flag( NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED ) == true -> Metered.TEMPORARILY_NOT_METERED + else -> Metered.UNKNOWN_OR_METERED } @@ -93,26 +96,31 @@ internal class StreamNetworkSnapshotBuilder( } else { null } - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } val bandwidthConstrained = - when ( - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED - ) + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 ) { - true -> false - else -> null + when ( + networkCapabilities.flag( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED + ) + ) { + true -> false + else -> null + } + } else { + null } val priority = when { networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == true -> PriorityHint.LATENCY + networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == true -> PriorityHint.BANDWIDTH + else -> PriorityHint.NONE } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt index d1047c0..e9056f1 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorFactoryTest.kt @@ -31,7 +31,8 @@ internal class StreamNetworkMonitorFactoryTest { @Test fun `factory creates monitor instance`() { val logger = mockk(relaxed = true) - val subscriptionManager = mockk>(relaxed = true) + val subscriptionManager = + mockk>(relaxed = true) val scope = TestScope(StandardTestDispatcher()) val connectivityManager = mockk() diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt index 12307be..282418a 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorCallbackTest.kt @@ -26,9 +26,9 @@ import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListe import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.mockk.MockKAnnotations -import io.mockk.coVerify import io.mockk.clearMocks import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just @@ -103,7 +103,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onRegistered() scope.advanceUntilIdle() @@ -150,7 +151,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -169,12 +171,18 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.failure(error) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.failure(error) callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }) } + verify { + logger.e( + error, + match { it?.invoke()?.contains("Failed to assemble network snapshot") == true }, + ) + } coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } } @@ -189,9 +197,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -214,9 +221,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(snapshot), Result.success(snapshot)) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(snapshot), Result.success(snapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -239,10 +245,10 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities - every { connectivityManager.getLinkProperties(network) } returnsMany listOf(initialLink, updatedLink) - every { - snapshotBuilder.build(network, capabilities, any()) - } returnsMany listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) + every { connectivityManager.getLinkProperties(network) } returnsMany + listOf(initialLink, updatedLink) + every { snapshotBuilder.build(network, capabilities, any()) } returnsMany + listOf(Result.success(initialSnapshot), Result.success(updatedSnapshot)) callback.onAvailable(network) scope.advanceUntilIdle() @@ -263,7 +269,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -291,7 +298,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -317,7 +325,8 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) callback.onAvailable(network) scope.advanceUntilIdle() @@ -347,14 +356,20 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) coEvery { failingListener.onNetworkConnected(snapshot) } throws error coEvery { healthyListener.onNetworkConnected(snapshot) } just runs callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Network monitor listener failure") == true }) } + verify { + logger.e( + error, + match { it?.invoke()?.contains("Network monitor listener failure") == true }, + ) + } coVerify { healthyListener.onNetworkConnected(snapshot) } } @@ -369,18 +384,27 @@ internal class StreamNetworkMonitorCallbackTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) subscriptionManager.forEachFailure = error callback.onAvailable(network) scope.advanceUntilIdle() - verify { logger.e(error, match { it?.invoke()?.contains("Failed to iterate network monitor listeners") == true }) } + verify { + logger.e( + error, + match { + it?.invoke()?.contains("Failed to iterate network monitor listeners") == true + }, + ) + } coVerify(exactly = 0) { primaryListener.onNetworkConnected(any()) } } - private class RecordingSubscriptionManager : StreamSubscriptionManager { + private class RecordingSubscriptionManager : + StreamSubscriptionManager { private val listeners = linkedSetOf() var forEachFailure: Throwable? = null @@ -389,15 +413,18 @@ internal class StreamNetworkMonitorCallbackTest { options: StreamSubscriptionManager.Options, ): Result { listeners += listener - return Result.success(object : StreamSubscription { - private var cancelled = false - override fun cancel() { - if (!cancelled) { - cancelled = true - listeners -= listener + return Result.success( + object : StreamSubscription { + private var cancelled = false + + override fun cancel() { + if (!cancelled) { + cancelled = true + listeners -= listener + } } } - }) + ) } override fun clear(): Result { diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt index 42bd200..8f909a6 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorImplTest.kt @@ -41,9 +41,9 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import org.junit.runner.RunWith @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -86,10 +86,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start().getOrThrow() callbackSlot.captured.onAvailable(network) @@ -109,12 +111,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { - snapshotBuilder.build(network, capabilities, linkProperties) - } returnsMany listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returnsMany + listOf(Result.success(initialSnapshot), Result.failure(IllegalStateException("boom"))) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start() callbackSlot.captured.onAvailable(network) @@ -136,10 +138,12 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs monitor.start() val callback = callbackSlot.captured @@ -167,11 +171,17 @@ internal class StreamNetworkMonitorImplTest { every { connectivityManager.activeNetwork } returns network every { connectivityManager.getNetworkCapabilities(network) } returns capabilities every { connectivityManager.getLinkProperties(network) } returns linkProperties - every { snapshotBuilder.build(network, capabilities, linkProperties) } returns Result.success(snapshot) + every { snapshotBuilder.build(network, capabilities, linkProperties) } returns + Result.success(snapshot) val callbackSlot = slot() - every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just runs - every { connectivityManager.unregisterNetworkCallback(any()) } just runs + every { connectivityManager.registerDefaultNetworkCallback(capture(callbackSlot)) } just + runs + every { + connectivityManager.unregisterNetworkCallback( + any() + ) + } just runs monitor.start() val callback = callbackSlot.captured @@ -180,7 +190,8 @@ internal class StreamNetworkMonitorImplTest { verify { connectivityManager.unregisterNetworkCallback(callback) } } - private class RecordingSubscriptionManager : StreamSubscriptionManager { + private class RecordingSubscriptionManager : + StreamSubscriptionManager { private val listeners = mutableSetOf() override fun subscribe( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 3c9b765..3791f08 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -18,10 +18,6 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities import android.net.wifi.WifiInfo import android.os.Build -import android.telephony.CellSignalStrengthLte -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength -import android.telephony.TelephonyManager import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK From 3ff9bb1f8d4b74f99b069bb79e9eea4a01739437 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:29:20 +0200 Subject: [PATCH 11/42] Refactor snapshot builder and add tests --- .../network/StreamNetworkSnapshotBuilder.kt | 296 +++++++++--------- ...eamNetworkSnapshotBuilderLatestApiTest.kt} | 9 +- ...reamNetworkSnapshotBuilderLegacyApiTest.kt | 60 ++++ 3 files changed, 215 insertions(+), 150 deletions(-) rename stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/{StreamNetworkSnapshotBuilderTest.kt => StreamNetworkSnapshotBuilderLatestApiTest.kt} (93%) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt index 2289d99..b9fe8bf 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilder.kt @@ -30,10 +30,12 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo. import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport import kotlin.time.ExperimentalTime +@Suppress("NewApi") internal class StreamNetworkSnapshotBuilder( private val signalProcessing: StreamNetworkSignalProcessing, private val wifiManager: WifiManager, private val telephonyManager: TelephonyManager, + private val extensionVersionProvider: (Int) -> Int = DEFAULT_EXTENSION_PROVIDER, ) { @OptIn(ExperimentalTime::class) fun build( @@ -41,161 +43,78 @@ internal class StreamNetworkSnapshotBuilder( networkCapabilities: NetworkCapabilities?, linkProperties: LinkProperties?, ): Result = runCatching { - val transports = transportsFor(networkCapabilities) - val internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val validated = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } else { - null - } - val captivePortal = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) - } else { - null - } - val notVpn = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - val vpn = - networkCapabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || (notVpn == false) - val trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED) - val localOnly = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) - } else { - null - } - - val metered = - when { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> - Metered.NOT_METERED - - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED - ) == true -> Metered.TEMPORARILY_NOT_METERED - - else -> Metered.UNKNOWN_OR_METERED - } - - val congested = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)) { - true -> false - else -> null - } - } else { - null - } - val suspended = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - when (networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)) { - true -> false - else -> null - } - } else { - null - } - val bandwidthConstrained = - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 - ) { - when ( - networkCapabilities.flag( - NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED - ) - ) { - true -> false - else -> null - } - } else { - null - } - - val priority = - when { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == - true -> PriorityHint.LATENCY - - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == - true -> PriorityHint.BANDWIDTH - - else -> PriorityHint.NONE - } - - val bandwidth = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Bandwidth( - downKbps = networkCapabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, - upKbps = networkCapabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 }, - ) - } else { - null - } - - val signal = - signalProcessing.bestEffortSignal( - wifiManager, - telephonyManager, - networkCapabilities, - transports, - ) + if (!supportsSnapshots()) { + return@runCatching null + } - val link = linkProperties?.toLink() - - val notRoaming = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) - } else { - null - } + val transports = transportsFor(networkCapabilities) + val metered = networkCapabilities.resolveMetered() StreamNetworkInfo.Snapshot( transports = transports, - internet = internet, - validated = validated, - captivePortal = captivePortal, - vpn = vpn, - trusted = trusted, - localOnly = localOnly, + internet = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_INTERNET), + validated = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_VALIDATED, + Build.VERSION_CODES.M, + ), + captivePortal = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL, + Build.VERSION_CODES.M, + ), + vpn = isVpn(networkCapabilities), + trusted = networkCapabilities.flag(NetworkCapabilities.NET_CAPABILITY_TRUSTED), + localOnly = + networkCapabilities.flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK, + Build.VERSION_CODES.VANILLA_ICE_CREAM, + ), metered = metered, - roaming = notRoaming?.not(), - congested = congested, - suspended = suspended, - bandwidthConstrained = bandwidthConstrained, - bandwidthKbps = bandwidth, - priority = priority, - signal = signal, - link = link, + roaming = + networkCapabilities + .flagIfAtLeast( + NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING, + Build.VERSION_CODES.P, + ) + ?.not(), + congested = + networkCapabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED, + Build.VERSION_CODES.P, + ), + suspended = + networkCapabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED, + Build.VERSION_CODES.P, + ), + bandwidthConstrained = bandwidthConstraintHint(networkCapabilities), + bandwidthKbps = bandwidthFor(networkCapabilities), + priority = resolvePriority(networkCapabilities), + signal = + signalProcessing.bestEffortSignal( + wifiManager, + telephonyManager, + networkCapabilities, + transports, + ), + link = linkProperties?.toLink(), ) } private fun transportsFor(capabilities: NetworkCapabilities?): Set { - if (capabilities == null) return emptySet() - val out = mutableSetOf() - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) - out += Transport.WIFI - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) - out += Transport.CELLULAR - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true) - out += Transport.ETHERNET - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) == true) - out += Transport.BLUETOOTH - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE) == true) - out += Transport.WIFI_AWARE - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_LOWPAN) == true) - out += Transport.LOW_PAN - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_USB) == true) - out += Transport.USB - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_THREAD) == true) - out += Transport.THREAD - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_SATELLITE) == true) - out += Transport.SATELLITE - if (capabilities.safeHasTransport(NetworkCapabilities.TRANSPORT_VPN) == true) - out += Transport.VPN - if (out.isEmpty()) out += Transport.UNKNOWN - return out + if (capabilities == null) { + return emptySet() + } + val transports = + KNOWN_TRANSPORTS.mapNotNull { (id, transport) -> + transport.takeIf { capabilities.safeHasTransport(id) == true } + } + .toMutableSet() + if (transports.isEmpty()) { + transports += Transport.UNKNOWN + } + return transports } private fun LinkProperties.toLink(): Link? { @@ -203,7 +122,11 @@ internal class StreamNetworkSnapshotBuilder( val dnsServers = dnsServers.mapNotNull { it.hostAddress } val domains = domains?.split(" ")?.filter { it.isNotBlank() } ?: emptyList() val mtuValue = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) mtu.takeIf { it > 0 } else null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + mtu.takeIf { it > 0 } + } else { + null + } val httpProxyValue = httpProxy?.let { "${it.host}:${it.port}" } return Link( interfaceName = interfaceName, @@ -220,4 +143,83 @@ internal class StreamNetworkSnapshotBuilder( private fun NetworkCapabilities?.transport(transport: Int): Boolean = this?.safeHasTransport(transport) == true + + private fun supportsSnapshots(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + private fun NetworkCapabilities?.flagIfAtLeast(capability: Int, minSdk: Int): Boolean? = + if (Build.VERSION.SDK_INT >= minSdk) flag(capability) else null + + private fun NetworkCapabilities?.negatedCapabilityAsFalse( + capability: Int, + minSdk: Int, + ): Boolean? = + when (flagIfAtLeast(capability, minSdk)) { + true -> false + else -> null + } + + private fun NetworkCapabilities?.resolveMetered(): Metered = + when { + flag(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> Metered.NOT_METERED + flag(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) == true -> + Metered.TEMPORARILY_NOT_METERED + + else -> Metered.UNKNOWN_OR_METERED + } + + private fun isVpn(capabilities: NetworkCapabilities?): Boolean = + capabilities.transport(NetworkCapabilities.TRANSPORT_VPN) || + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false + + private fun bandwidthConstraintHint(capabilities: NetworkCapabilities?): Boolean? = + if (isBandwidthConstraintSupported()) { + capabilities.negatedCapabilityAsFalse( + NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED, + Build.VERSION_CODES.R, + ) + } else { + null + } + + private fun isBandwidthConstraintSupported(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + extensionVersionProvider(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 16 + + private fun bandwidthFor(capabilities: NetworkCapabilities?): Bandwidth? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null + val down = capabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 } + val up = capabilities?.linkUpstreamBandwidthKbps?.takeIf { it > 0 } + return if (down != null || up != null) Bandwidth(downKbps = down, upKbps = up) else null + } + + private fun resolvePriority(capabilities: NetworkCapabilities?): PriorityHint = + when { + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_LATENCY) == true -> + PriorityHint.LATENCY + + capabilities.flag(NetworkCapabilities.NET_CAPABILITY_PRIORITIZE_BANDWIDTH) == true -> + PriorityHint.BANDWIDTH + + else -> PriorityHint.NONE + } + + private companion object { + private val DEFAULT_EXTENSION_PROVIDER: (Int) -> Int = { extension -> + SdkExtensions.getExtensionVersion(extension) + } + + private val KNOWN_TRANSPORTS = + listOf( + NetworkCapabilities.TRANSPORT_WIFI to Transport.WIFI, + NetworkCapabilities.TRANSPORT_CELLULAR to Transport.CELLULAR, + NetworkCapabilities.TRANSPORT_ETHERNET to Transport.ETHERNET, + NetworkCapabilities.TRANSPORT_BLUETOOTH to Transport.BLUETOOTH, + NetworkCapabilities.TRANSPORT_WIFI_AWARE to Transport.WIFI_AWARE, + NetworkCapabilities.TRANSPORT_LOWPAN to Transport.LOW_PAN, + NetworkCapabilities.TRANSPORT_USB to Transport.USB, + NetworkCapabilities.TRANSPORT_THREAD to Transport.THREAD, + NetworkCapabilities.TRANSPORT_SATELLITE to Transport.SATELLITE, + NetworkCapabilities.TRANSPORT_VPN to Transport.VPN, + ) + } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt similarity index 93% rename from stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt rename to stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt index 134b719..d8431e5 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLatestApiTest.kt @@ -41,8 +41,8 @@ import org.robolectric.annotation.Config @OptIn(ExperimentalTime::class) @RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.Q]) -internal class StreamNetworkSnapshotBuilderTest { +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamNetworkSnapshotBuilderLatestApiTest { @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing @MockK(relaxed = true) lateinit var wifiManager: WifiManager @@ -53,7 +53,8 @@ internal class StreamNetworkSnapshotBuilderTest { @BeforeTest fun setup() { MockKAnnotations.init(this) - builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + builder = + StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) { 20 } } @Test @@ -78,6 +79,8 @@ internal class StreamNetworkSnapshotBuilderTest { every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK) } returns false + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns + false every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED) } returns true diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt new file mode 100644 index 0000000..5bba942 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSnapshotBuilderLegacyApiTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.Network +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.time.ExperimentalTime +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalTime::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamNetworkSnapshotBuilderLegacyApiTest { + + @MockK(relaxed = true) lateinit var signalProcessing: StreamNetworkSignalProcessing + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var builder: StreamNetworkSnapshotBuilder + + @BeforeTest + fun setup() { + MockKAnnotations.init(this) + builder = StreamNetworkSnapshotBuilder(signalProcessing, wifiManager, telephonyManager) + } + + @Test + fun `build returns null for legacy api`() { + val network = mockk() + val capabilities = mockk() + + val snapshot = builder.build(network, capabilities, linkProperties = null).getOrThrow() + + assertNull(snapshot) + } +} From dd9922636c34f5ac462edf32766aab27ac4a14a6 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 10:45:09 +0200 Subject: [PATCH 12/42] Refactor signal processing and add tests --- .../network/StreamNetworkMonitorUtils.kt | 39 ---- .../network/StreamNetworkSignalProcessing.kt | 103 ++++++++--- .../network/StreamNetworkMonitorUtilsTest.kt | 13 +- ...eamNetworkSignalProcessingLatestApiTest.kt | 170 ++++++++++++++++++ ...eamNetworkSignalProcessingLegacyApiTest.kt | 60 +++++++ .../StreamNetworkSignalProcessingTest.kt | 28 --- 6 files changed, 311 insertions(+), 102 deletions(-) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt index 33afb9b..e7e807c 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtils.kt @@ -16,48 +16,9 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities -import android.net.wifi.WifiInfo -import android.net.wifi.WifiManager -import android.os.Build -import android.telephony.CellSignalStrengthLte -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength -import android.telephony.TelephonyManager internal fun NetworkCapabilities.safeHasCapability(capability: Int): Boolean? = runCatching { hasCapability(capability) }.getOrNull() internal fun NetworkCapabilities.safeHasTransport(transport: Int): Boolean? = runCatching { hasTransport(transport) }.getOrNull() - -internal fun sanitizeSsid(info: WifiInfo): String? { - val raw = info.ssid?.trim('"') ?: return null - val isPlatformUnknown = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) raw == WifiManager.UNKNOWN_SSID - else false - val isLegacyUnknown = raw == "" - return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } -} - -internal fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = - runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() - -internal fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null - -internal fun signalLevel(strength: SignalStrength?): Int? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null - -internal fun nrStrength(strength: SignalStrength?): CellSignalStrengthNr? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() - } else { - null - } - -internal fun lteStrength(strength: SignalStrength?): CellSignalStrengthLte? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() - } else { - null - } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt index 872fd94..bb40a59 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessing.kt @@ -15,30 +15,28 @@ */ package io.getstream.android.core.internal.observers.network -import android.annotation.SuppressLint import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport internal class StreamNetworkSignalProcessing { - @SuppressLint("MissingPermission") fun bestEffortSignal( wifiManager: WifiManager, telephonyManager: TelephonyManager, capabilities: NetworkCapabilities?, transports: Set, ): Signal? { - val genericValue = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } - } else { - null - } - if (genericValue != null) return Signal.Generic(genericValue) + genericSignal(capabilities)?.let { + return it + } return when { Transport.WIFI in transports -> wifiSignal(wifiManager) @@ -47,7 +45,6 @@ internal class StreamNetworkSignalProcessing { } } - @SuppressLint("MissingPermission") fun wifiSignal(wifiManager: WifiManager): Signal.Wifi? { val info = wifiManager.connectionInfo ?: return null val rssi = info.rssi @@ -60,36 +57,27 @@ internal class StreamNetworkSignalProcessing { ) } - @SuppressLint("MissingPermission") fun cellularSignal(telephonyManager: TelephonyManager): Signal.Cellular? { val strength = telephonySignalStrength(telephonyManager) ?: return null val level = signalLevel(strength) - val nr = nrStrength(strength) - if (nr != null) { - val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrp else null - val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssRsrq else null - val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) nr.ssSinr else null + nrReport(strength)?.let { nr -> return Signal.Cellular( rat = "NR", level0to4 = level, - rsrpDbm = rsrp, - rsrqDb = rsrq, - sinrDb = sinr, + rsrpDbm = nr.ssRsrpValue(), + rsrqDb = nr.ssRsrqValue(), + sinrDb = nr.ssSinrValue(), ) } - val lte = lteStrength(strength) - if (lte != null) { - val rsrp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrp else null - val rsrq = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rsrq else null - val sinr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) lte.rssnr else null + lteReport(strength)?.let { lte -> return Signal.Cellular( rat = "LTE", level0to4 = level, - rsrpDbm = rsrp, - rsrqDb = rsrq, - sinrDb = sinr, + rsrpDbm = lte.rsrpValue(), + rsrqDb = lte.rsrqValue(), + sinrDb = lte.rssnrValue(), ) } @@ -101,4 +89,65 @@ internal class StreamNetworkSignalProcessing { sinrDb = null, ) } + + private fun genericSignal(capabilities: NetworkCapabilities?): Signal.Generic? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return null + } + val strength = capabilities?.signalStrength?.takeIf { it != Int.MIN_VALUE } ?: return null + return Signal.Generic(strength) + } + + private fun sanitizeSsid(info: WifiInfo): String? { + val raw = info.ssid?.trim('"') ?: return null + val isPlatformUnknown = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + raw == WifiManager.UNKNOWN_SSID + } else { + false + } + val isLegacyUnknown = raw == "" + return raw.takeUnless { isPlatformUnknown || isLegacyUnknown } + } + + private fun wifiSignalLevel(rssi: Int, numLevels: Int = 5): Int? = + runCatching { WifiManager.calculateSignalLevel(rssi, numLevels) }.getOrNull() + + private fun telephonySignalStrength(manager: TelephonyManager): SignalStrength? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) manager.signalStrength else null + + private fun signalLevel(strength: SignalStrength?): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) strength?.level else null + + private fun nrReport(strength: SignalStrength?): CellSignalStrengthNr? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + + private fun lteReport(strength: SignalStrength?): CellSignalStrengthLte? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + strength?.cellSignalStrengths?.filterIsInstance()?.firstOrNull() + } else { + null + } + + private fun CellSignalStrengthNr.ssRsrpValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssRsrp else null + + private fun CellSignalStrengthNr.ssRsrqValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssRsrq else null + + private fun CellSignalStrengthNr.ssSinrValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ssSinr else null + + private fun CellSignalStrengthLte.rsrpValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rsrp else null + + private fun CellSignalStrengthLte.rsrqValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rsrq else null + + private fun CellSignalStrengthLte.rssnrValue(): Int? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) rssnr else null } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt index 3791f08..965e564 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkMonitorUtilsTest.kt @@ -16,15 +16,12 @@ package io.getstream.android.core.internal.observers.network import android.net.NetworkCapabilities -import android.net.wifi.WifiInfo import android.os.Build import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith @@ -52,11 +49,11 @@ internal class StreamNetworkMonitorUtilsTest { } @Test - fun `sanitizeSsid trims markers and ignores unknown`() { - val info = mockk { every { ssid } returns "\"Stream\"" } - assertEquals("Stream", sanitizeSsid(info)) + fun `safeHasTransport returns value or null on error`() { + every { capabilities.hasTransport(1) } returns true + assertTrue(capabilities.safeHasTransport(1) == true) - every { info.ssid } returns "" - assertNull(sanitizeSsid(info)) + every { capabilities.hasTransport(2) } throws SecurityException("boom") + assertNull(capabilities.safeHasTransport(2)) } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt new file mode 100644 index 0000000..87dcdaa --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLatestApiTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.CellSignalStrengthLte +import android.telephony.CellSignalStrengthNr +import android.telephony.SignalStrength +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamNetworkSignalProcessingLatestApiTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + processing = StreamNetworkSignalProcessing() + } + + @AfterTest + fun tearDown() { + clearMocks(wifiManager, telephonyManager) + } + + @Test + fun `best effort prefers generic signal strength when available`() { + val capabilities = mockk(relaxed = true) + every { capabilities.signalStrength } returns 120 + + val signal = + processing.bestEffortSignal(wifiManager, telephonyManager, capabilities, emptySet()) + + assertIs(signal) + assertEquals(120, signal.value) + } + + @Test + fun `wifi signal is mapped when wifi transport present`() { + val wifiInfo = mockk(relaxed = true) + every { wifiInfo.rssi } returns -50 + every { wifiInfo.ssid } returns "\"TestWifi\"" + every { wifiInfo.bssid } returns "01:23:45:67:89:ab" + every { wifiInfo.frequency } returns 2412 + every { wifiManager.connectionInfo } returns wifiInfo + + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI), + ) + + val wifi = assertIs(signal) + assertEquals(-50, wifi.rssiDbm) + assertEquals("TestWifi", wifi.ssid) + assertEquals("01:23:45:67:89:ab", wifi.bssid) + assertNotNull(wifi.level0to4) + assertEquals(2412, wifi.frequencyMhz) + } + + @Test + fun `cellular signal prefers NR metrics and falls back to LTE`() { + val nrStrength = mockk(relaxed = true) + every { nrStrength.ssRsrp } returns -95 + every { nrStrength.ssRsrq } returns -10 + every { nrStrength.ssSinr } returns 20 + + val lteStrength = mockk(relaxed = true) + every { lteStrength.rsrp } returns -110 + every { lteStrength.rsrq } returns -12 + every { lteStrength.rssnr } returns 5 + + val strength = mockk(relaxed = true) + every { strength.level } returns 3 + every { strength.cellSignalStrengths } returns listOf(nrStrength, lteStrength) + every { telephonyManager.signalStrength } returns strength + + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.CELLULAR), + ) + + val cellular = assertIs(signal) + assertEquals("NR", cellular.rat) + assertEquals(3, cellular.level0to4) + assertEquals(-95, cellular.rsrpDbm) + assertEquals(-10, cellular.rsrqDb) + assertEquals(20, cellular.sinrDb) + } + + @Test + fun `cellular signal falls back when only LTE metrics available`() { + val lteStrength = mockk(relaxed = true) + every { lteStrength.rsrp } returns -105 + every { lteStrength.rsrq } returns -11 + every { lteStrength.rssnr } returns 6 + + val strength = mockk(relaxed = true) + every { strength.level } returns 2 + every { strength.cellSignalStrengths } returns listOf(lteStrength) + every { telephonyManager.signalStrength } returns strength + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertEquals("LTE", cellular.rat) + assertEquals(2, cellular.level0to4) + assertEquals(-105, cellular.rsrpDbm) + assertEquals(-11, cellular.rsrqDb) + assertEquals(6, cellular.sinrDb) + } + + @Test + fun `cellular signal returns generic level when no specific RAT present`() { + val strength = mockk(relaxed = true) + every { strength.level } returns 1 + every { strength.cellSignalStrengths } returns emptyList() + every { telephonyManager.signalStrength } returns strength + + val signal = processing.cellularSignal(telephonyManager) + + val cellular = assertIs(signal) + assertNull(cellular.rat) + assertEquals(1, cellular.level0to4) + assertEquals(null, cellular.rsrpDbm) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt new file mode 100644 index 0000000..36de51b --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingLegacyApiTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.internal.observers.network + +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamNetworkSignalProcessingLegacyApiTest { + + @MockK(relaxed = true) lateinit var wifiManager: WifiManager + @MockK(relaxed = true) lateinit var telephonyManager: TelephonyManager + + private lateinit var processing: StreamNetworkSignalProcessing + + @BeforeTest + fun setUp() { + MockKAnnotations.init(this, relaxUnitFun = true) + every { wifiManager.connectionInfo } returns null + processing = StreamNetworkSignalProcessing() + } + + @Test + fun `best effort signal returns null on legacy devices`() { + val signal = + processing.bestEffortSignal( + wifiManager, + telephonyManager, + capabilities = null, + transports = setOf(Transport.WIFI, Transport.CELLULAR), + ) + + assertNull(signal) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt index ac89e6d..ea2936f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/network/StreamNetworkSignalProcessingTest.kt @@ -18,8 +18,6 @@ package io.getstream.android.core.internal.observers.network import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build -import android.telephony.CellSignalStrengthNr -import android.telephony.SignalStrength import android.telephony.TelephonyManager import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Signal import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo.Transport @@ -27,7 +25,6 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -85,31 +82,6 @@ internal class StreamNetworkSignalProcessingTest { assertEquals(5200, wifiSignal.frequencyMhz) } - @Test - fun `cellularSignal returns NR details when available`() { - val strength = mockk(relaxed = true) - val nrStrength = - mockk(relaxed = true) { - every { ssRsrp } returns -95 - every { ssRsrq } returns -10 - every { ssSinr } returns 18 - } - - mockkStatic( - "io.getstream.android.core.internal.observers.network.StreamNetworkMonitorUtilsKt" - ) - every { telephonySignalStrength(telephonyManager) } returns strength - every { signalLevel(strength) } returns 3 - every { nrStrength(strength) } returns nrStrength - every { lteStrength(strength) } returns null - - val signal = processing.cellularSignal(telephonyManager) - - val cellular = assertIs(signal) - assertEquals("NR", cellular.rat) - assertEquals(3, cellular.level0to4) - } - @Test fun `bestEffortSignal returns null when no transports`() { val signal = From c9f8003a578d6ceb552f4ccc1fc704af26d6cff4 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 11:39:18 +0200 Subject: [PATCH 13/42] Add more tests --- ...mAndroidComponentsProviderLatestApiTest.kt | 71 +++++++++++++++++ ...mAndroidComponentsProviderLegacyApiTest.kt | 76 +++++++++++++++++++ .../StreamNetworkMonitorListenerTest.kt | 73 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt new file mode 100644 index 0000000..29dcd7e --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.VANILLA_ICE_CREAM]) +internal class StreamAndroidComponentsProviderLatestApiTest { + + private lateinit var context: Context + private lateinit var provider: StreamAndroidComponentsProvider + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + provider = StreamAndroidComponentsProvider(context) + } + + @Test + fun `connectivity manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(ConnectivityManager::class.java) + val result = provider.connectivityManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `wifi manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(WifiManager::class.java) + val result = provider.wifiManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `telephony manager is returned from application context`() { + val expected = context.applicationContext.getSystemService(TelephonyManager::class.java) + val result = provider.telephonyManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt new file mode 100644 index 0000000..de898e2 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.components + +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.os.Build +import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) +internal class StreamAndroidComponentsProviderLegacyApiTest { + + private lateinit var context: Context + private lateinit var provider: StreamAndroidComponentsProvider + + @BeforeTest + fun setUp() { + context = ApplicationProvider.getApplicationContext() + provider = StreamAndroidComponentsProvider(context) + } + + @Test + fun `connectivity manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) + as ConnectivityManager + val result = provider.connectivityManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `wifi manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val result = provider.wifiManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } + + @Test + fun `telephony manager is obtained via legacy service lookup`() { + val expected = + context.applicationContext.getSystemService(Context.TELEPHONY_SERVICE) + as TelephonyManager + val result = provider.telephonyManager() + + assertTrue(result.isSuccess) + assertEquals(expected, result.getOrNull()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt new file mode 100644 index 0000000..f98bb68 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.core.api.observers.network + +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlin.test.assertContentEquals +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +internal class StreamNetworkMonitorListenerTest { + + @Test + fun `default listener implementations no-op`() = runTest { + val listener = object : StreamNetworkMonitorListener {} + + listener.onNetworkConnected(null) + listener.onNetworkLost() + listener.onNetworkPropertiesChanged( + StreamNetworkInfo.Snapshot(transports = emptySet()), + ) + } + + @Test + fun `overrides receive the expected payloads`() = runTest { + val snapshots = mutableListOf() + var lostFlag: Boolean? = null + + val listener = + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) { + snapshots += snapshot + } + + override suspend fun onNetworkLost(permanent: Boolean) { + lostFlag = permanent + } + + override suspend fun onNetworkPropertiesChanged( + snapshot: StreamNetworkInfo.Snapshot + ) { + snapshots += snapshot + } + } + + val connected = StreamNetworkInfo.Snapshot(transports = emptySet()) + val updated = connected.copy(priority = StreamNetworkInfo.PriorityHint.LATENCY) + + listener.onNetworkConnected(connected) + listener.onNetworkPropertiesChanged(updated) + listener.onNetworkLost(permanent = true) + + assertContentEquals(listOf(connected, updated), snapshots) + assertTrue(lostFlag == true) + } +} From d1188f63ddf2027c9b9c7113e71eb4df260675d4 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 11:40:03 +0200 Subject: [PATCH 14/42] Spotless --- .../StreamAndroidComponentsProviderLatestApiTest.kt | 2 +- .../StreamAndroidComponentsProviderLegacyApiTest.kt | 2 +- .../network/StreamNetworkMonitorListenerTest.kt | 9 +++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt index 29dcd7e..1ec22a2 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLatestApiTest.kt @@ -2,7 +2,7 @@ * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. * * Licensed under the Stream License; - * You may not use this file except in compliance with the License. + * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://github.com/GetStream/stream-core-android/blob/main/LICENSE diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt index de898e2..45f6c3e 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/components/StreamAndroidComponentsProviderLegacyApiTest.kt @@ -2,7 +2,7 @@ * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. * * Licensed under the Stream License; - * You may not use this file except in compliance with the License. + * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://github.com/GetStream/stream-core-android/blob/main/LICENSE diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt index f98bb68..59fa6e5 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListenerTest.kt @@ -17,12 +17,11 @@ package io.getstream.android.core.api.observers.network import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.assertContentEquals import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import kotlin.test.assertContentEquals -import kotlin.time.ExperimentalTime @OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) internal class StreamNetworkMonitorListenerTest { @@ -33,9 +32,7 @@ internal class StreamNetworkMonitorListenerTest { listener.onNetworkConnected(null) listener.onNetworkLost() - listener.onNetworkPropertiesChanged( - StreamNetworkInfo.Snapshot(transports = emptySet()), - ) + listener.onNetworkPropertiesChanged(StreamNetworkInfo.Snapshot(transports = emptySet())) } @Test From b91fcdc2d669eb5ff3560e15a79546b11e6c7c3a Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 13:40:11 +0200 Subject: [PATCH 15/42] Fix algebra --- .../io/getstream/android/core/api/utils/Algebra.kt | 13 +++++++++++-- .../android/core/api/utils/AlgebraTest.kt | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt index 033bd19..6a5e614 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -47,5 +47,14 @@ public operator fun Throwable.plus(other: Throwable): StreamAggregateException { * [StreamAggregateException] if either [Result] is a failure. */ @StreamInternalApi -public operator fun Result.times(other: Result): Result> = - this.flatMap { first -> other.map { second -> first to second } } +public operator fun Result.times(other: Result): Result> { + when { + this.isFailure && other.isFailure -> { + return Result.failure(this.exceptionOrNull()!! + other.exceptionOrNull()!!) + } + + this.isFailure -> return Result.failure(this.exceptionOrNull()!!) + other.isFailure -> return Result.failure(other.exceptionOrNull()!!) + } + return Result.success(this.getOrThrow() to other.getOrThrow()) +} \ No newline at end of file diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt index 3ecbef3..cf8fe8f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -103,4 +103,18 @@ class AlgebraTest { assertTrue(combined.isFailure) assertSame(failure, combined.exceptionOrNull()) } + + @Test + fun `times propagates both results when both fail`() { + val failure = IllegalArgumentException("broken") + val failure2 = IllegalArgumentException("broken2") + val left = Result.failure(failure) + val right = Result.failure(failure2) + + val combined = left * right + + assertTrue(combined.isFailure) + val exception = combined.exceptionOrNull() as StreamAggregateException + assertEquals(listOf(failure, failure2), exception.causes) + } } From 77f24ef427bc73ea2021e8c7d097e6a3e658df32 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 13:43:55 +0200 Subject: [PATCH 16/42] Update wrapper is now more generic --- .../android/core/internal/client/StreamClientImpl.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 000b3ca..a3e2d2b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -89,7 +89,7 @@ internal class StreamClientImpl( object : StreamClientListener { override fun onState(state: StreamConnectionState) { logger.v { "[client#onState]: $state" } - mutableConnectionState.update { state } + mutableConnectionState.update(state) subscriptionManager.forEach { it.onState(state) } } @@ -135,19 +135,19 @@ internal class StreamClientImpl( logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update { snapshot } + internalNetworkInfo.update(snapshot) } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - internalNetworkInfo.update { null } + internalNetworkInfo.update(null) } override suspend fun onNetworkPropertiesChanged( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update { snapshot } + internalNetworkInfo.update(snapshot) } }, StreamSubscriptionManager.Options( @@ -189,7 +189,7 @@ internal class StreamClientImpl( singleFlight.clear(true) } - private fun MutableStateFlow.update(state: StreamConnectionState) { + private fun MutableStateFlow.update(state: T) { this.update { state } } } From 693328ecfd9af9b407d5a26b239b04236533c891 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:00:30 +0200 Subject: [PATCH 17/42] Spotless --- .../main/java/io/getstream/android/core/api/utils/Algebra.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt index 6a5e614..4fb8c10 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Algebra.kt @@ -57,4 +57,4 @@ public operator fun Result.times(other: Result): Result> other.isFailure -> return Result.failure(other.exceptionOrNull()!!) } return Result.success(this.getOrThrow() to other.getOrThrow()) -} \ No newline at end of file +} From e0e234c708cfc0281cf1e2f6bc0a1af1e168956c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:32:01 +0200 Subject: [PATCH 18/42] Change state update mechanism --- .../android/core/api/StreamClient.kt | 5 +- .../connection/network/StreamNetworkState.kt | 63 +++++++++++++++++++ .../socket/listeners/StreamClientListener.kt | 9 +++ .../core/internal/client/StreamClientImpl.kt | 31 ++++++--- .../StreamListenersDefaultImplsTest.kt | 6 ++ 5 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt index 8ec0940..14ee9fd 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/StreamClient.kt @@ -25,7 +25,7 @@ import io.getstream.android.core.api.model.config.StreamHttpConfig import io.getstream.android.core.api.model.config.StreamSocketConfig import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamUserId @@ -117,7 +117,7 @@ public interface StreamClient { * - Hot & conflated: new collectors receive the latest value immediately. * - `null` if no network is available. */ - @StreamInternalApi public val networkInfo: StateFlow + @StreamInternalApi public val networkState: StateFlow /** * Establishes a connection for the current user. @@ -291,6 +291,7 @@ public fun StreamClient( serialQueue = serialQueue, connectionIdHolder = connectionIdHolder, logger = clientLogger, + mutableNetworkState = MutableStateFlow(StreamNetworkState.Unknown), mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle), subscriptionManager = clientSubscriptionManager, networkMonitor = networkMonitor, diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt new file mode 100644 index 0000000..545728a --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt @@ -0,0 +1,63 @@ +package io.getstream.android.core.api.model.connection.network + +import io.getstream.android.core.annotations.StreamInternalApi + +@StreamInternalApi +public sealed class StreamNetworkState { + + /** + * Signals that the platform reported a permanent loss of network connectivity. + * + * This state mirrors the `ConnectivityManager.NetworkCallback.onUnavailable` callback, which + * indicates no viable network path exists. Applications should back off from network work and + * surface an offline UI until a different state is received. + * + * ### Example + * ```kotlin + * when (state) { + * StreamNetworkState.Unavailable -> showOfflineBanner("No connection available") + * else -> hideOfflineBanner() + * } + * ``` + */ + public data object Unavailable : StreamNetworkState() + + /** + * Represents the initial, indeterminate state before any network callbacks have fired. + * + * Use this as a cue to defer UI decisions until more definitive information arrives. The state + * will transition to one of the other variants once the monitor observes connectivity events. + */ + public data object Unknown : StreamNetworkState() + + /** + * Indicates that a network was previously tracked but has been lost. + * + * This corresponds to `ConnectivityManager.NetworkCallback.onLost`. Stream monitors emit this + * when the active network disconnects but the system may still attempt reconnection, so you can + * show transient offline messaging or pause network-heavy tasks. + */ + public data object Disconnected : StreamNetworkState() + + /** + * A network path is currently active and considered connected. + * + * This state maps to `ConnectivityManager.NetworkCallback.onAvailable` and carries the most + * recent [StreamNetworkInfo.Snapshot], allowing callers to inspect transports, metering, or + * other network characteristics before resuming work. + * + * ### Example + * ```kotlin + * when (state) { + * is StreamNetworkState.Available -> + * logger.i { "Connected via ${state.snapshot?.transports}" } + * StreamNetworkState.Disconnected -> logger.w { "Network dropped" } + * StreamNetworkState.Unavailable -> logger.e { "No connection" } + * StreamNetworkState.Unknown -> logger.d { "Awaiting first update" } + * } + * ``` + * + * @property snapshot Latest network snapshot, or `null` if collection failed. + */ + public data class Available(val snapshot: StreamNetworkInfo.Snapshot?) : StreamNetworkState() +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt index d3f64fd..ab92108 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt @@ -17,6 +17,8 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState /** * Listener interface for Feeds socket events. @@ -46,4 +48,11 @@ public interface StreamClientListener { * @param err The error that occurred. */ public fun onError(err: Throwable) {} + + /** + * Called when the network connection changes. + * + * @param state The new network state. + */ + public fun onNetworkState(state: StreamNetworkState) {} } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index a3e2d2b..06130c9 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -15,7 +15,6 @@ */ package io.getstream.android.core.internal.client -import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger @@ -23,6 +22,7 @@ import io.getstream.android.core.api.model.StreamTypedKey.Companion.randomExecut import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamUserId import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener @@ -48,6 +48,7 @@ internal class StreamClientImpl( private val serialQueue: StreamSerialProcessingQueue, private val connectionIdHolder: StreamConnectionIdHolder, private val socketSession: StreamSocketSession, + private var mutableNetworkState: MutableStateFlow, private val mutableConnectionState: MutableStateFlow, private val logger: StreamLogger, private val subscriptionManager: StreamSubscriptionManager, @@ -64,12 +65,9 @@ internal class StreamClientImpl( override val connectionState: StateFlow get() = mutableConnectionState.asStateFlow() - private var internalNetworkInfo: MutableStateFlow = - MutableStateFlow(null) + override val networkState: StateFlow + get() = mutableNetworkState.asStateFlow() - @StreamInternalApi - override val networkInfo: StateFlow - get() = internalNetworkInfo.asStateFlow() override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) @@ -135,19 +133,34 @@ internal class StreamClientImpl( logger.v { "[connect] Network connected: $snapshot" } - internalNetworkInfo.update(snapshot) + val state = StreamNetworkState.Available(snapshot) + mutableNetworkState.update(state) + subscriptionManager.forEach { + it.onNetworkState(state) + } } override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - internalNetworkInfo.update(null) + val state = if (permanent) { + StreamNetworkState.Unavailable + } else { + StreamNetworkState.Disconnected + } + mutableNetworkState.update(state) + subscriptionManager.forEach { + it.onNetworkState(state) + } } override suspend fun onNetworkPropertiesChanged( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - internalNetworkInfo.update(snapshot) + mutableNetworkState.update(StreamNetworkState.Available(snapshot)) + subscriptionManager.forEach { + it.onNetworkState(StreamNetworkState.Available(snapshot)) + } } }, StreamSubscriptionManager.Options( diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt index 5b96b56..aacc977 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/socket/listeners/StreamListenersDefaultImplsTest.kt @@ -19,6 +19,7 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import kotlin.test.assertEquals import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch @@ -50,6 +51,7 @@ internal class StreamListenersDefaultImplsTest { val stateChannel = Channel(capacity = 1) val eventChannel = Channel(capacity = 1) val errorChannel = Channel(capacity = 1) + val networkChannel = Channel(capacity = 1) val listener = object : StreamClientListener { @@ -64,6 +66,10 @@ internal class StreamListenersDefaultImplsTest { override fun onError(err: Throwable) { errorChannel.trySend(err) } + + override fun onNetworkState(state: StreamNetworkState) { + networkChannel.trySend(state) + } } val state = StreamConnectionState.Connecting.Opening("user") From c2c3cef5e72e3b5e54ecf2374818b7c6c06da82d Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:32:35 +0200 Subject: [PATCH 19/42] Spotless --- .../connection/network/StreamNetworkState.kt | 17 ++++++++++++++++ .../socket/listeners/StreamClientListener.kt | 1 - .../core/internal/client/StreamClientImpl.kt | 20 +++++++++++-------- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt index 545728a..3c6b1ce 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/network/StreamNetworkState.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.getstream.android.core.api.model.connection.network import io.getstream.android.core.annotations.StreamInternalApi @@ -13,6 +28,7 @@ public sealed class StreamNetworkState { * surface an offline UI until a different state is received. * * ### Example + * * ```kotlin * when (state) { * StreamNetworkState.Unavailable -> showOfflineBanner("No connection available") @@ -47,6 +63,7 @@ public sealed class StreamNetworkState { * other network characteristics before resuming work. * * ### Example + * * ```kotlin * when (state) { * is StreamNetworkState.Available -> diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt index ab92108..1693c01 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/socket/listeners/StreamClientListener.kt @@ -17,7 +17,6 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState -import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo import io.getstream.android.core.api.model.connection.network.StreamNetworkState /** diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index 06130c9..f1dd2c5 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt @@ -68,7 +68,6 @@ internal class StreamClientImpl( override val networkState: StateFlow get() = mutableNetworkState.asStateFlow() - override fun subscribe(listener: StreamClientListener): Result = subscriptionManager.subscribe(listener) @@ -142,11 +141,12 @@ internal class StreamClientImpl( override suspend fun onNetworkLost(permanent: Boolean) { logger.v { "[connect] Network lost" } - val state = if (permanent) { - StreamNetworkState.Unavailable - } else { - StreamNetworkState.Disconnected - } + val state = + if (permanent) { + StreamNetworkState.Unavailable + } else { + StreamNetworkState.Disconnected + } mutableNetworkState.update(state) subscriptionManager.forEach { it.onNetworkState(state) @@ -157,9 +157,13 @@ internal class StreamClientImpl( snapshot: StreamNetworkInfo.Snapshot ) { logger.v { "[connect] Network changed: $snapshot" } - mutableNetworkState.update(StreamNetworkState.Available(snapshot)) + mutableNetworkState.update( + StreamNetworkState.Available(snapshot) + ) subscriptionManager.forEach { - it.onNetworkState(StreamNetworkState.Available(snapshot)) + it.onNetworkState( + StreamNetworkState.Available(snapshot) + ) } } }, From 3b30b033b73f073f0afbd4527d9a6a645871d231 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:42:30 +0200 Subject: [PATCH 20/42] Update network info UI in sample --- .../getstream/android/core/sample/SampleActivity.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index ec66e23..b88a4f3 100644 --- a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt +++ b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.value.StreamApiKey import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import io.getstream.android.core.api.model.value.StreamToken @@ -132,10 +133,18 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() - val networkSnapshot = streamClient.networkInfo.collectAsStateWithLifecycle() + val networkSnapshot = streamClient.networkState.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") + val networkState = networkSnapshot.value Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ConnectionStateCard(state = state.value) - NetworkInfoCard(snapshot = networkSnapshot.value) + when (networkState) { + is StreamNetworkState.Available -> { + NetworkInfoCard(snapshot = networkState.snapshot) + } + else -> { + NetworkInfoCard(snapshot = null) + } + } } } From bace61440ec8c708cb22aed351abc2d29d0c1815 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:44:03 +0200 Subject: [PATCH 21/42] Update client test --- .../internal/client/StreamClientIImplTest.kt | 115 ++++++++++++++++-- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 5bb56ed..3320a4f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -21,6 +21,8 @@ import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.log.StreamLogger import io.getstream.android.core.api.model.connection.StreamConnectedUser import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.network.StreamNetworkInfo +import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.event.StreamClientWsEvent import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId @@ -30,6 +32,8 @@ import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* import kotlinx.coroutines.CoroutineScope @@ -37,10 +41,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.bouncycastle.util.test.SimpleTest.runTest import org.junit.Assert.* import org.junit.Before import org.junit.Test +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class StreamClientIImplTest { private var userId: StreamUserId = StreamUserId.fromString("u1") @@ -53,10 +60,9 @@ class StreamClientIImplTest { private lateinit var subscriptionManager: StreamSubscriptionManager - // private lateinit var client: StreamClient - // Backing state flow for MutableStreamClientState.connectionState private lateinit var connFlow: MutableStateFlow + private lateinit var networkFlow: MutableStateFlow @Before fun setUp() { @@ -78,13 +84,17 @@ class StreamClientIImplTest { Result.success(block()) } - // Mutable client state: expose a real StateFlow that update() mutates + // Mutable client state: expose real StateFlows that update() mutates connFlow = MutableStateFlow(StreamConnectionState.Disconnected()) + networkFlow = MutableStateFlow(StreamNetworkState.Unknown) every { connectionIdHolder.clear() } returns Result.success(Unit) } - private fun createClient(scope: CoroutineScope) = + private fun createClient( + scope: CoroutineScope, + networkMonitor: StreamNetworkMonitor = mockNetworkMonitor(), + ) = StreamClientImpl( userId = userId, tokenManager = tokenManager, @@ -93,17 +103,20 @@ class StreamClientIImplTest { connectionIdHolder = connectionIdHolder, socketSession = socketSession, logger = logger, + mutableNetworkState = networkFlow, mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, - networkMonitor = - mockk(relaxed = true) { - every { start() } returns Result.success(Unit) - every { stop() } returns Result.success(Unit) - every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - }, + networkMonitor = networkMonitor, ) + private fun mockNetworkMonitor(): StreamNetworkMonitor = + mockk(relaxed = true) { + every { start() } returns Result.success(Unit) + every { stop() } returns Result.success(Unit) + every { subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) + } + @Test fun `connect short-circuits when already connected`() = runTest { backgroundScope @@ -125,7 +138,8 @@ class StreamClientIImplTest { @Test fun `disconnect performs cleanup - updates state, clears ids, cancels handle, stops processors`() = runTest { - val client = createClient(backgroundScope) + val networkMonitor = mockNetworkMonitor() + val client = createClient(backgroundScope, networkMonitor) // Make singleFlight actually run the provided block and return success coEvery { singleFlight.run(any(), any Any>()) } coAnswers { @@ -141,6 +155,13 @@ class StreamClientIImplTest { client.javaClass.getDeclaredField("handle").apply { isAccessible = true } handleField.set(client, fakeHandle) + val networkHandle = mockk(relaxed = true) + val networkHandleField = + client.javaClass + .getDeclaredField("networkMonitorHandle") + .apply { isAccessible = true } + networkHandleField.set(client, networkHandle) + every { connectionIdHolder.clear() } returns Result.success(Unit) every { socketSession.disconnect() } returns Result.success(Unit) coEvery { serialQueue.stop(any()) } returns Result.success(Unit) // default-arg path @@ -159,11 +180,83 @@ class StreamClientIImplTest { verify { tokenManager.invalidate() } coVerify { serialQueue.stop(any()) } coVerify { singleFlight.clear(true) } + verify { networkMonitor.stop() } + verify { networkHandle.cancel() } // Handle is nulled assertNull(handleField.get(client)) + assertNull(networkHandleField.get(client)) } + @Test + fun `network monitor updates state and notifies subscribers`() = runTest { + val forwardedStates = mutableListOf() + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + every { external.onNetworkState(any()) } answers + { + forwardedStates += firstArg() + } + block(external) + Result.success(Unit) + } + + val networkHandle = mockk(relaxed = true) + var capturedListener: StreamNetworkMonitorListener? = null + val networkMonitor = mockk() + every { networkMonitor.start() } returns Result.success(Unit) + every { networkMonitor.stop() } returns Result.success(Unit) + every { networkMonitor.subscribe(any(), any()) } answers + { + capturedListener = firstArg() + Result.success(networkHandle) + } + + val client = createClient(backgroundScope, networkMonitor) + + val socketHandle = mockk(relaxed = true) + every { socketSession.subscribe(any(), any()) } returns + Result.success(socketHandle) + val token = StreamToken.fromString("tok") + coEvery { tokenManager.loadIfAbsent() } returns Result.success(token) + val connectedUser = mockk(relaxed = true) + val connectedState = StreamConnectionState.Connected(connectedUser, "conn-1") + coEvery { socketSession.connect(any()) } returns Result.success(connectedState) + every { connectionIdHolder.setConnectionId("conn-1") } returns Result.success("conn-1") + + val result = client.connect() + + assertTrue(result.isSuccess) + verify(exactly = 1) { networkMonitor.subscribe(any(), any()) } + verify(exactly = 1) { networkMonitor.start() } + val listener = capturedListener ?: error("Expected network monitor listener") + + val connectedSnapshot = StreamNetworkInfo.Snapshot(transports = emptySet()) + listener.onNetworkConnected(connectedSnapshot) + assertEquals(StreamNetworkState.Available(connectedSnapshot), networkFlow.value) + + listener.onNetworkLost(permanent = false) + assertEquals(StreamNetworkState.Disconnected, networkFlow.value) + + listener.onNetworkLost(permanent = true) + assertEquals(StreamNetworkState.Unavailable, networkFlow.value) + + val updatedSnapshot = + connectedSnapshot.copy(priority = StreamNetworkInfo.PriorityHint.LATENCY) + listener.onNetworkPropertiesChanged(updatedSnapshot) + assertEquals(StreamNetworkState.Available(updatedSnapshot), networkFlow.value) + + val expectedStates = listOf( + StreamNetworkState.Available(connectedSnapshot), + StreamNetworkState.Disconnected, + StreamNetworkState.Unavailable, + StreamNetworkState.Available(updatedSnapshot), + ) + assertTrue(forwardedStates.containsAll(expectedStates)) + } + @Test fun `subscribe delegates to subscriptionManager`() = runTest { val listener = mockk(relaxed = true) From f484eb4a838d3e9e54af34f19d5ba6944199d45c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 22 Oct 2025 14:47:04 +0200 Subject: [PATCH 22/42] Spotless --- .../internal/client/StreamClientIImplTest.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt index 3320a4f..5a4466c 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -26,16 +26,17 @@ import io.getstream.android.core.api.model.connection.network.StreamNetworkState import io.getstream.android.core.api.model.event.StreamClientWsEvent import io.getstream.android.core.api.model.value.StreamToken import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.api.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager -import io.getstream.android.core.api.observers.network.StreamNetworkMonitor -import io.getstream.android.core.api.observers.network.StreamNetworkMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* +import kotlin.time.ExperimentalTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -45,7 +46,6 @@ import org.bouncycastle.util.test.SimpleTest.runTest import org.junit.Assert.* import org.junit.Before import org.junit.Test -import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) class StreamClientIImplTest { @@ -157,9 +157,9 @@ class StreamClientIImplTest { val networkHandle = mockk(relaxed = true) val networkHandleField = - client.javaClass - .getDeclaredField("networkMonitorHandle") - .apply { isAccessible = true } + client.javaClass.getDeclaredField("networkMonitorHandle").apply { + isAccessible = true + } networkHandleField.set(client, networkHandle) every { connectionIdHolder.clear() } returns Result.success(Unit) @@ -248,12 +248,13 @@ class StreamClientIImplTest { listener.onNetworkPropertiesChanged(updatedSnapshot) assertEquals(StreamNetworkState.Available(updatedSnapshot), networkFlow.value) - val expectedStates = listOf( - StreamNetworkState.Available(connectedSnapshot), - StreamNetworkState.Disconnected, - StreamNetworkState.Unavailable, - StreamNetworkState.Available(updatedSnapshot), - ) + val expectedStates = + listOf( + StreamNetworkState.Available(connectedSnapshot), + StreamNetworkState.Disconnected, + StreamNetworkState.Unavailable, + StreamNetworkState.Available(updatedSnapshot), + ) assertTrue(forwardedStates.containsAll(expectedStates)) } From 885d450875a1708e9bbccb99658dc11400e66069 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 23 Oct 2025 07:02:03 +0200 Subject: [PATCH 23/42] Move update helper in a separate class --- .../getstream/android/core/api/utils/Flows.kt | 17 ++++++++++ .../core/internal/client/StreamClientImpl.kt | 5 +-- .../android/core/api/utils/FlowsTest.kt | 32 +++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt new file mode 100644 index 0000000..93b3a79 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt @@ -0,0 +1,17 @@ +package io.getstream.android.core.api.utils + +import io.getstream.android.core.annotations.StreamInternalApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * Updates the value of the [MutableStateFlow] with the given [state]. + * Internally calls [MutableStateFlow.update] with a lambda that always returns the given [state]. + * More readable than `stateFlow.update { state }`. + * + * @param state The new value to set. + */ +@StreamInternalApi +public fun MutableStateFlow.update(state: T) { + this.update { state } +} \ No newline at end of file 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 f1dd2c5..72cf053 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 @@ -33,6 +33,7 @@ import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.api.utils.flatMap +import io.getstream.android.core.api.utils.update import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.model.ConnectUserData import kotlinx.coroutines.CoroutineScope @@ -205,8 +206,4 @@ internal class StreamClientImpl( serialQueue.stop() singleFlight.clear(true) } - - private fun MutableStateFlow.update(state: T) { - this.update { state } - } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt new file mode 100644 index 0000000..101930f --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt @@ -0,0 +1,32 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.flow.MutableStateFlow + +internal class FlowsTest { + + @Test + fun `update replaces state value`() { + val flow = MutableStateFlow(0) + + flow.update(42) + + assertEquals(42, flow.value) + } +} From 3bed61085a958030a0230e1ba08717164de87910 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 23 Oct 2025 08:14:42 +0200 Subject: [PATCH 24/42] Spotless --- .../getstream/android/core/api/utils/Flows.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt index 93b3a79..5ca964d 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt @@ -1,3 +1,18 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package io.getstream.android.core.api.utils import io.getstream.android.core.annotations.StreamInternalApi @@ -5,13 +20,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update /** - * Updates the value of the [MutableStateFlow] with the given [state]. - * Internally calls [MutableStateFlow.update] with a lambda that always returns the given [state]. - * More readable than `stateFlow.update { state }`. + * Updates the value of the [MutableStateFlow] with the given [state]. Internally calls + * [MutableStateFlow.update] with a lambda that always returns the given [state]. More readable than + * `stateFlow.update { state }`. * * @param state The new value to set. */ @StreamInternalApi public fun MutableStateFlow.update(state: T) { this.update { state } -} \ No newline at end of file +} From c1e5668ec83b167bc7740de05ca6d31c26a5bb28 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 20 Nov 2025 11:08:10 +0100 Subject: [PATCH 25/42] Connection recovery handler and monitors --- .../android/core/sample/SampleActivity.kt | 52 ++- .../core/sample/client/StreamClient.kt | 17 + gradle/libs.versions.toml | 3 + stream-android-core/build.gradle.kts | 2 + .../src/main/AndroidManifest.xml | 1 + .../android/core/api/StreamClient.kt | 62 +-- .../StreamAndroidComponentsProvider.kt | 63 ++- .../lifecycle/StreamLifecycleState.kt | 38 ++ .../api/model/connection/recovery/Recovery.kt | 50 +++ .../api/observers/StreamStartableComponent.kt | 56 +++ .../lifecycle/StreamLifecycleListener.kt | 54 +++ .../lifecycle/StreamLifecycleMonitor.kt | 65 +++ .../observers/network/StreamNetworkMonitor.kt | 37 +- .../network/StreamNetworkMonitorListener.kt | 33 +- .../core/api/processing/StreamBatcher.kt | 45 ++- .../StreamConnectionRecoveryEvaluator.kt | 64 +++ .../api/socket/monitor/StreamHealthMonitor.kt | 9 +- .../core/api/subscribe/StreamObservable.kt | 52 +++ .../subscribe/StreamSubscriptionManager.kt | 26 +- .../android/core/api/utils/Result.kt | 20 + .../core/internal/client/StreamClientImpl.kt | 248 +++++++----- .../StreamAndroidComponentsProviderImpl.kt | 4 + .../StreamNetworkAndLifeCycleMonitor.kt | 78 ++++ .../StreamNetworkAndLifecycleMonitorImpl.kt | 127 ++++++ ...treamNetworkAndLifecycleMonitorListener.kt | 34 ++ .../lifecycle/StreamLifecycleMonitorImpl.kt | 89 +++++ .../StreamConnectionRecoveryEvaluatorImpl.kt | 101 +++++ .../core/internal/subscribe/utils/ForEach.kt | 29 ++ .../core/api/StreamClientFactoryTest.kt | 13 +- .../android/core/api/utils/AlgebraTest.kt | 76 ++++ .../internal/client/StreamClientIImplTest.kt | 90 +---- ...reamConnectionRecoveryEvaluatorImplTest.kt | 378 ++++++++++++++++++ 32 files changed, 1711 insertions(+), 305 deletions(-) create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt create mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index b88a4f3..bdc2796 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 @@ -27,6 +27,7 @@ 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.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,7 +40,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import io.getstream.android.core.api.StreamClient import io.getstream.android.core.api.authentication.StreamTokenProvider -import io.getstream.android.core.api.model.connection.network.StreamNetworkState +import io.getstream.android.core.api.model.connection.StreamConnectionState 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 @@ -47,10 +48,8 @@ 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 class SampleActivity : ComponentActivity() { @@ -90,7 +89,7 @@ class SampleActivity : ComponentActivity() { ) streamClient = streamClient2 lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { streamClient?.connect() } + repeatOnLifecycle(Lifecycle.State.CREATED) { streamClient?.connect() } } enableEdgeToEdge() setContent { @@ -107,16 +106,41 @@ class SampleActivity : ComponentActivity() { ) { Greeting(name = "Android") ClientInfo(streamClient = streamClient2) + val state = streamClient?.connectionState?.collectAsStateWithLifecycle() + val buttonState = + when (state?.value) { + is StreamConnectionState.Connected -> { + Triple( + "Disconnect", + true, + { + lifecycleScope.launch { streamClient?.disconnect() } + Unit + }, + ) + } + is StreamConnectionState.Connecting -> { + Triple("Connecting", false, { Unit }) + } + else -> { + Triple( + "Connect", + true, + { + lifecycleScope.launch { streamClient?.connect() } + Unit + }, + ) + } + } + Button(onClick = buttonState.third, enabled = buttonState.second) { + Text(text = buttonState.first) + } } } } } } - - override fun onStop() { - runBlocking { streamClient?.disconnect() } - super.onStop() - } } @Composable @@ -133,18 +157,8 @@ fun GreetingPreview() { @Composable fun ClientInfo(streamClient: StreamClient) { val state = streamClient.connectionState.collectAsStateWithLifecycle() - val networkSnapshot = streamClient.networkState.collectAsStateWithLifecycle() Log.d("SampleActivity", "Client state: ${state.value}") - val networkState = networkSnapshot.value Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { ConnectionStateCard(state = state.value) - 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 baf3908..9681cee 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 @@ -27,11 +27,13 @@ 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.lifecycle.StreamLifecycleMonitor 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 import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.recovery.StreamConnectionRecoveryEvaluator import io.getstream.android.core.api.serialization.StreamEventSerialization import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.StreamWebSocketFactory @@ -108,6 +110,15 @@ fun createStreamClient( logger = logProvider.taggedLogger("SCNetworkMonitorSubscriptions") ), ) + val lifecycleMonitor = + StreamLifecycleMonitor( + logger = logProvider.taggedLogger("SCLifecycleMonitor"), + subscriptionManager = + StreamSubscriptionManager( + logger = logProvider.taggedLogger("SCLifecycleMonitorSubscriptions") + ), + lifecycle = androidComponentsProvider.lifecycle(), + ) return StreamClient( scope = scope, @@ -135,6 +146,12 @@ fun createStreamClient( override fun deserialize(raw: String): Result = Result.success(Unit) } ), + lifecycleMonitor = lifecycleMonitor, + connectionRecoveryEvaluator = + StreamConnectionRecoveryEvaluator( + logger = logProvider.taggedLogger("SCConnectionRecoveryEvaluator"), + singleFlightProcessor = singleFlight, + ), batcher = batcher, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7dc10e6..fc37008 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" appcompat = "1.7.1" kotlinxCoroutines = "1.10.2" +lifecycleRuntime = "2.9.4" lintApi = "31.12.0" material = "1.12.0" jetbrainsKotlinJvm = "2.2.0" @@ -32,6 +33,8 @@ annotationJvm = "1.9.1" [libraries] androidx-core = { module = "androidx.test:core", version.ref = "core" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycleRuntime" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleRuntime" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } diff --git a/stream-android-core/build.gradle.kts b/stream-android-core/build.gradle.kts index fa6e385..8858da6 100644 --- a/stream-android-core/build.gradle.kts +++ b/stream-android-core/build.gradle.kts @@ -82,6 +82,8 @@ dependencies { // Android implementation(libs.androidx.annotation.jvm) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.process) // Network implementation(libs.moshi) diff --git a/stream-android-core/src/main/AndroidManifest.xml b/stream-android-core/src/main/AndroidManifest.xml index 48e9a84..e03bcec 100644 --- a/stream-android-core/src/main/AndroidManifest.xml +++ b/stream-android-core/src/main/AndroidManifest.xml @@ -3,5 +3,6 @@ + \ 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 14ee9fd..25fbb84 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 @@ -15,6 +15,7 @@ */ package io.getstream.android.core.api +import android.annotation.SuppressLint import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.authentication.StreamTokenManager import io.getstream.android.core.api.authentication.StreamTokenProvider @@ -25,25 +26,29 @@ 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.lifecycle.StreamLifecycleState 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.lifecycle.StreamLifecycleMonitor 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 import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.recovery.StreamConnectionRecoveryEvaluator import io.getstream.android.core.api.serialization.StreamEventSerialization import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.StreamWebSocket import io.getstream.android.core.api.socket.StreamWebSocketFactory import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.socket.monitor.StreamHealthMonitor -import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamObservable import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.internal.client.StreamClientImpl +import io.getstream.android.core.internal.observers.StreamNetworkAndLifeCycleMonitor import io.getstream.android.core.internal.serialization.StreamCompositeEventSerializationImpl import io.getstream.android.core.internal.serialization.StreamCompositeMoshiJsonSerialization import io.getstream.android.core.internal.serialization.StreamMoshiJsonSerializationImpl @@ -99,7 +104,7 @@ import kotlinx.coroutines.flow.StateFlow * ``` */ @StreamInternalApi -public interface StreamClient { +public interface StreamClient : StreamObservable { /** * Read-only, hot state holder for this client. * @@ -109,16 +114,6 @@ 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. * @@ -142,13 +137,6 @@ public interface StreamClient { * - Throws [kotlinx.coroutines.CancellationException] if the awaiting coroutine is cancelled. */ public suspend fun disconnect(): Result - - /** - * Subscribes to client events and state - * - * @param listener The listener to subscribe. - */ - public fun subscribe(listener: StreamClientListener): Result } /** @@ -196,21 +184,25 @@ public interface StreamClient { * @param apiKey The API key. * @param userId The user ID. * @param wsUrl The WebSocket URL. + * @param products Stream product codes (for feature gates / telemetry) negotiated with the socket. * @param clientInfoHeader The client info header. + * @param clientSubscriptionManager Manages socket-level listeners registered via [StreamClient]. * @param tokenProvider The token provider. - * @param scope The coroutine scope. - * @param logProvider The logger provider. - * @param clientSubscriptionManager The client subscription manager. * @param tokenManager The token manager. * @param singleFlight The single-flight processor. * @param serialQueue The serial processing queue. - * @param httpConfig The HTTP configuration. * @param retryProcessor The retry processor. + * @param scope The coroutine scope powering internal work (usually `SupervisorJob + Dispatcher`). * @param connectionIdHolder The connection ID holder. * @param socketFactory The WebSocket factory. - * @param healthMonitor The health monitor. * @param batcher The WebSocket event batcher. + * @param healthMonitor The health monitor. + * @param networkMonitor Tracks device connectivity and feeds connection recovery. + * @param httpConfig Optional HTTP client customization. + * @param serializationConfig Composite JSON / event serialization configuration. + * @param logProvider The logger provider. */ +@SuppressLint("ExposeAsStateFlow") @StreamInternalApi public fun StreamClient( // Client config @@ -235,6 +227,8 @@ public fun StreamClient( // Monitoring healthMonitor: StreamHealthMonitor, networkMonitor: StreamNetworkMonitor, + lifecycleMonitor: StreamLifecycleMonitor, + connectionRecoveryEvaluator: StreamConnectionRecoveryEvaluator, // Http httpConfig: StreamHttpConfig? = null, // Serialization @@ -283,6 +277,20 @@ public fun StreamClient( configuredInterceptors.forEach { httpBuilder.addInterceptor(it) } } + val networkAndLifeCycleMonitor = + StreamNetworkAndLifeCycleMonitor( + logger = logProvider.taggedLogger("SCNetworkAndLifecycleMonitor"), + networkMonitor = networkMonitor, + lifecycleMonitor = lifecycleMonitor, + mutableNetworkState = MutableStateFlow(StreamNetworkState.Unknown), + mutableLifecycleState = MutableStateFlow(StreamLifecycleState.Unknown), + subscriptionManager = + StreamSubscriptionManager( + logger = logProvider.taggedLogger("SCNLMonitorSubscriptions") + ), + ) + + val mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle) return StreamClientImpl( userId = userId, scope = clientScope, @@ -291,10 +299,10 @@ public fun StreamClient( serialQueue = serialQueue, connectionIdHolder = connectionIdHolder, logger = clientLogger, - mutableNetworkState = MutableStateFlow(StreamNetworkState.Unknown), - mutableConnectionState = MutableStateFlow(StreamConnectionState.Idle), + mutableConnectionState = mutableConnectionState, subscriptionManager = clientSubscriptionManager, - networkMonitor = networkMonitor, + networkAndLifeCycleMonitor = networkAndLifeCycleMonitor, + connectionRecoveryEvaluator = connectionRecoveryEvaluator, 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 index 6a692d6..e2e1248 100644 --- 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 @@ -19,14 +19,23 @@ import android.content.Context import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.telephony.TelephonyManager +import androidx.lifecycle.Lifecycle import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.internal.components.StreamAndroidComponentsProviderImpl /** - * Provides access to Android system services. + * Facade over the Android system services required by the core SDK. * - * 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. + * Abstracting the access behind this interface allows Stream components to operate in tests, + * host-apps with custom dependency wiring, or alternative runtime environments. + * + * ### Typical usage + * + * ```kotlin + * val components = StreamAndroidComponentsProvider(context) + * val connectivity = components.connectivityManager().getOrNull() + * val lifecycle = components.lifecycle() + * ``` */ @StreamInternalApi public interface StreamAndroidComponentsProvider { @@ -34,26 +43,62 @@ 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. + * ### Example + * + * ```kotlin + * val connectivity: ConnectivityManager = + * components.connectivityManager().getOrElse { throwable -> + * logger.e(throwable) { "Connectivity unavailable" } + * throw throwable + * } + * ``` + * + * @return [Result.success] with the manager, or [Result.failure] when the service is missing. */ 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. + * ### Example + * + * ```kotlin + * val isWifiEnabled = components.wifiManager() + * .map { wifi -> wifi.isWifiEnabled } + * .getOrDefault(false) + * ``` + * + * @return [Result.success] with the manager, or [Result.failure] when the service is missing. */ 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. + * ### Example + * + * ```kotlin + * val networkType = components.telephonyManager() + * .map { telephony -> telephony.dataNetworkType } + * .getOrElse { TelephonyManager.NETWORK_TYPE_UNKNOWN } + * ``` + * + * @return [Result.success] with the manager, or [Result.failure] when the service is missing. */ public fun telephonyManager(): Result + + /** + * Retrieves the [Lifecycle] for the application. + * + * ### Example + * + * ```kotlin + * components.lifecycle().addObserver(lifecycleObserver) + * ``` + * + * @return The process-level [Lifecycle]. + */ + public fun lifecycle(): Lifecycle } /** diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt new file mode 100644 index 0000000..d8d24a3 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt @@ -0,0 +1,38 @@ +/* + * 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.lifecycle + +import io.getstream.android.core.annotations.StreamInternalApi + +/** + * Process-wide lifecycle snapshot used by the connection recovery layer. + * + * Implementations surface coarse-grained lifecycle boundaries (foreground/background) that + * influence reconnection heuristics. `Unknown` is emitted while the lifecycle source is still being + * resolved. + */ +@StreamInternalApi +public sealed class StreamLifecycleState { + + /** The lifecycle source has not yet reported a definitive state. */ + public object Unknown : StreamLifecycleState() + + /** The app is considered foregrounded (user-visible). */ + public object Foreground : StreamLifecycleState() + + /** The app moved to background and is no longer user-visible. */ + public object Background : StreamLifecycleState() +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt new file mode 100644 index 0000000..67faad3 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt @@ -0,0 +1,50 @@ +/* + * 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.recovery + +import io.getstream.android.core.annotations.StreamInternalApi + +/** + * Represents a connection recovery decision. + * + * @param T The type of the data that is passed to the recovery decision. + */ +@StreamInternalApi +public sealed class Recovery { + + /** + * The connection should be reconnected. + * + * @param why The reason for the reconnect. + * @param T The type of the data that is passed to the recovery decision. + */ + public data class Connect(val why: T) : Recovery() + + /** + * The connection should be disconnected. + * + * @param why The reason for the disconnect. + * @param T The type of the data that is passed to the recovery decision. + */ + public data class Disconnect(val why: T) : Recovery() + + /** + * An error occurred while evaluating the recovery strategy. + * + * @property error The error that occurred. + */ + public data class Error(val error: Throwable) : Recovery() +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt new file mode 100644 index 0000000..78c3507 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt @@ -0,0 +1,56 @@ +/* + * 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 + +import io.getstream.android.core.annotations.StreamInternalApi + +/** + * Minimal lifecycle contract for components that can be started and stopped on demand. + * + * Consumers typically call [start] during initialization and [stop] when tearing down resources or + * responding to lifecycle events. + */ +@StreamInternalApi +public interface StreamStartableComponent { + + /** + * Starts the component. + * + * ### Example + * + * ```kotlin + * subscriptionManager.start() + * .onFailure { cause -> logger.e(cause) { "Unable to start monitor" } } + * ``` + * + * @return `Result.success(Unit)` on success; `Result.failure(cause)` when startup fails. + */ + public fun start(): Result + + /** + * Stops the component. + * + * ### Example + * + * ```kotlin + * subscriptionManager.stop() + * .onFailure { cause -> logger.w(cause) { "Unable to stop monitor" } } + * ``` + * + * @return `Result.success(Unit)` on success; `Result.failure(cause)` when shutdown fails. + */ + public fun stop(): Result +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt new file mode 100644 index 0000000..7817e51 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt @@ -0,0 +1,54 @@ +/* + * 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.lifecycle + +import io.getstream.android.core.annotations.StreamInternalApi + +/** + * Callbacks mirroring host lifecycle events emitted by [StreamLifecycleMonitor]. + * + * Implementers typically register via `StreamLifecycleMonitor.subscribe(listener)` and override the + * relevant callbacks to react to lifecycle transitions. + */ +@StreamInternalApi +public interface StreamLifecycleListener { + + /** + * Called when the app moves to the foreground. + * + * ### Example + * + * ```kotlin + * override fun onForeground() { + * logger.i { "App moved to foreground" } + * } + * ``` + */ + public fun onForeground() {} + + /** + * Called when the app moves to the background. + * + * ### Example + * + * ```kotlin + * override fun onBackground() { + * logger.i { "App moved to background" } + * } + * ``` + */ + public fun onBackground() {} +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt new file mode 100644 index 0000000..f1eebe2 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt @@ -0,0 +1,65 @@ +/* + * 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.lifecycle + +import androidx.lifecycle.Lifecycle +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.observers.StreamStartableComponent +import io.getstream.android.core.api.subscribe.StreamObservable +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.lifecycle.StreamLifecycleMonitorImpl + +/** + * Aggregates lifecycle events from the host environment and re-emits them to registered listeners. + * + * ### Example + * + * ```kotlin + * val subscription = lifecycleMonitor.subscribe(MyLifecycleListener()) + * lifecycleMonitor.start() + * // … later … + * subscription.getOrThrow().cancel() + * lifecycleMonitor.stop() + * ``` + */ +@StreamInternalApi +public interface StreamLifecycleMonitor : + StreamStartableComponent, StreamObservable { + + /** + * Returns the current lifecycle state. + * + * @return The current lifecycle state. + */ + public fun getCurrentState(): StreamLifecycleState +} + +/** + * Creates a [StreamLifecycleMonitor] instance. + * + * @param logger The logger to use for logging. + * @param subscriptionManager The subscription manager to use for managing listeners. + * @param lifecycle The host lifecycle to observe. + * @return A new [StreamLifecycleMonitor] instance. + */ +@StreamInternalApi +public fun StreamLifecycleMonitor( + logger: StreamLogger, + lifecycle: Lifecycle, + subscriptionManager: StreamSubscriptionManager, +): StreamLifecycleMonitor = StreamLifecycleMonitorImpl(logger, subscriptionManager, lifecycle) 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 index 3f0e857..1b34c0a 100644 --- 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 @@ -20,7 +20,8 @@ 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.observers.StreamStartableComponent +import io.getstream.android.core.api.subscribe.StreamObservable 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 @@ -30,23 +31,22 @@ 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. + * Implementations are expected to be lifecycle-aware and safe to invoke from any thread. + * + * ### Example + * + * ```kotlin + * val subscription = monitor.subscribe(listener).getOrThrow() + * monitor.start() + * + * // ... later ... + * subscription.cancel() + * monitor.stop() + * ``` */ @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 -} +public interface StreamNetworkMonitor : + StreamStartableComponent, StreamObservable /** * Creates a [StreamNetworkMonitor] instance. @@ -54,7 +54,10 @@ public interface StreamNetworkMonitor { * @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. + * @param wifiManager The Wi-Fi manager to use for accessing Wi-Fi information. + * @param telephonyManager The telephony manager to use for accessing cellular information. + * @param connectivityManager The connectivity manager to use for accessing network information. + * @return A new [StreamNetworkMonitor] instance. */ @StreamInternalApi public fun StreamNetworkMonitor( diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt index 040674b..db8f759 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/network/StreamNetworkMonitorListener.kt @@ -18,16 +18,20 @@ 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. - */ +/** Receives network availability and capability updates from [StreamNetworkMonitor]. */ @StreamInternalApi public interface StreamNetworkMonitorListener { /** * Called when the network is connected. * + * ### Example + * + * ```kotlin + * override suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) { + * logger.i { "Network connected: ${snapshot?.type}" } + * } + * ``` + * * @param snapshot A [StreamNetworkInfo.Snapshot] describing the newly connected network. */ public suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) {} @@ -35,7 +39,16 @@ public interface StreamNetworkMonitorListener { /** * Called when the network is lost. * - * @param permanent True if the network is lost permanently (e.g., due to airplane mode). + * ### Example + * + * ```kotlin + * override suspend fun onNetworkLost(permanent: Boolean) { + * retryScheduler.pause() + * if (permanent) alertUser() + * } + * ``` + * + * @param permanent True if the network is lost permanently (e.g., onUnavailable called). */ public suspend fun onNetworkLost(permanent: Boolean = false) {} @@ -43,6 +56,14 @@ public interface StreamNetworkMonitorListener { * Called when the properties of the currently connected network change while the connection * remains active. * + * ### Example + * + * ```kotlin + * override suspend fun onNetworkPropertiesChanged(snapshot: StreamNetworkInfo.Snapshot) { + * metrics.recordThroughput(snapshot.linkBandwidthDownKbps) + * } + * ``` + * * @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/processing/StreamBatcher.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/processing/StreamBatcher.kt index ac70437..4178fea 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/processing/StreamBatcher.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/processing/StreamBatcher.kt @@ -49,6 +49,13 @@ public interface StreamBatcher { /** * Starts the processor if it's not already running. * + * ### Example + * + * ```kotlin + * batcher.start() + * .onFailure { cause -> logger.e(cause) { "Unable to start batcher" } } + * ``` + * * @return `Result.success(Unit)` if the processor was started successfully; otherwise a * `Result.failure(cause)` describing why the start failed. */ @@ -66,6 +73,14 @@ public interface StreamBatcher { * - **Int**: the number of items emitted in this batch (equals `batch.size`). * * Calling this method replaces any previously registered handler. + * + * ### Example + * + * ```kotlin + * batcher.onBatch { batch, _, _ -> + * batch.forEach { event -> handle(event) } + * } + * ``` */ public fun onBatch(handler: suspend (List, Long, Int) -> Unit) @@ -80,16 +95,29 @@ public interface StreamBatcher { * Implementations may start processing lazily on the first call. * * @param item The item to enqueue. + * + * ### Example + * + * ```kotlin + * batcher.enqueue(event) + * .onFailure { cause -> logger.w(cause) { "Dropped event" } } + * ``` */ public suspend fun enqueue(item: T): Result /** * Enqueues a single item for debounced processing. * - * This function **does not suspend** and returns `false` if the underlying buffer is full - * (bounded-capacity implementations). It returns a [Result]: - * - `true` if the item was accepted, - * - `false` if the processor is closed/stopped or cannot accept the item. + * This non-suspending variant returns `false` when the underlying buffer is full + * (bounded-capacity implementations). + * + * ### Example + * + * ```kotlin + * if (!batcher.offer(event)) { + * metrics.incrementDropped() + * } + * ``` */ public fun offer(item: T): Boolean @@ -100,6 +128,13 @@ public interface StreamBatcher { * calls should fail with `Result.failure`. Multiple calls to [stop] are allowed and should be * **idempotent**. * + * ### Example + * + * ```kotlin + * batcher.stop() + * .onFailure { cause -> logger.w(cause) { "Unable to stop batcher" } } + * ``` + * * @return `Result.success(Unit)` on a successful stop; `Result.failure(cause)` if stopping * failed. */ @@ -127,7 +162,7 @@ public fun StreamBatcher( autoStart: Boolean = true, channelCapacity: Int = Channel.UNLIMITED, ): StreamBatcher = - StreamBatcherImpl( + StreamBatcherImpl( scope = scope, batchSize = batchSize, initialDelayMs = initialDelayMs, diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt new file mode 100644 index 0000000..7674020 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt @@ -0,0 +1,64 @@ +/* + * 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.recovery + +import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState +import io.getstream.android.core.api.model.connection.recovery.Recovery +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.internal.recovery.StreamConnectionRecoveryEvaluatorImpl + +/** + * Evaluates the current connection state and network availability to determine whether a reconnect + * or disconnect action should be taken. + * + * Implementations are responsible for defining the heuristics that determine when a reconnect or + * disconnect should be triggered based on the current connection state and network availability. + */ +@StreamInternalApi +public interface StreamConnectionRecoveryEvaluator { + /** + * Evaluates the current connection state and network availability to determine whether a + * reconnect or disconnect action should be taken. + * + * @param lifecycleState The current lifecycle state. + * @param networkState The current network state. + * @return A [io.getstream.android.core.api.model.connection.recovery.Recovery] indicating the + * action to take, or `null` if no action is needed. + */ + public suspend fun evaluate( + connectionState: StreamConnectionState, + lifecycleState: StreamLifecycleState, + networkState: StreamNetworkState, + ): Result +} + +/** + * Creates a new [StreamConnectionRecoveryEvaluator] instance. + * + * @param logger The logger to use for logging. + * @param singleFlightProcessor The single-flight processor to use for managing concurrent requests. + * @return A new [StreamConnectionRecoveryEvaluator] instance. + */ +@StreamInternalApi +public fun StreamConnectionRecoveryEvaluator( + logger: StreamLogger, + singleFlightProcessor: StreamSingleFlightProcessor, +): StreamConnectionRecoveryEvaluator = + StreamConnectionRecoveryEvaluatorImpl(logger, singleFlightProcessor) 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 95658a8..c4b9a4b 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 @@ -17,6 +17,7 @@ package io.getstream.android.core.api.socket.monitor import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.observers.StreamStartableComponent import io.getstream.android.core.internal.socket.monitor.StreamHealthMonitorImpl import kotlin.time.ExperimentalTime import kotlinx.coroutines.CoroutineScope @@ -32,7 +33,7 @@ import kotlinx.coroutines.CoroutineScope * - Start and stop the monitor as needed. */ @StreamInternalApi -public interface StreamHealthMonitor { +public interface StreamHealthMonitor : StreamStartableComponent { /** * Registers a callback that is invoked at every heartbeat interval. * @@ -60,12 +61,6 @@ public interface StreamHealthMonitor { * is alive and healthy. Resets the liveness timer. */ public fun acknowledgeHeartbeat() - - /** Starts the health monitor, beginning the heartbeat and liveness checks. */ - public fun start(): Result - - /** Stops the health monitor, halting heartbeat and liveness checks. */ - public fun stop(): Result } /** diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt new file mode 100644 index 0000000..af1d1d7 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.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.subscribe + +import io.getstream.android.core.annotations.StreamPublishedApi +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options + +/** + * Interface for observables that can be subscribed to. + * + * @param T The type of the listener. + */ +@StreamPublishedApi +public interface StreamObservable { + + /** + * Adds [listener] to the active set and returns a handle that can later be used to unregister + * it. + * + * The returned [StreamSubscription] is idempotent: + * - Calling `cancel()` multiple times is safe. + * - After `cancel()` completes, the listener is guaranteed to be absent from subsequent + * [forEach] iterations. + * + * Retention: + * - When [options.retention] is [Options.Retention.AUTO_REMOVE] (default), you can omit calling + * `cancel()`. Once your code drops all references to the listener, it is removed + * automatically and will no longer receive events. + * - When [options.retention] is [Options.Retention.KEEP_UNTIL_CANCELLED], you must call + * `cancel()` (or invoke [clear]) to stop events. + * + * @param listener The listener to register. + * @param options Retention options; defaults to automatic removal when the listener is no + * longer referenced. + * @return `Result.success(StreamSubscription)` when the listener was added; + * `Result.failure(Throwable)` if the operation cannot be completed (e.g., capacity limits). + */ + public fun subscribe(listener: T, options: Options = Options()): Result +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamSubscriptionManager.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamSubscriptionManager.kt index 3d2b0a8..cbb9530 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamSubscriptionManager.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamSubscriptionManager.kt @@ -38,7 +38,7 @@ import io.getstream.android.core.internal.subscribe.StreamSubscriptionManagerImp * @param T the listener type (often a function type, e.g. `(Event) -> Unit`) */ @StreamInternalApi -public interface StreamSubscriptionManager { +public interface StreamSubscriptionManager : StreamObservable { /** * Subscription behavior options. * @@ -62,30 +62,6 @@ public interface StreamSubscriptionManager { } } - /** - * Adds [listener] to the active set and returns a handle that can later be used to unregister - * it. - * - * The returned [StreamSubscription] is idempotent: - * - Calling `cancel()` multiple times is safe. - * - After `cancel()` completes, the listener is guaranteed to be absent from subsequent - * [forEach] iterations. - * - * Retention: - * - When [options.retention] is [Options.Retention.AUTO_REMOVE] (default), you can omit calling - * `cancel()`. Once your code drops all references to the listener, it is removed - * automatically and will no longer receive events. - * - When [options.retention] is [Options.Retention.KEEP_UNTIL_CANCELLED], you must call - * `cancel()` (or invoke [clear]) to stop events. - * - * @param listener The listener to register. - * @param options Retention options; defaults to automatic removal when the listener is no - * longer referenced. - * @return `Result.success(StreamSubscription)` when the listener was added; - * `Result.failure(Throwable)` if the operation cannot be completed (e.g., capacity limits). - */ - public fun subscribe(listener: T, options: Options = Options()): Result - /** * Removes **all** listeners and releases related resources. * diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt index d4db062..b032c2d 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt @@ -16,6 +16,7 @@ package io.getstream.android.core.api.utils import io.getstream.android.core.annotations.StreamInternalApi +import io.getstream.android.core.api.model.exceptions.StreamEndpointException import kotlin.coroutines.cancellation.CancellationException /** @@ -35,3 +36,22 @@ import kotlin.coroutines.cancellation.CancellationException @StreamInternalApi public inline fun Result.flatMap(transform: (T) -> Result): Result = fold(onSuccess = { transform(it) }, onFailure = { Result.failure(it) }) + +/** + * Invokes the given [function] if this [Result] is a failure and the error is a token error. + * + * @param function A function to invoke if this [Result] is a failure and the error is a token + * error. + * @return This [Result] if it is a success, or the result of [function] if it is a failure and the + * error is a token error. + */ +@StreamInternalApi +public suspend fun Result.onTokenError( + function: suspend (exception: StreamEndpointException, code: Int) -> Result +): Result = onFailure { + if (it is StreamEndpointException && it.apiError?.code?.div(10) == 4) { + function(it, it.apiError.code) + } else { + this + } +} 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 72cf053..b42a836 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,26 +21,29 @@ 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.lifecycle.StreamLifecycleState import io.getstream.android.core.api.model.connection.network.StreamNetworkState +import io.getstream.android.core.api.model.connection.recovery.Recovery +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.recovery.StreamConnectionRecoveryEvaluator import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.api.utils.flatMap +import io.getstream.android.core.api.utils.onTokenError import io.getstream.android.core.api.utils.update +import io.getstream.android.core.internal.observers.StreamNetworkAndLifeCycleMonitor +import io.getstream.android.core.internal.observers.StreamNetworkAndLifecycleMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.getstream.android.core.internal.socket.model.ConnectUserData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update internal class StreamClientImpl( private val userId: StreamUserId, @@ -49,11 +52,11 @@ internal class StreamClientImpl( private val serialQueue: StreamSerialProcessingQueue, private val connectionIdHolder: StreamConnectionIdHolder, private val socketSession: StreamSocketSession, - private var mutableNetworkState: MutableStateFlow, + private val networkAndLifeCycleMonitor: StreamNetworkAndLifeCycleMonitor, + private val connectionRecoveryEvaluator: StreamConnectionRecoveryEvaluator, private val mutableConnectionState: MutableStateFlow, private val logger: StreamLogger, private val subscriptionManager: StreamSubscriptionManager, - private val networkMonitor: StreamNetworkMonitor, private val scope: CoroutineScope, ) : StreamClient { companion object { @@ -61,17 +64,11 @@ internal class StreamClientImpl( private val disconnectKey = randomExecutionKey() } - private var handle: StreamSubscription? = null - private var networkMonitorHandle: StreamSubscription? = null + private var socketSessionHandle: StreamSubscription? = null + private var networkAndLifecycleMonitorHandle: 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) - override suspend fun connect(): Result = singleFlight.run(connectKey) { val currentState = connectionState.value @@ -79,104 +76,71 @@ internal class StreamClientImpl( logger.w { "[connect] Already connected!" } return@run currentState.connectedUser } - if (handle == null) { + + val retentionOptions = + StreamSubscriptionManager.Options( + retention = StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED + ) + + if (socketSessionHandle == null) { logger.v { "[connect] Subscribing to socket events]" } - handle = - socketSession - .subscribe( - object : StreamClientListener { - override fun onState(state: StreamConnectionState) { - logger.v { "[client#onState]: $state" } - mutableConnectionState.update(state) - subscriptionManager.forEach { it.onState(state) } - } - - override fun onEvent(event: Any) { - logger.v { "[client#onEvent]: $event" } - subscriptionManager.forEach { it.onEvent(event) } - } - }, - StreamSubscriptionManager.Options( - retention = - StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED - ), - ) + val clientListener = + object : StreamClientListener { + override fun onState(state: StreamConnectionState) { + logger.v { "[client#onState]: $state" } + mutableConnectionState.update(state) + subscriptionManager.forEach { it.onState(state) } + } + + override fun onEvent(event: Any) { + logger.v { "[client#onEvent]: $event" } + subscriptionManager.forEach { it.onEvent(event) } + } + + override fun onError(err: Throwable) { + logger.e(err) { "[client#onError]: $err" } + subscriptionManager.forEach { it.onError(err) } + } + } + socketSessionHandle = + socketSession.subscribe(clientListener, retentionOptions).getOrThrow() + } + + if (networkAndLifecycleMonitorHandle == null) { + logger.v { "[connect] Setup network and lifecycle monitor callback" } + val networkAndLifecycleMonitorListener = + object : StreamNetworkAndLifecycleMonitorListener { + override suspend fun onNetworkAndLifecycleState( + networkState: StreamNetworkState, + lifecycleState: StreamLifecycleState, + ) { + val connectionState = mutableConnectionState.value + logger.v { + """networkAndLifecycleMonitor#onNetworkAndLifecycleState] + network=$networkState, + lifecycle=$lifecycleState + connectionState=$connectionState""" + } + val recovery = + connectionRecoveryEvaluator.evaluate( + connectionState, + lifecycleState, + networkState, + ) + recoveryEffect(recovery) + } + } + networkAndLifecycleMonitorHandle = + networkAndLifeCycleMonitor + .subscribe(networkAndLifecycleMonitorListener, retentionOptions) .getOrThrow() } tokenManager .loadIfAbsent() - .flatMap { token -> - socketSession.connect( - ConnectUserData( - userId = userId.rawValue, - token = token.rawValue, - name = null, - image = null, - invisible = false, - language = null, - custom = null, - ) - ) - } + .flatMap { token -> connectSocketSession(token) } .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 @@ -188,6 +152,9 @@ internal class StreamClientImpl( Result.failure(error) }, ) + .flatMap { connectedUser -> + networkAndLifeCycleMonitor.start().map { connectedUser } + } .getOrThrow() } @@ -197,13 +164,76 @@ internal class StreamClientImpl( mutableConnectionState.update(StreamConnectionState.Disconnected()) connectionIdHolder.clear() socketSession.disconnect() - handle?.cancel() - networkMonitor.stop() - networkMonitorHandle?.cancel() - networkMonitorHandle = null - handle = null + socketSessionHandle?.cancel() + networkAndLifeCycleMonitor.stop() + networkAndLifecycleMonitorHandle?.cancel() + networkAndLifecycleMonitorHandle = null + socketSessionHandle = null tokenManager.invalidate() serialQueue.stop() singleFlight.clear(true) } + + override fun subscribe( + listener: StreamClientListener, + options: StreamSubscriptionManager.Options, + ): Result = subscriptionManager.subscribe(listener, options) + + private suspend fun connectSocketSession( + token: StreamToken + ): Result { + val data = + ConnectUserData( + userId = userId.rawValue, + token = token.rawValue, + name = null, + image = null, + invisible = false, + language = null, + custom = null, + ) + return socketSession.connect(data).onTokenError { error, code -> + logger.e(error) { "Token error: $code" } + tokenManager.invalidate() + tokenManager.refresh().flatMap { newToken -> + // One retry once with new token + socketSession.connect(data.copy(token = newToken.rawValue)) + } + } + } + + private suspend fun recoveryEffect(recovery: Result) { + recovery.fold( + onSuccess = { recovery -> + when (recovery) { + is Recovery.Connect<*> -> { + logger.v { "[recovery] Connecting: $recovery" } + connect().notifyFailure(subscriptionManager) + } + + is Recovery.Disconnect<*> -> { + logger.v { "[recovery] Disconnecting: $recovery" } + socketSession.disconnect().notifyFailure(subscriptionManager) + } + + is Recovery.Error -> { + logger.e(recovery.error) { "[recovery] Error: ${recovery.error.message}" } + subscriptionManager.forEach { it.onError(recovery.error) } + } + + null -> { + logger.v { "[recovery] No action" } + } + } + }, + onFailure = { error -> + logger.e(error) { "[recovery] Error: ${error.message}" } + subscriptionManager.forEach { it.onError(error) } + }, + ) + } + + private fun Result.notifyFailure( + subscriptionManager: StreamSubscriptionManager + ) = onFailure { error -> subscriptionManager.forEach { it.onError(error) } } } 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 index 9cce078..2b1ba84 100644 --- 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 @@ -21,6 +21,8 @@ import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.Build import android.telephony.TelephonyManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import io.getstream.android.core.api.components.StreamAndroidComponentsProvider internal class StreamAndroidComponentsProviderImpl(context: Context) : @@ -52,4 +54,6 @@ internal class StreamAndroidComponentsProviderImpl(context: Context) : applicationContext.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager } } + + override fun lifecycle(): Lifecycle = ProcessLifecycleOwner.get().lifecycle } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt new file mode 100644 index 0000000..cf814af --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt @@ -0,0 +1,78 @@ +/* + * 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 + +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState +import io.getstream.android.core.api.observers.StreamStartableComponent +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor +import io.getstream.android.core.api.observers.network.StreamNetworkMonitor +import io.getstream.android.core.api.subscribe.StreamObservable +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Coordinates lifecycle and network signals to make connection recovery decisions. + * + * ### Responsibilities + * - Bridges callbacks from [StreamNetworkMonitor] and [StreamLifecycleMonitor] into hot state flows + * exposed as [networkState] and [lifecycleState]. + * - Notifies registered [StreamConnectionRecoveryListener]s when reconnect/teardown actions should + * occur. + * - Implements [StreamStartableComponent] so callers can hook into their own lifecycle. + * + * ### Usage + * + * ```kotlin + * val subscription = connectionRecoveryManager.subscribe(listener).getOrThrow() + * connectionRecoveryManager.start() + * // … later … + * subscription.cancel() + * connectionRecoveryManager.stop() + * ``` + */ +internal interface StreamNetworkAndLifeCycleMonitor : + StreamStartableComponent, StreamObservable {} + +/** + * Creates a [StreamNetworkAndLifeCycleMonitor] instance. + * + * @param logger The logger to use for logging. + * @param lifecycleMonitor The lifecycle monitor to use for accessing lifecycle information. + * @param networkMonitor The network monitor to use for accessing network information. + * @param mutableNetworkState The mutable network state to use for accessing network information. + * @param mutableLifecycleState The mutable lifecycle state to use for accessing lifecycle + * information. + * @param subscriptionManager The subscription manager to use for managing listeners. + * @return A new [StreamNetworkAndLifeCycleMonitor] instance. + */ +internal fun StreamNetworkAndLifeCycleMonitor( + logger: StreamLogger, + lifecycleMonitor: StreamLifecycleMonitor, + networkMonitor: StreamNetworkMonitor, + mutableNetworkState: MutableStateFlow, + mutableLifecycleState: MutableStateFlow, + subscriptionManager: StreamSubscriptionManager, +): StreamNetworkAndLifeCycleMonitor = + StreamNetworkAndLifecycleMonitorImpl( + logger = logger, + networkMonitor = networkMonitor, + lifecycleMonitor = lifecycleMonitor, + mutableNetworkState = mutableNetworkState, + mutableLifecycleState = mutableLifecycleState, + subscriptionManager = subscriptionManager, + ) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt new file mode 100644 index 0000000..6c26ad4 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.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 + +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +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.observers.lifecycle.StreamLifecycleListener +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor +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 io.getstream.android.core.api.utils.flatMap +import io.getstream.android.core.api.utils.times +import io.getstream.android.core.api.utils.update +import io.getstream.android.core.internal.subscribe.utils.forEachSuspend +import kotlinx.coroutines.flow.MutableStateFlow + +internal class StreamNetworkAndLifecycleMonitorImpl( + private val logger: StreamLogger, + private val networkMonitor: StreamNetworkMonitor, + private val lifecycleMonitor: StreamLifecycleMonitor, + private val mutableNetworkState: MutableStateFlow, + private val mutableLifecycleState: MutableStateFlow, + private val subscriptionManager: + StreamSubscriptionManager, +) : StreamNetworkAndLifeCycleMonitor { + private var networkHandle: StreamSubscription? = null + private var lifecycleHandle: StreamSubscription? = null + private val lifecycleListener = + object : StreamLifecycleListener { + + override fun onForeground() { + logger.v { "Lifecycle foregrounded" } + val lifecycleState = StreamLifecycleState.Foreground + val networkState = mutableNetworkState.value + mutableLifecycleState.update(lifecycleState) + subscriptionManager.forEachSuspend { + it.onNetworkAndLifecycleState(networkState, lifecycleState) + } + } + + override fun onBackground() { + logger.v { "Lifecycle backgrounded" } + val lifecycleState = StreamLifecycleState.Background + val networkState = mutableNetworkState.value + mutableLifecycleState.update(lifecycleState) + subscriptionManager.forEachSuspend { + it.onNetworkAndLifecycleState(networkState, lifecycleState) + } + } + } + private val networkMonitorListener = + object : StreamNetworkMonitorListener { + override suspend fun onNetworkConnected(snapshot: StreamNetworkInfo.Snapshot?) { + logger.v { "Network connected: $snapshot" } + val state = StreamNetworkState.Available(snapshot) + mutableNetworkState.update(state) + val lifecycleState = mutableLifecycleState.value + subscriptionManager.forEachSuspend { + it.onNetworkAndLifecycleState(state, lifecycleState) + } + } + + override suspend fun onNetworkLost(permanent: Boolean) { + logger.v { "Network lost" } + val state = + if (permanent) { + StreamNetworkState.Unavailable + } else { + StreamNetworkState.Disconnected + } + mutableNetworkState.update(state) + val lifecycleState = mutableLifecycleState.value + subscriptionManager.forEachSuspend { + it.onNetworkAndLifecycleState(state, lifecycleState) + } + } + } + + override fun start(): Result { + val networkStart = + networkMonitor + .start() + .flatMap { networkMonitor.subscribe(networkMonitorListener) } + .also { result -> networkHandle = result.getOrNull() } + val lifecycleStart = + lifecycleMonitor + .start() + .flatMap { lifecycleMonitor.subscribe(lifecycleListener) } + .also { result -> lifecycleHandle = result.getOrNull() } + + mutableLifecycleState.update(lifecycleMonitor.getCurrentState()) + return (networkStart * lifecycleStart).flatMap { Result.success(Unit) } + } + + override fun stop(): Result { + networkHandle?.cancel() + lifecycleHandle?.cancel() + networkHandle = null + lifecycleHandle = null + mutableNetworkState.update(StreamNetworkState.Unknown) + mutableLifecycleState.update(StreamLifecycleState.Unknown) + subscriptionManager.clear() + return (lifecycleMonitor.stop() * networkMonitor.stop()).flatMap { Result.success(Unit) } + } + + override fun subscribe( + listener: StreamNetworkAndLifecycleMonitorListener, + options: StreamSubscriptionManager.Options, + ): Result = subscriptionManager.subscribe(listener, options) +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt new file mode 100644 index 0000000..0c32965 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt @@ -0,0 +1,34 @@ +/* + * 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 + +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState + +/** Listener interface for receiving network and lifecycle state updates. */ +internal interface StreamNetworkAndLifecycleMonitorListener { + + /** + * Called when the network or lifecycle state changes. + * + * @param networkState The new network state. + * @param lifecycleState The new lifecycle state. + */ + suspend fun onNetworkAndLifecycleState( + networkState: StreamNetworkState, + lifecycleState: StreamLifecycleState, + ) +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt new file mode 100644 index 0000000..2a88490 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt @@ -0,0 +1,89 @@ +/* + * 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.lifecycle + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleListener +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.ExperimentalCoroutinesApi + +internal class StreamLifecycleMonitorImpl( + private val logger: StreamLogger, + private val subscriptionManager: StreamSubscriptionManager, + private val lifecycle: Lifecycle, +) : StreamLifecycleMonitor, DefaultLifecycleObserver { + + private val started = AtomicBoolean(false) + + override fun subscribe( + listener: StreamLifecycleListener, + options: StreamSubscriptionManager.Options, + ): Result = subscriptionManager.subscribe(listener, options) + + override fun start(): Result = runCatching { + if (!started.compareAndSet(false, true)) { + return@runCatching + } + lifecycle.addObserver(this) + } + + override fun stop(): Result = runCatching { + if (!started.compareAndSet(true, false)) { + return@runCatching + } + lifecycle.removeObserver(this) + } + + override fun onResume(owner: LifecycleOwner) { + notifyListeners { it.onForeground() } + } + + override fun onPause(owner: LifecycleOwner) { + notifyListeners { it.onBackground() } + } + + private fun notifyListeners(block: (StreamLifecycleListener) -> Unit) { + subscriptionManager + .forEach { listener -> + runCatching { block(listener) } + .onFailure { throwable -> + logger.e(throwable) { + "StreamLifecycleListener block() failed when notifying" + } + } + } + .onFailure { throwable -> + logger.e(throwable) { "Failed to iterate lifecycle listeners" } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getCurrentState(): StreamLifecycleState = + when (lifecycle.currentState) { + Lifecycle.State.INITIALIZED -> StreamLifecycleState.Unknown + Lifecycle.State.DESTROYED -> StreamLifecycleState.Background + Lifecycle.State.CREATED -> StreamLifecycleState.Background + Lifecycle.State.STARTED -> StreamLifecycleState.Background + Lifecycle.State.RESUMED -> StreamLifecycleState.Foreground + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt new file mode 100644 index 0000000..13d2c6a --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt @@ -0,0 +1,101 @@ +/* + * 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.recovery + +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.StreamTypedKey.Companion.asStreamTypedKey +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +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.connection.recovery.Recovery +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.recovery.StreamConnectionRecoveryEvaluator + +internal class StreamConnectionRecoveryEvaluatorImpl( + private val logger: StreamLogger, + private val singleFlightProcessor: StreamSingleFlightProcessor, +) : StreamConnectionRecoveryEvaluator { + + companion object { + private val evaluateKey = "ev".asStreamTypedKey() + } + + private var hasConnectedBefore: Boolean = false + private var lastLifecycleState: StreamLifecycleState = StreamLifecycleState.Unknown + private var lastNetworkState: StreamNetworkState = StreamNetworkState.Unknown + private var lastNetworkSnapshot: StreamNetworkInfo.Snapshot? = null + private var lastConnectionState: StreamConnectionState = StreamConnectionState.Idle + + override suspend fun evaluate( + connectionState: StreamConnectionState, + lifecycleState: StreamLifecycleState, + networkState: StreamNetworkState, + ): Result = + singleFlightProcessor.run(evaluateKey) { + logger.v { + "[evaluate] connectionState=$connectionState, lifecycleState=$lifecycleState, networkState=$networkState" + } + + val isConnected = connectionState is StreamConnectionState.Connected + val isConnecting = connectionState is StreamConnectionState.Connecting + val isDisconnected = + connectionState is StreamConnectionState.Disconnected || + connectionState is StreamConnectionState.Idle + val previousLifecycle = lastLifecycleState + val previousNetwork = lastNetworkState + val networkAvailable = networkState is StreamNetworkState.Available + val networkBecameAvailable = + networkAvailable && previousNetwork !is StreamNetworkState.Available + val lifecycleForeground = lifecycleState == StreamLifecycleState.Foreground + val returningToForeground = + previousLifecycle == StreamLifecycleState.Background && lifecycleForeground + + val shouldDisconnect = + (isConnected || isConnecting) && + (!networkAvailable || lifecycleState == StreamLifecycleState.Background) + + val shouldConnect = + hasConnectedBefore && + isDisconnected && + lifecycleForeground && + (networkBecameAvailable || returningToForeground && networkAvailable) + + val connectSnapshot = + when (networkState) { + is StreamNetworkState.Available -> networkState.snapshot + else -> lastNetworkSnapshot + } + + val result = + when { + shouldConnect -> Recovery.Connect(connectSnapshot) + shouldDisconnect -> Recovery.Disconnect(networkState) + else -> null + } + + if (isConnected) { + hasConnectedBefore = true + } + lastConnectionState = connectionState + lastLifecycleState = lifecycleState + lastNetworkState = networkState + if (networkState is StreamNetworkState.Available) { + lastNetworkSnapshot = networkState.snapshot + } + result + } +} diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt new file mode 100644 index 0000000..3176c82 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt @@ -0,0 +1,29 @@ +/* + * 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.subscribe.utils + +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import kotlinx.coroutines.runBlocking + +/** + * Iterates over all listeners, invoking [block] for each one. This is a convenience wrapper over + * [StreamSubscriptionManager#forEach] that allows suspending [block]s. + * + * @see StreamSubscriptionManager#forEach + */ +internal fun StreamSubscriptionManager.forEachSuspend(block: suspend (T) -> Unit) { + this.forEach { runBlocking { block(it) } } +} 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 8e5c4be..cc89771 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 @@ -30,10 +30,13 @@ 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.lifecycle.StreamLifecycleMonitor +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 import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.recovery.StreamConnectionRecoveryEvaluator import io.getstream.android.core.api.serialization.StreamEventSerialization import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.StreamWebSocketFactory @@ -97,6 +100,9 @@ internal class StreamClientFactoryTest { val socketFactory: StreamWebSocketFactory, val healthMonitor: StreamHealthMonitor, val batcher: StreamBatcher, + val lifecycleMonitor: StreamLifecycleMonitor, + val networkMonitor: StreamNetworkMonitor, + val connectionRecoveryEvaluator: StreamConnectionRecoveryEvaluator, ) private fun createClient( @@ -127,6 +133,9 @@ internal class StreamClientFactoryTest { socketFactory = mockk(relaxed = true), healthMonitor = mockk(relaxed = true), batcher = mockk(relaxed = true), + lifecycleMonitor = mockk(relaxed = true), + networkMonitor = mockk(relaxed = true), + connectionRecoveryEvaluator = mockk(relaxed = true), ) val client = @@ -150,7 +159,9 @@ internal class StreamClientFactoryTest { httpConfig = httpConfig, serializationConfig = serializationConfig, logProvider = logProvider, - networkMonitor = mockk(relaxed = true), + networkMonitor = deps.networkMonitor, + lifecycleMonitor = deps.lifecycleMonitor, + connectionRecoveryEvaluator = deps.connectionRecoveryEvaluator, ) return client to deps diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt index cf8fe8f..f582fed 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/AlgebraTest.kt @@ -117,4 +117,80 @@ class AlgebraTest { val exception = combined.exceptionOrNull() as StreamAggregateException assertEquals(listOf(failure, failure2), exception.causes) } + + @Test + fun `result pair times result value propagates left failure`() { + val failure = IllegalStateException("pair failed") + val left = Result.failure>(failure) + val right = Result.success(3.0) + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `result pair times result value propagates right failure`() { + val left = Result.success(1 to "two") + val failure = IllegalArgumentException("value failed") + val right = Result.failure(failure) + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `result pair times result value aggregates failures`() { + val leftFailure = IllegalStateException("pair boom") + val rightFailure = IllegalArgumentException("value boom") + val left = Result.failure>(leftFailure) + val right = Result.failure(rightFailure) + + val combined = left * right + + assertTrue(combined.isFailure) + val aggregate = combined.exceptionOrNull() as StreamAggregateException + assertEquals(listOf(leftFailure, rightFailure), aggregate.causes) + } + + @Test + fun `result value times result pair propagates left failure`() { + val failure = IllegalStateException("value failed") + val left = Result.failure(failure) + val right = Result.success(1 to "two") + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `result value times result pair propagates right failure`() { + val left = Result.success(true) + val failure = IllegalArgumentException("pair failed") + val right = Result.failure>(failure) + + val combined = left * right + + assertTrue(combined.isFailure) + assertSame(failure, combined.exceptionOrNull()) + } + + @Test + fun `result value times result pair aggregates failures`() { + val leftFailure = IllegalStateException("value boom") + val rightFailure = IllegalArgumentException("pair boom") + val left = Result.failure(leftFailure) + val right = Result.failure>(rightFailure) + + val combined = left * right + + assertTrue(combined.isFailure) + val aggregate = combined.exceptionOrNull() as StreamAggregateException + assertEquals(listOf(leftFailure, rightFailure), aggregate.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 5a4466c..5349a72 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,19 +21,17 @@ 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 import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.internal.observers.StreamNetworkAndLifeCycleMonitor import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* import kotlin.time.ExperimentalTime @@ -93,7 +91,7 @@ class StreamClientIImplTest { private fun createClient( scope: CoroutineScope, - networkMonitor: StreamNetworkMonitor = mockNetworkMonitor(), + networkAndLifeCycleMonitor: StreamNetworkAndLifeCycleMonitor = mockNetworkMonitor(), ) = StreamClientImpl( userId = userId, @@ -103,14 +101,14 @@ class StreamClientIImplTest { connectionIdHolder = connectionIdHolder, socketSession = socketSession, logger = logger, - mutableNetworkState = networkFlow, mutableConnectionState = connFlow, scope = scope, subscriptionManager = subscriptionManager, - networkMonitor = networkMonitor, + networkAndLifeCycleMonitor = networkAndLifeCycleMonitor, + connectionRecoveryEvaluator = mockk(relaxed = true), ) - private fun mockNetworkMonitor(): StreamNetworkMonitor = + private fun mockNetworkMonitor(): StreamNetworkAndLifeCycleMonitor = mockk(relaxed = true) { every { start() } returns Result.success(Unit) every { stop() } returns Result.success(Unit) @@ -152,12 +150,14 @@ class StreamClientIImplTest { // Pretend we already have a live subscription handle inside the client val fakeHandle = mockk(relaxed = true) val handleField = - client.javaClass.getDeclaredField("handle").apply { isAccessible = true } + client.javaClass.getDeclaredField("socketSessionHandle").apply { + isAccessible = true + } handleField.set(client, fakeHandle) val networkHandle = mockk(relaxed = true) val networkHandleField = - client.javaClass.getDeclaredField("networkMonitorHandle").apply { + client.javaClass.getDeclaredField("networkAndLifecycleMonitorHandle").apply { isAccessible = true } networkHandleField.set(client, networkHandle) @@ -188,76 +188,6 @@ class StreamClientIImplTest { 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) @@ -297,7 +227,7 @@ class StreamClientIImplTest { val connectedState = StreamConnectionState.Connected(connectedUser, "conn-1") coEvery { socketSession.connect(any()) } returns Result.success(connectedState) - every { connectionIdHolder.setConnectionId("conn-1") } returns Result.success("Unit") + every { connectionIdHolder.setConnectionId("conn-1") } returns Result.success("conn-1") val result = client.connect() diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt new file mode 100644 index 0000000..e90cb0b --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt @@ -0,0 +1,378 @@ +/* + * 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.recovery + +import io.getstream.android.core.api.log.StreamLogger +import io.getstream.android.core.api.model.StreamTypedKey +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.lifecycle.StreamLifecycleState +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.connection.recovery.Recovery +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import java.util.Date +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalTime::class) +class StreamConnectionRecoveryEvaluatorImplTest { + + @Test + fun `reconnects when network returns while foreground`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + val newSnapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + .also { assertNull(it) } + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + .also { assertIs>(it) } + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(newSnapshot), + ) + .getOrThrow() + + val connect = assertIs>(recovery) + assertEquals(newSnapshot, connect.why) + } + + @Test + fun `reconnects immediately when returning foreground with available network`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + val newSnapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Background, + networkState = available(snapshot), + ) + .getOrThrow() + .also { assertIs>(it) } + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(newSnapshot), + ) + .getOrThrow() + + val connect = assertIs>(recovery) + assertEquals(newSnapshot, connect.why) + } + + @Test + fun `waits for network after foregrounding if offline`() = runTest { + val evaluator = evaluator() + val initialSnapshot = StreamNetworkInfo.Snapshot() + val restoredSnapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(initialSnapshot), + ) + .getOrThrow() + .also { + // Do nothing + assertNull(it) + } + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Background, + networkState = available(initialSnapshot), + ) + .getOrThrow() + .also { assertIs>(it) } + + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + .also { assertNull(it) } + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(restoredSnapshot), + ) + .getOrThrow() + + val connect = assertIs>(recovery) + assertEquals(restoredSnapshot, connect.why) + } + + @Test + fun `reconnects when network returns without lifecycle change`() = runTest { + val evaluator = evaluator() + val initialSnapshot = StreamNetworkInfo.Snapshot() + val restoredSnapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(initialSnapshot), + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + .also { assertNull(it) } + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(restoredSnapshot), + ) + .getOrThrow() + + val connect = assertIs>(recovery) + assertEquals(restoredSnapshot, connect.why) + } + + @Test + fun `does not connect before the first successful connection`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + + assertNull(recovery) + } + + @Test + fun `stays idle when returning foreground while already reconnecting`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + val networkLostSnapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Background, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Connecting.Opening(TEST_USER_ID), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(networkLostSnapshot), + ) + .getOrThrow() + + assertNull(recovery) + } + + @Test + fun `does not reconnect while background even if network returns`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Background, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Disconnected(), + lifecycleState = StreamLifecycleState.Background, + networkState = available(snapshot), + ) + .getOrThrow() + + assertNull(recovery) + } + + @Test + fun `ignores reconnect signal while already connecting`() = runTest { + val evaluator = evaluator() + val snapshot = StreamNetworkInfo.Snapshot() + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(snapshot), + ) + .getOrThrow() + + evaluator + .evaluate( + connectionState = connectedState(), + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Disconnected, + ) + .getOrThrow() + + val recovery = + evaluator + .evaluate( + connectionState = StreamConnectionState.Connecting.Opening(TEST_USER_ID), + lifecycleState = StreamLifecycleState.Foreground, + networkState = available(StreamNetworkInfo.Snapshot()), + ) + .getOrThrow() + + assertNull(recovery) + } + + private fun evaluator(): StreamConnectionRecoveryEvaluatorImpl = + StreamConnectionRecoveryEvaluatorImpl(NoopLogger, ImmediateSingleFlightProcessor()) + + private fun connectedState(): StreamConnectionState.Connected = + StreamConnectionState.Connected(testUser(), TEST_CONNECTION_ID) + + private fun testUser(): StreamConnectedUser = + StreamConnectedUser( + createdAt = Date(0L), + id = TEST_USER_ID, + language = "en", + role = "user", + updatedAt = Date(0L), + teams = emptyList(), + ) + + private fun available(snapshot: StreamNetworkInfo.Snapshot): StreamNetworkState.Available = + StreamNetworkState.Available(snapshot) + + private object NoopLogger : StreamLogger { + override fun log( + level: StreamLogger.LogLevel, + throwable: Throwable?, + message: () -> String, + ) { + // no-op + } + } + + private class ImmediateSingleFlightProcessor : StreamSingleFlightProcessor { + override suspend fun run(key: StreamTypedKey, block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (t: Throwable) { + Result.failure(t) + } + + override fun has(key: StreamTypedKey): Boolean = false + + override fun cancel(key: StreamTypedKey): Result = Result.success(Unit) + + override fun clear(cancelRunning: Boolean): Result = Result.success(Unit) + + override fun stop(): Result = Result.success(Unit) + } + + private companion object { + private const val TEST_USER_ID = "user-id" + private const val TEST_CONNECTION_ID = "connection-id" + } +} From 08363ed352b7ece0c1a5a9ce427eb99ffa2e4ed6 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 10:41:29 +0100 Subject: [PATCH 26/42] Add more tests --- .../lifecycle/StreamLifecycleMonitorTest.kt | 116 ++++++++++++++++++ ...mConnectionRecoveryEvaluatorFactoryTest.kt | 53 ++++++++ ...StreamSubscriptionManagerExtensionsTest.kt | 32 +++++ .../android/core/testing/TestLogger.kt | 9 ++ 4 files changed, 210 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt new file mode 100644 index 0000000..8e77465 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt @@ -0,0 +1,116 @@ +package io.getstream.android.core.api.observers.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options.Retention +import io.getstream.android.core.testing.TestLogger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StreamLifecycleMonitorTest { + + @Test + fun `start forwards lifecycle events to listeners`() { + val owner = TestLifecycleOwner() + val monitor = StreamLifecycleMonitor(TestLogger, owner.lifecycle, newSubscriptionManager()) + val received = mutableListOf() + + val listener = + object : StreamLifecycleListener { + override fun onForeground() { + received += "fg" + } + + override fun onBackground() { + received += "bg" + } + } + val noopListener = object : StreamLifecycleListener {} + + val options = Options(retention = Retention.KEEP_UNTIL_CANCELLED) + val subscription = monitor.subscribe(listener, options).getOrThrow() + monitor.subscribe(noopListener, options).getOrThrow() + + monitor.start().getOrThrow() + + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + + assertEquals(listOf("fg", "bg"), received) + + monitor.stop().getOrThrow() + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + assertEquals(2, received.size) + + subscription.cancel() + } + + @Test + fun `getCurrentState reflects lifecycle state`() { + val expectations = + listOf( + Lifecycle.State.INITIALIZED to StreamLifecycleState.Unknown, + Lifecycle.State.CREATED to StreamLifecycleState.Background, + Lifecycle.State.STARTED to StreamLifecycleState.Background, + Lifecycle.State.RESUMED to StreamLifecycleState.Foreground, + Lifecycle.State.DESTROYED to StreamLifecycleState.Background, + ) + + expectations.forEach { (state, expected) -> + val owner = TestLifecycleOwner() + val monitor = StreamLifecycleMonitor(TestLogger, owner.lifecycle, newSubscriptionManager()) + + when (state) { + Lifecycle.State.INITIALIZED -> Unit + Lifecycle.State.CREATED -> owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Lifecycle.State.STARTED -> { + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + Lifecycle.State.RESUMED -> { + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + Lifecycle.State.DESTROYED -> { + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + } + + assertEquals(expected, monitor.getCurrentState()) + } + } + + @Test + fun `lifecycle listener default callbacks are no-op`() { + val listener = object : StreamLifecycleListener {} + + listener.onForeground() + listener.onBackground() + + assertTrue(true) + } + + private fun newSubscriptionManager(): StreamSubscriptionManager = + StreamSubscriptionManager(TestLogger) + + private class TestLifecycleOwner : LifecycleOwner { + val registry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = registry + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt new file mode 100644 index 0000000..9607df3 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt @@ -0,0 +1,53 @@ +package io.getstream.android.core.api.recovery + +import io.getstream.android.core.api.model.StreamTypedKey +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +import io.getstream.android.core.api.model.connection.network.StreamNetworkState +import io.getstream.android.core.api.model.connection.recovery.Recovery +import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.testing.TestLogger +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest + +class StreamConnectionRecoveryEvaluatorFactoryTest { + + @Test + fun `factory wires a working evaluator`() = runTest { + val evaluator = StreamConnectionRecoveryEvaluator(TestLogger, ImmediateSingleFlightProcessor()) + + val result = + evaluator + .evaluate( + connectionState = StreamConnectionState.Idle, + lifecycleState = StreamLifecycleState.Foreground, + networkState = StreamNetworkState.Unknown, + ) + .getOrThrow() + + assertNull(result) + assertIs(evaluator) + } + + private class ImmediateSingleFlightProcessor : StreamSingleFlightProcessor { + override suspend fun run( + key: StreamTypedKey, + block: suspend () -> T, + ): Result = + try { + Result.success(block()) + } catch (t: Throwable) { + Result.failure(t) + } + + override fun has(key: StreamTypedKey): Boolean = false + + override fun cancel(key: StreamTypedKey): Result = Result.success(Unit) + + override fun clear(cancelRunning: Boolean): Result = Result.success(Unit) + + override fun stop(): Result = Result.success(Unit) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt new file mode 100644 index 0000000..ba3b298 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt @@ -0,0 +1,32 @@ +package io.getstream.android.core.internal.subscribe.utils + +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options.Retention +import io.getstream.android.core.testing.TestLogger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.yield + +class StreamSubscriptionManagerExtensionsTest { + + @Test + fun `forEachSuspend invokes suspending block for each listener`() { + val manager = StreamSubscriptionManager<(String) -> Unit>(TestLogger) + val recorded = mutableListOf() + val options = Options(retention = Retention.KEEP_UNTIL_CANCELLED) + + val first = { value: String -> recorded += "first:$value" } + val second = { value: String -> recorded += "second:$value" } + + manager.subscribe(first, options).getOrThrow() + manager.subscribe(second, options).getOrThrow() + + manager.forEachSuspend { listener -> + yield() + listener("event") + } + + assertEquals(setOf("first:event", "second:event"), recorded.toSet()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt b/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt new file mode 100644 index 0000000..6188352 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt @@ -0,0 +1,9 @@ +package io.getstream.android.core.testing + +import io.getstream.android.core.api.log.StreamLogger + +internal object TestLogger : StreamLogger { + override fun log(level: StreamLogger.LogLevel, throwable: Throwable?, message: () -> String) { + // Swallow logs during tests + } +} From 836e5740f6e746f3cfec683d0c57abeade5e9727 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 10:48:06 +0100 Subject: [PATCH 27/42] Remove onNetworkState method --- .../core/api/socket/listeners/StreamClientListener.kt | 7 ------- .../socket/listeners/StreamListenersDefaultImplsTest.kt | 4 ---- 2 files changed, 11 deletions(-) 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 1693c01..c0f59d6 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 @@ -47,11 +47,4 @@ 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/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 aacc977..52b1abc 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 @@ -66,10 +66,6 @@ internal class StreamListenersDefaultImplsTest { override fun onError(err: Throwable) { errorChannel.trySend(err) } - - override fun onNetworkState(state: StreamNetworkState) { - networkChannel.trySend(state) - } } val state = StreamConnectionState.Connecting.Opening("user") From 5a0b34e695e31832b4191e7475c0dd4d9d3bf353 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 11:01:34 +0100 Subject: [PATCH 28/42] Merged devop and reformatted --- .../android/core/sample/SampleActivity.kt | 1 + .../core/sample/client/StreamClient.kt | 1 + .../android/core/api/StreamClient.kt | 1 + .../StreamAndroidComponentsProvider.kt | 1 + .../lifecycle/StreamLifecycleState.kt | 1 + .../api/model/connection/recovery/Recovery.kt | 1 + .../api/observers/StreamStartableComponent.kt | 1 + .../lifecycle/StreamLifecycleListener.kt | 1 + .../lifecycle/StreamLifecycleMonitor.kt | 1 + .../observers/network/StreamNetworkMonitor.kt | 1 + .../StreamConnectionRecoveryEvaluator.kt | 1 + .../api/socket/monitor/StreamHealthMonitor.kt | 1 + .../core/api/subscribe/StreamObservable.kt | 1 + .../getstream/android/core/api/utils/Flows.kt | 1 + .../core/internal/client/StreamClientImpl.kt | 3 +- .../StreamAndroidComponentsProviderImpl.kt | 1 + .../StreamNetworkAndLifeCycleMonitor.kt | 1 + .../StreamNetworkAndLifecycleMonitorImpl.kt | 1 + ...treamNetworkAndLifecycleMonitorListener.kt | 1 + .../lifecycle/StreamLifecycleMonitorImpl.kt | 1 + .../StreamConnectionRecoveryEvaluatorImpl.kt | 1 + .../core/internal/subscribe/utils/ForEach.kt | 1 + .../core/api/StreamClientFactoryTest.kt | 1 + .../lifecycle/StreamLifecycleMonitorTest.kt | 25 +- ...mConnectionRecoveryEvaluatorFactoryTest.kt | 25 +- .../android/core/api/utils/FlowsTest.kt | 1 + .../internal/client/StreamClientIImplTest.kt | 1 + ...treamNetworkAndLifecycleMonitorImplTest.kt | 248 ++++++++++++++++++ ...reamConnectionRecoveryEvaluatorImplTest.kt | 1 + ...StreamSubscriptionManagerExtensionsTest.kt | 16 ++ .../android/core/testing/TestLogger.kt | 16 ++ 31 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index bdc2796..f6ebf5c 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.sample import android.os.Build 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 9681cee..bd4941f 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.sample.client import android.content.Context 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 25fbb84..f67201f 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api import android.annotation.SuppressLint 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 index e2e1248..727efc1 100644 --- 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 @@ -13,6 +13,7 @@ * 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 diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt index d8d24a3..0849680 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/lifecycle/StreamLifecycleState.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.model.connection.lifecycle import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt index 67faad3..34dfb6b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/connection/recovery/Recovery.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.model.connection.recovery import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt index 78c3507..8f7ac77 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/StreamStartableComponent.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.observers import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt index 7817e51..ac9ac46 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleListener.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.observers.lifecycle import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt index f1eebe2..b3608db 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitor.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.observers.lifecycle import androidx.lifecycle.Lifecycle 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 index 1b34c0a..3249b39 100644 --- 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 @@ -13,6 +13,7 @@ * 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 diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt index 7674020..8ed7d76 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluator.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.recovery import io.getstream.android.core.annotations.StreamInternalApi 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 c4b9a4b..739ec2f 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.socket.monitor import io.getstream.android.core.annotations.StreamInternalApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt index af1d1d7..6f95554 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/subscribe/StreamObservable.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.subscribe import io.getstream.android.core.annotations.StreamPublishedApi diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt index 5ca964d..4bc8a74 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Flows.kt @@ -13,6 +13,7 @@ * 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 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 b42a836..a14e523 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.client import io.getstream.android.core.api.StreamClient @@ -117,7 +118,7 @@ internal class StreamClientImpl( val connectionState = mutableConnectionState.value logger.v { """networkAndLifecycleMonitor#onNetworkAndLifecycleState] - network=$networkState, + network=$networkState, lifecycle=$lifecycleState connectionState=$connectionState""" } 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 index 2b1ba84..5b76b43 100644 --- 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 @@ -13,6 +13,7 @@ * 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 diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt index cf814af..354a438 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.observers import io.getstream.android.core.api.log.StreamLogger diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt index 6c26ad4..29e38e8 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.observers import io.getstream.android.core.api.log.StreamLogger diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt index 0c32965..cc64279 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.observers import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt index 2a88490..37fdc7c 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/lifecycle/StreamLifecycleMonitorImpl.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.observers.lifecycle import androidx.lifecycle.DefaultLifecycleObserver diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt index 13d2c6a..dacddbe 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.recovery import io.getstream.android.core.api.log.StreamLogger diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt index 3176c82..9be7279 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.subscribe.utils import io.getstream.android.core.api.subscribe.StreamSubscriptionManager 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 cc89771..47002dc 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + @file:OptIn(StreamInternalApi::class) package io.getstream.android.core.api diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt index 8e77465..49fdc1a 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/observers/lifecycle/StreamLifecycleMonitorTest.kt @@ -1,5 +1,22 @@ +/* + * 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.lifecycle +import android.os.Build import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry @@ -13,8 +30,10 @@ 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.UPSIDE_DOWN_CAKE]) class StreamLifecycleMonitorTest { @Test @@ -66,11 +85,13 @@ class StreamLifecycleMonitorTest { expectations.forEach { (state, expected) -> val owner = TestLifecycleOwner() - val monitor = StreamLifecycleMonitor(TestLogger, owner.lifecycle, newSubscriptionManager()) + val monitor = + StreamLifecycleMonitor(TestLogger, owner.lifecycle, newSubscriptionManager()) when (state) { Lifecycle.State.INITIALIZED -> Unit - Lifecycle.State.CREATED -> owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + Lifecycle.State.CREATED -> + owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) Lifecycle.State.STARTED -> { owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) owner.registry.handleLifecycleEvent(Lifecycle.Event.ON_START) diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt index 9607df3..3fbd08c 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/recovery/StreamConnectionRecoveryEvaluatorFactoryTest.kt @@ -1,10 +1,25 @@ +/* + * 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.recovery import io.getstream.android.core.api.model.StreamTypedKey import io.getstream.android.core.api.model.connection.StreamConnectionState import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState import io.getstream.android.core.api.model.connection.network.StreamNetworkState -import io.getstream.android.core.api.model.connection.recovery.Recovery import io.getstream.android.core.api.processing.StreamSingleFlightProcessor import io.getstream.android.core.testing.TestLogger import kotlin.test.Test @@ -16,7 +31,8 @@ class StreamConnectionRecoveryEvaluatorFactoryTest { @Test fun `factory wires a working evaluator`() = runTest { - val evaluator = StreamConnectionRecoveryEvaluator(TestLogger, ImmediateSingleFlightProcessor()) + val evaluator = + StreamConnectionRecoveryEvaluator(TestLogger, ImmediateSingleFlightProcessor()) val result = evaluator @@ -32,10 +48,7 @@ class StreamConnectionRecoveryEvaluatorFactoryTest { } private class ImmediateSingleFlightProcessor : StreamSingleFlightProcessor { - override suspend fun run( - key: StreamTypedKey, - block: suspend () -> T, - ): Result = + override suspend fun run(key: StreamTypedKey, block: suspend () -> T): Result = try { Result.success(block()) } catch (t: Throwable) { diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt index 101930f..2b49af5 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/utils/FlowsTest.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.api.utils import kotlin.test.Test 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 5349a72..27ba1e6 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 @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + @file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) package io.getstream.android.core.internal.client diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt new file mode 100644 index 0000000..e5845bb --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt @@ -0,0 +1,248 @@ +/* + * 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 + +import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleState +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.observers.lifecycle.StreamLifecycleListener +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor +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 io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options.Retention +import io.getstream.android.core.testing.TestLogger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking + +@OptIn(ExperimentalTime::class) +class StreamNetworkAndLifecycleMonitorImplTest { + + private val networkState = MutableStateFlow(StreamNetworkState.Unknown) + private val lifecycleState = + MutableStateFlow(StreamLifecycleState.Unknown) + private val downstreamSubscriptionManager = + StreamSubscriptionManager(TestLogger) + private val fakeLifecycleMonitor = FakeLifecycleMonitor() + private val fakeNetworkMonitor = FakeNetworkMonitor() + private val monitor: StreamNetworkAndLifeCycleMonitor = + StreamNetworkAndLifeCycleMonitor( + logger = TestLogger, + lifecycleMonitor = fakeLifecycleMonitor, + networkMonitor = fakeNetworkMonitor, + mutableLifecycleState = lifecycleState, + mutableNetworkState = networkState, + subscriptionManager = downstreamSubscriptionManager, + ) + private val options = Options(retention = Retention.KEEP_UNTIL_CANCELLED) + + @Test + fun `network callbacks update state and notify listeners`() { + val events = mutableListOf>() + monitor + .subscribe( + object : StreamNetworkAndLifecycleMonitorListener { + override suspend fun onNetworkAndLifecycleState( + networkState: StreamNetworkState, + lifecycleState: StreamLifecycleState, + ) { + events += networkState to lifecycleState + } + }, + options, + ) + .getOrThrow() + + monitor.start().getOrThrow() + + val snapshot = StreamNetworkInfo.Snapshot() + fakeNetworkMonitor.emitConnected(snapshot) + assertEquals(StreamNetworkState.Available(snapshot), networkState.value) + + fakeNetworkMonitor.emitLost(permanent = false) + assertTrue(networkState.value is StreamNetworkState.Disconnected) + + fakeNetworkMonitor.emitLost(permanent = true) + assertEquals(StreamNetworkState.Unavailable, networkState.value) + + assertEquals( + listOf( + StreamNetworkState.Available(snapshot) to lifecycleState.value, + StreamNetworkState.Disconnected to lifecycleState.value, + StreamNetworkState.Unavailable to lifecycleState.value, + ), + events, + ) + } + + @Test + fun `lifecycle callbacks update state and notify listeners`() { + val events = mutableListOf>() + monitor + .subscribe( + object : StreamNetworkAndLifecycleMonitorListener { + override suspend fun onNetworkAndLifecycleState( + networkState: StreamNetworkState, + lifecycleState: StreamLifecycleState, + ) { + events += networkState to lifecycleState + } + }, + options, + ) + .getOrThrow() + + monitor.start().getOrThrow() + + fakeLifecycleMonitor.emitForeground() + assertEquals(StreamLifecycleState.Foreground, lifecycleState.value) + + fakeLifecycleMonitor.emitBackground() + assertEquals(StreamLifecycleState.Background, lifecycleState.value) + + assertEquals( + listOf( + networkState.value to StreamLifecycleState.Foreground, + networkState.value to StreamLifecycleState.Background, + ), + events, + ) + } + + @Test + fun `stop resets states and detaches listeners`() { + val events = mutableListOf>() + monitor + .subscribe( + object : StreamNetworkAndLifecycleMonitorListener { + override suspend fun onNetworkAndLifecycleState( + networkState: StreamNetworkState, + lifecycleState: StreamLifecycleState, + ) { + events += networkState to lifecycleState + } + }, + options, + ) + .getOrThrow() + + monitor.start().getOrThrow() + fakeLifecycleMonitor.emitForeground() + fakeNetworkMonitor.emitConnected(StreamNetworkInfo.Snapshot()) + + monitor.stop().getOrThrow() + + assertEquals(StreamLifecycleState.Unknown, lifecycleState.value) + assertEquals(StreamNetworkState.Unknown, networkState.value) + assertEquals(1, fakeLifecycleMonitor.stopCalls) + assertEquals(1, fakeNetworkMonitor.stopCalls) + assertFalse(fakeLifecycleMonitor.hasListeners()) + assertFalse(fakeNetworkMonitor.hasListener()) + + val previousEventCount = events.size + fakeLifecycleMonitor.emitForeground() + fakeNetworkMonitor.emitConnected(StreamNetworkInfo.Snapshot()) + assertEquals(previousEventCount, events.size) + } + + private class FakeLifecycleMonitor : StreamLifecycleMonitor { + private val listeners = mutableSetOf() + var stopCalls: Int = 0 + private set + + override fun start(): Result = Result.success(Unit) + + override fun stop(): Result = + Result.success(Unit).also { + stopCalls++ + listeners.clear() + } + + override fun subscribe( + listener: StreamLifecycleListener, + options: Options, + ): Result { + listeners += listener + return Result.success( + object : StreamSubscription { + override fun cancel() { + listeners -= listener + } + } + ) + } + + override fun getCurrentState(): StreamLifecycleState = StreamLifecycleState.Unknown + + fun emitForeground() { + listeners.forEach { it.onForeground() } + } + + fun emitBackground() { + listeners.forEach { it.onBackground() } + } + + fun hasListeners(): Boolean = listeners.isNotEmpty() + } + + private class FakeNetworkMonitor : StreamNetworkMonitor { + private var listener: StreamNetworkMonitorListener? = null + var stopCalls: Int = 0 + private set + + override fun start(): Result = Result.success(Unit) + + override fun stop(): Result = + Result.success(Unit).also { + stopCalls++ + listener = null + } + + override fun subscribe( + listener: StreamNetworkMonitorListener, + options: Options, + ): Result { + this.listener = listener + return Result.success( + object : StreamSubscription { + override fun cancel() { + if (this@FakeNetworkMonitor.listener === listener) { + this@FakeNetworkMonitor.listener = null + } + } + } + ) + } + + fun emitConnected(snapshot: StreamNetworkInfo.Snapshot?) { + runBlocking { listener?.onNetworkConnected(snapshot) } + } + + fun emitLost(permanent: Boolean) { + runBlocking { listener?.onNetworkLost(permanent) } + } + + fun hasListener(): Boolean = listener != null + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt index e90cb0b..ac7d90e 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImplTest.kt @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.getstream.android.core.internal.recovery import io.getstream.android.core.api.log.StreamLogger diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt index ba3b298..089e8ba 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt @@ -1,3 +1,19 @@ +/* + * 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.subscribe.utils import io.getstream.android.core.api.subscribe.StreamSubscriptionManager diff --git a/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt b/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt index 6188352..f930a02 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/testing/TestLogger.kt @@ -1,3 +1,19 @@ +/* + * 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.testing import io.getstream.android.core.api.log.StreamLogger From e550526926a5adabe0a57131ebf34c895c312183 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 11:13:04 +0100 Subject: [PATCH 29/42] Remove the network state --- .../core/api/socket/listeners/StreamClientListener.kt | 7 ------- .../socket/listeners/StreamListenersDefaultImplsTest.kt | 4 ---- 2 files changed, 11 deletions(-) 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 b4c2074..cc10017 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 @@ -48,11 +48,4 @@ 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/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 2a0dede..aad56ae 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 @@ -67,10 +67,6 @@ internal class StreamListenersDefaultImplsTest { override fun onError(err: Throwable) { errorChannel.trySend(err) } - - override fun onNetworkState(state: StreamNetworkState) { - networkChannel.trySend(state) - } } val state = StreamConnectionState.Connecting.Opening("user") From 3dd800b31fa6e2b1dba971c2091ff3119ab2c3cf Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 11:17:20 +0100 Subject: [PATCH 30/42] Spotless --- .../android/core/api/socket/listeners/StreamClientListener.kt | 1 - 1 file changed, 1 deletion(-) 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 cc10017..fd819d4 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 @@ -18,7 +18,6 @@ package io.getstream.android.core.api.socket.listeners import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.api.model.connection.StreamConnectionState -import io.getstream.android.core.api.model.connection.network.StreamNetworkState /** * Listener interface for Feeds socket events. From 2c868945ec0a0fc04002bb57f71e4a99ed935fd0 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 16:11:17 +0100 Subject: [PATCH 31/42] add tests --- .../core/internal/client/StreamClientImpl.kt | 6 - .../internal/client/StreamClientIImplTest.kt | 269 +++++++++++++++++- 2 files changed, 266 insertions(+), 9 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt index a14e523..7be7851 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 @@ -116,12 +116,6 @@ internal class StreamClientImpl( lifecycleState: StreamLifecycleState, ) { val connectionState = mutableConnectionState.value - logger.v { - """networkAndLifecycleMonitor#onNetworkAndLifecycleState] - network=$networkState, - lifecycle=$lifecycleState - connectionState=$connectionState""" - } val recovery = connectionRecoveryEvaluator.evaluate( connectionState, 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 27ba1e6..1967fd9 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 @@ -22,17 +22,24 @@ 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.lifecycle.StreamLifecycleState +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.connection.recovery.Recovery +import io.getstream.android.core.api.model.exceptions.StreamEndpointErrorData +import io.getstream.android.core.api.model.exceptions.StreamEndpointException 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.processing.StreamSerialProcessingQueue import io.getstream.android.core.api.processing.StreamSingleFlightProcessor +import io.getstream.android.core.api.recovery.StreamConnectionRecoveryEvaluator import io.getstream.android.core.api.socket.StreamConnectionIdHolder import io.getstream.android.core.api.socket.listeners.StreamClientListener import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.internal.observers.StreamNetworkAndLifeCycleMonitor +import io.getstream.android.core.internal.observers.StreamNetworkAndLifecycleMonitorListener import io.getstream.android.core.internal.socket.StreamSocketSession import io.mockk.* import kotlin.time.ExperimentalTime @@ -80,7 +87,11 @@ class StreamClientIImplTest { coEvery { singleFlight.run(any(), any Any>()) } coAnswers { val block = secondArg Any>() - Result.success(block()) + try { + Result.success(block()) + } catch (t: Throwable) { + Result.failure(t) + } } // Mutable client state: expose real StateFlows that update() mutates @@ -88,11 +99,13 @@ class StreamClientIImplTest { networkFlow = MutableStateFlow(StreamNetworkState.Unknown) every { connectionIdHolder.clear() } returns Result.success(Unit) + every { subscriptionManager.forEach(any()) } returns Result.success(Unit) } private fun createClient( scope: CoroutineScope, networkAndLifeCycleMonitor: StreamNetworkAndLifeCycleMonitor = mockNetworkMonitor(), + connectionRecoveryEvaluator: StreamConnectionRecoveryEvaluator = mockk(relaxed = true), ) = StreamClientImpl( userId = userId, @@ -106,14 +119,28 @@ class StreamClientIImplTest { scope = scope, subscriptionManager = subscriptionManager, networkAndLifeCycleMonitor = networkAndLifeCycleMonitor, - connectionRecoveryEvaluator = mockk(relaxed = true), + connectionRecoveryEvaluator = connectionRecoveryEvaluator, ) private fun mockNetworkMonitor(): StreamNetworkAndLifeCycleMonitor = 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)) + every { subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + } + + private fun capturingNetworkMonitor( + onListener: (StreamNetworkAndLifecycleMonitorListener) -> Unit + ): StreamNetworkAndLifeCycleMonitor = + mockk(relaxed = true) { + every { start() } returns Result.success(Unit) + every { stop() } returns Result.success(Unit) + every { subscribe(any(), any()) } answers + { + onListener(firstArg()) + Result.success(mockk(relaxed = true)) + } } @Test @@ -350,6 +377,171 @@ class StreamClientIImplTest { verify(exactly = 0) { connectionIdHolder.setConnectionId(any()) } } + @Test + fun `recovery connect triggers another connect attempt`() = runTest { + var networkListener: StreamNetworkAndLifecycleMonitorListener? = null + val networkMonitor = capturingNetworkMonitor { networkListener = it } + val recoveryEvaluator = mockk() + coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns + Result.success(Recovery.Connect(StreamNetworkInfo.Snapshot())) + + val error = RuntimeException("no token") + coEvery { tokenManager.loadIfAbsent() } returnsMany + listOf(Result.failure(error), Result.failure(error)) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + + client.connect().onFailure {} + advanceUntilIdle() + + val listener = networkListener ?: error("Network listener not registered") + val networkState = StreamNetworkState.Available(StreamNetworkInfo.Snapshot()) + listener.onNetworkAndLifecycleState(networkState, StreamLifecycleState.Foreground) + advanceUntilIdle() + + coVerify(exactly = 2) { tokenManager.loadIfAbsent() } + coVerify(exactly = 1) { recoveryEvaluator.evaluate(any(), any(), any()) } + } + + @Test + fun `recovery disconnect closes the socket session`() = runTest { + var networkListener: StreamNetworkAndLifecycleMonitorListener? = null + val networkMonitor = capturingNetworkMonitor { networkListener = it } + val recoveryEvaluator = mockk() + coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns + Result.success(Recovery.Disconnect(StreamNetworkState.Disconnected)) + + val error = RuntimeException("no token") + coEvery { tokenManager.loadIfAbsent() } returns Result.failure(error) + every { socketSession.disconnect() } returns Result.success(Unit) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + + client.connect().onFailure {} + advanceUntilIdle() + + val listener = networkListener ?: error("Network listener not registered") + listener.onNetworkAndLifecycleState( + StreamNetworkState.Disconnected, + StreamLifecycleState.Background, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { recoveryEvaluator.evaluate(any(), any(), any()) } + verify(exactly = 1) { socketSession.disconnect() } + } + + @Test + fun `recovery error notifies subscribers`() = runTest { + var networkListener: StreamNetworkAndLifecycleMonitorListener? = null + val networkMonitor = capturingNetworkMonitor { networkListener = it } + val recoveryEvaluator = mockk() + val boom = RuntimeException("recovery error") + coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns + Result.success(Recovery.Error(boom)) + + val reported = mutableListOf() + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + every { external.onError(any()) } answers { reported += firstArg() } + block(external) + Result.success(Unit) + } + + val tokenError = RuntimeException("token") + coEvery { tokenManager.loadIfAbsent() } returns Result.failure(tokenError) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + + client.connect().onFailure {} + advanceUntilIdle() + + val listener = networkListener ?: error("Network listener not registered") + listener.onNetworkAndLifecycleState( + StreamNetworkState.Disconnected, + StreamLifecycleState.Background, + ) + advanceUntilIdle() + + assertTrue(reported.contains(boom)) + every { subscriptionManager.forEach(any()) } returns Result.success(Unit) + } + + @Test + fun `recovery null results in no action`() = runTest { + var networkListener: StreamNetworkAndLifecycleMonitorListener? = null + val networkMonitor = capturingNetworkMonitor { networkListener = it } + val recoveryEvaluator = mockk() + coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns Result.success(null) + + val tokenError = RuntimeException("token") + coEvery { tokenManager.loadIfAbsent() } returns Result.failure(tokenError) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + + client.connect().onFailure {} + advanceUntilIdle() + + val listener = networkListener ?: error("Network listener not registered") + listener.onNetworkAndLifecycleState( + StreamNetworkState.Disconnected, + StreamLifecycleState.Background, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { tokenManager.loadIfAbsent() } + verify(exactly = 0) { socketSession.disconnect() } + } + + @Test + fun `recovery failure notifies subscribers`() = runTest { + var networkListener: StreamNetworkAndLifecycleMonitorListener? = null + val networkMonitor = capturingNetworkMonitor { networkListener = it } + val recoveryEvaluator = mockk() + val boom = IllegalStateException("recovery failure") + coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns Result.failure(boom) + + val reported = mutableListOf() + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + every { external.onError(any()) } answers { reported += firstArg() } + block(external) + Result.success(Unit) + } + + val tokenError = RuntimeException("token") + coEvery { tokenManager.loadIfAbsent() } returns Result.failure(tokenError) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + + val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + + client.connect().onFailure {} + advanceUntilIdle() + + val listener = networkListener ?: error("Network listener not registered") + listener.onNetworkAndLifecycleState( + StreamNetworkState.Disconnected, + StreamLifecycleState.Background, + ) + advanceUntilIdle() + + assertTrue(reported.contains(boom)) + every { subscriptionManager.forEach(any()) } returns Result.success(Unit) + } + @Test fun `subscription onState updates client state and forwards to subscribers`() = runTest { val client = createClient(backgroundScope) @@ -457,4 +649,75 @@ class StreamClientIImplTest { assertTrue(forwardedEvents.contains(event)) verify(atLeast = 1) { subscriptionManager.forEach(any()) } } + + @Test + fun `subscription onError forwards to subscribers`() = runTest { + val client = createClient(backgroundScope) + coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers + { + val block = secondArg StreamConnectedUser>() + try { + Result.success(block.invoke()) + } catch (t: Throwable) { + Result.failure(t) + } + } + + var capturedListener: StreamClientListener? = null + every { socketSession.subscribe(any(), any()) } answers + { + capturedListener = firstArg() + Result.success(mockk(relaxed = true)) + } + + val reported = mutableListOf() + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + every { external.onError(any()) } answers { reported += firstArg() } + block(external) + Result.success(Unit) + } + + coEvery { tokenManager.loadIfAbsent() } returns Result.failure(RuntimeException("stop")) + + client.connect().onFailure {} + advanceUntilIdle() + + val error = RuntimeException("socket failure") + capturedListener!!.onError(error) + + assertTrue(reported.contains(error)) + every { subscriptionManager.forEach(any()) } returns Result.success(Unit) + } + + @Test + fun `connect retries when token error occurs`() = runTest { + val client = createClient(backgroundScope) + + val token = StreamToken.fromString("tok-1") + val refreshedToken = StreamToken.fromString("tok-2") + coEvery { tokenManager.loadIfAbsent() } returns Result.success(token) + justRun { tokenManager.invalidate() } + coEvery { tokenManager.refresh() } returns Result.success(refreshedToken) + + val endpointError = + StreamEndpointException(apiError = StreamEndpointErrorData(code = 40)) + val connectedUser = mockk(relaxed = true) + val connectedState = StreamConnectionState.Connected(connectedUser, "conn-42") + coEvery { socketSession.connect(match { it.token == token.rawValue }) } returns + Result.failure(endpointError) + coEvery { socketSession.connect(match { it.token == refreshedToken.rawValue }) } returns + Result.success(connectedState) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) + every { connectionIdHolder.setConnectionId("conn-42") } returns Result.success("conn-42") + + client.connect().onFailure {} + + verify { tokenManager.invalidate() } + coVerify { tokenManager.refresh() } + coVerify(exactly = 2) { socketSession.connect(any()) } + } } From 90c3538ca58ae168b98342893da8efbe3d211fd9 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Fri, 21 Nov 2025 16:47:25 +0100 Subject: [PATCH 32/42] add tests --- .../StreamClientSerializationConfigTest.kt | 54 +++++++++++++++++++ .../model/config/StreamSocketConfigTest.kt | 48 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt create mode 100644 stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt new file mode 100644 index 0000000..5b72260 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt @@ -0,0 +1,54 @@ +package io.getstream.android.core.api.model.config + +import io.getstream.android.core.api.model.event.StreamClientWsEvent +import io.getstream.android.core.api.serialization.StreamEventSerialization +import io.getstream.android.core.api.serialization.StreamJsonSerialization +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class StreamClientSerializationConfigTest { + + @Test + fun `json builder injects json implementation`() { + val json = FakeJsonSerialization() + val productEvents = FakeEventSerialization() + val alsoExternal = setOf("custom:event") + + val config = StreamClientSerializationConfig.json(json, productEvents, alsoExternal) + + assertSame(json, config.json) + assertNull(config.eventParser) + assertSame(productEvents, config.productEventSerializers) + assertEquals(alsoExternal, config.alsoExternal) + } + + @Test + fun `event builder injects event parser`() { + val eventParser = FakeEventSerialization() + val productEvents = FakeEventSerialization() + val alsoExternal = setOf("product:event") + + val config = StreamClientSerializationConfig.event(eventParser, productEvents, alsoExternal) + + assertSame(eventParser, config.eventParser) + assertNull(config.json) + assertSame(productEvents, config.productEventSerializers) + assertEquals(alsoExternal, config.alsoExternal) + } + + private class FakeJsonSerialization : StreamJsonSerialization { + override fun toJson(any: Any): Result = Result.success("{}") + + override fun fromJson(raw: String, clazz: Class): Result = + Result.failure(UnsupportedOperationException()) + } + + private class FakeEventSerialization : StreamEventSerialization { + override fun serialize(data: T): Result = Result.success("event") + + override fun deserialize(raw: String): Result = + Result.failure(UnsupportedOperationException()) + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt new file mode 100644 index 0000000..6c88a49 --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt @@ -0,0 +1,48 @@ +package io.getstream.android.core.api.model.config + +import io.getstream.android.core.api.model.value.StreamApiKey +import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class StreamSocketConfigTest { + + private val apiKey = StreamApiKey.fromString("key") + private val header = StreamHttpClientInfoHeader.create("product", "1.0", "android", 34, "pixel") + + @Test + fun `anonymous config uses anonymous auth type`() { + val config = StreamSocketConfig.anonymous( + url = "wss://chat.stream.io", + apiKey = apiKey, + clientInfoHeader = header, + ) + + assertEquals("wss://chat.stream.io", config.url) + assertEquals(apiKey, config.apiKey) + assertEquals("anonymous", config.authType) + assertEquals(header, config.clientInfoHeader) + } + + @Test + fun `custom config uses provided auth type and validates input`() { + val config = + StreamSocketConfig.custom( + url = "wss://chat.stream.io/custom", + apiKey = apiKey, + authType = "token", + clientInfoHeader = header, + ) + + assertEquals("token", config.authType) + + assertFailsWith { + StreamSocketConfig.custom("", apiKey, "jwt", header) + } + assertFailsWith { + StreamSocketConfig.custom("wss://chat.stream.io", apiKey, "", header) + } + } +} From f0a0e4598ee7bd69d32deebd02316142e9126653 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 24 Nov 2025 09:57:08 +0100 Subject: [PATCH 33/42] Update sonar paths --- gradle/scripts/sonar.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle/scripts/sonar.gradle b/gradle/scripts/sonar.gradle index c5f7ee6..69fa059 100644 --- a/gradle/scripts/sonar.gradle +++ b/gradle/scripts/sonar.gradle @@ -32,6 +32,7 @@ sonarqube { property "sonar.java.coveragePlugin", "jacoco" property "sonar.sourceEncoding", "UTF-8" property "sonar.java.binaries", "${rootDir}/**/build/tmp/java-classes/debug" + property "sonar.coverage.jacoco.xmlReportPaths", coverageReports.join(",") property "sonar.coverage.exclusions", rootProject.ext.sonar.excludeFilter } } From 3e0986bcd246ce3c84027467a32e64af042c203d Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 24 Nov 2025 10:39:29 +0100 Subject: [PATCH 34/42] Update coverage parts --- gradle/scripts/sonar.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gradle/scripts/sonar.gradle b/gradle/scripts/sonar.gradle index 69fa059..84729e2 100644 --- a/gradle/scripts/sonar.gradle +++ b/gradle/scripts/sonar.gradle @@ -22,6 +22,15 @@ ext.sonar.ignoreModules.each { ext.sonar.excludeFilter << "**/${it}/**" } + +def coverageReports = rootProject.subprojects + .findAll { !rootProject.ext.sonar.ignoreModules.contains(it.name) } + .collect { "${it.buildDir}/reports/kover/report.xml" } + +if (coverageReports.isEmpty()) { + coverageReports << "${rootProject.buildDir}/reports/kover/report.xml" +} + sonarqube { properties { property("sonar.host.url", "https://sonarcloud.io") From d176eabbe90dc216194c3bccbdf7ef93a041f1cb Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 24 Nov 2025 11:26:33 +0100 Subject: [PATCH 35/42] Spotless and koverXmlReport dependency for sonar --- gradle/scripts/sonar.gradle | 6 ++++ .../StreamClientSerializationConfigTest.kt | 16 +++++++++++ .../model/config/StreamSocketConfigTest.kt | 28 +++++++++++++++---- .../internal/client/StreamClientIImplTest.kt | 5 ++-- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/gradle/scripts/sonar.gradle b/gradle/scripts/sonar.gradle index 84729e2..b56ec3d 100644 --- a/gradle/scripts/sonar.gradle +++ b/gradle/scripts/sonar.gradle @@ -45,3 +45,9 @@ sonarqube { property "sonar.coverage.exclusions", rootProject.ext.sonar.excludeFilter } } + +tasks.named("sonar").configure { + rootProject.subprojects + .findAll { !rootProject.ext.sonar.ignoreModules.contains(it.name) } + .each { dependsOn("${it.path}:koverXmlReport") } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt index 5b72260..c1aed8f 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamClientSerializationConfigTest.kt @@ -1,3 +1,19 @@ +/* + * 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.config import io.getstream.android.core.api.model.event.StreamClientWsEvent diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt index 6c88a49..33092c6 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/model/config/StreamSocketConfigTest.kt @@ -1,3 +1,19 @@ +/* + * 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.config import io.getstream.android.core.api.model.value.StreamApiKey @@ -5,7 +21,6 @@ import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertSame class StreamSocketConfigTest { @@ -14,11 +29,12 @@ class StreamSocketConfigTest { @Test fun `anonymous config uses anonymous auth type`() { - val config = StreamSocketConfig.anonymous( - url = "wss://chat.stream.io", - apiKey = apiKey, - clientInfoHeader = header, - ) + val config = + StreamSocketConfig.anonymous( + url = "wss://chat.stream.io", + apiKey = apiKey, + clientInfoHeader = header, + ) assertEquals("wss://chat.stream.io", config.url) assertEquals(apiKey, config.apiKey) 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 1967fd9..9e5e535 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/client/StreamClientIImplTest.kt @@ -26,9 +26,9 @@ import io.getstream.android.core.api.model.connection.lifecycle.StreamLifecycleS 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.connection.recovery.Recovery +import io.getstream.android.core.api.model.event.StreamClientWsEvent import io.getstream.android.core.api.model.exceptions.StreamEndpointErrorData import io.getstream.android.core.api.model.exceptions.StreamEndpointException -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.processing.StreamSerialProcessingQueue @@ -702,8 +702,7 @@ class StreamClientIImplTest { justRun { tokenManager.invalidate() } coEvery { tokenManager.refresh() } returns Result.success(refreshedToken) - val endpointError = - StreamEndpointException(apiError = StreamEndpointErrorData(code = 40)) + val endpointError = StreamEndpointException(apiError = StreamEndpointErrorData(code = 40)) val connectedUser = mockk(relaxed = true) val connectedState = StreamConnectionState.Connected(connectedUser, "conn-42") coEvery { socketSession.connect(match { it.token == token.rawValue }) } returns From d4e2a2b2cdf7757727ad34dd331a176eac3c433c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 11:32:05 +0100 Subject: [PATCH 36/42] Update stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com> --- .../recovery/StreamConnectionRecoveryEvaluatorImpl.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt index dacddbe..578d4f0 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt @@ -94,9 +94,7 @@ internal class StreamConnectionRecoveryEvaluatorImpl( lastConnectionState = connectionState lastLifecycleState = lifecycleState lastNetworkState = networkState - if (networkState is StreamNetworkState.Available) { - lastNetworkSnapshot = networkState.snapshot - } + lastNetworkSnapshot = connectSnapshot result } } From f20e65d7543b554f6bca6324f75e3f4442363d02 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 11:36:28 +0100 Subject: [PATCH 37/42] Update stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../io/getstream/android/core/api/utils/Result.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt index 603cb3a..ea56237 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt @@ -49,10 +49,13 @@ public inline fun Result.flatMap(transform: (T) -> Result): Result< @StreamInternalApi public suspend fun Result.onTokenError( function: suspend (exception: StreamEndpointException, code: Int) -> Result -): Result = onFailure { - if (it is StreamEndpointException && it.apiError?.code?.div(10) == 4) { - function(it, it.apiError.code) - } else { - this +): Result = fold( + onSuccess = { this }, + onFailure = { throwable -> + if (throwable is StreamEndpointException && throwable.apiError?.code?.div(10) == 4) { + function(throwable, throwable.apiError.code) + } else { + this + } } -} +) From 41cbf282d4ab41e86752959e88339fb931941042 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 11:36:40 +0100 Subject: [PATCH 38/42] Update stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../internal/observers/StreamNetworkAndLifeCycleMonitor.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt index 354a438..d3bec71 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt @@ -30,9 +30,9 @@ import kotlinx.coroutines.flow.MutableStateFlow * Coordinates lifecycle and network signals to make connection recovery decisions. * * ### Responsibilities - * - Bridges callbacks from [StreamNetworkMonitor] and [StreamLifecycleMonitor] into hot state flows - * exposed as [networkState] and [lifecycleState]. - * - Notifies registered [StreamConnectionRecoveryListener]s when reconnect/teardown actions should + * - Bridges callbacks from [StreamNetworkMonitor] and [StreamLifecycleMonitor] into internal state flows + * for network and lifecycle state. + * - Notifies registered [StreamNetworkAndLifecycleMonitorListener]s when reconnect/teardown actions should * occur. * - Implements [StreamStartableComponent] so callers can hook into their own lifecycle. * From ac231f85a5d0bdae3e0f97dd96a13c33d6b86e6e Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 11:56:28 +0100 Subject: [PATCH 39/42] Remove forEachSuspend as it breaks the async functionality since it uses runBlocking --- .../android/core/sample/SampleActivity.kt | 31 ++++++- .../socket/listeners/StreamClientListener.kt | 8 ++ .../core/internal/client/StreamClientImpl.kt | 25 ++++-- .../StreamNetworkAndLifeCycleMonitor.kt | 1 + .../StreamNetworkAndLifecycleMonitorImpl.kt | 9 +- ...treamNetworkAndLifecycleMonitorListener.kt | 2 +- .../core/internal/subscribe/utils/ForEach.kt | 30 ------- .../internal/client/StreamClientIImplTest.kt | 87 ++++++++++++------- ...treamNetworkAndLifecycleMonitorImplTest.kt | 6 +- ...StreamSubscriptionManagerExtensionsTest.kt | 48 ---------- 10 files changed, 119 insertions(+), 128 deletions(-) delete mode 100644 stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt delete mode 100644 stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index f6ebf5c..6aee6fe 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 @@ -42,21 +42,37 @@ 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.StreamConnectionState +import io.getstream.android.core.api.model.connection.recovery.Recovery 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.api.socket.listeners.StreamClientListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.sample.client.createStreamClient import io.getstream.android.core.sample.ui.ConnectionStateCard import io.getstream.android.core.sample.ui.theme.StreamandroidcoreTheme import kotlinx.coroutines.launch -class SampleActivity : ComponentActivity() { +class SampleActivity : ComponentActivity(), StreamClientListener { val userId = StreamUserId.fromString("petar") var streamClient: StreamClient? = null + var handle: StreamSubscription? = null + + override fun onRecovery(recovery: Recovery) { + super.onRecovery(recovery) + Log.d("SampleActivity", "Recovery: $recovery") + } + + override fun onError(err: Throwable) { + super.onError(err) + Log.e("SampleActivity", "Error: $err") + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val streamClient2 = @@ -92,6 +108,14 @@ class SampleActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { streamClient?.connect() } } + + if (handle == null) { + handle = streamClient2.subscribe( + this, options = StreamSubscriptionManager.Options( + retention = StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED, + ) + ).getOrThrow() + } enableEdgeToEdge() setContent { StreamandroidcoreTheme { @@ -99,7 +123,8 @@ class SampleActivity : ComponentActivity() { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = - Modifier.fillMaxSize() + Modifier + .fillMaxSize() .padding(innerPadding) .verticalScroll(scrollState) .padding(16.dp), @@ -120,9 +145,11 @@ class SampleActivity : ComponentActivity() { }, ) } + is StreamConnectionState.Connecting -> { Triple("Connecting", false, { Unit }) } + else -> { Triple( "Connect", 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 fd819d4..04d24a0 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 @@ -18,6 +18,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.recovery.Recovery /** * Listener interface for Feeds socket events. @@ -47,4 +48,11 @@ public interface StreamClientListener { * @param err The error that occurred. */ public fun onError(err: Throwable) {} + + /** + * Called when a recovery decision is made. + * + * @param recovery The recovery decision. + */ + public fun onRecovery(recovery: Recovery) {} } 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 7be7851..51fbae1 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 @@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch internal class StreamClientImpl( private val userId: StreamUserId, @@ -111,18 +112,20 @@ internal class StreamClientImpl( logger.v { "[connect] Setup network and lifecycle monitor callback" } val networkAndLifecycleMonitorListener = object : StreamNetworkAndLifecycleMonitorListener { - override suspend fun onNetworkAndLifecycleState( + override fun onNetworkAndLifecycleState( networkState: StreamNetworkState, lifecycleState: StreamLifecycleState, ) { - val connectionState = mutableConnectionState.value - val recovery = - connectionRecoveryEvaluator.evaluate( - connectionState, - lifecycleState, - networkState, - ) - recoveryEffect(recovery) + scope.launch { + val connectionState = mutableConnectionState.value + val recovery = + connectionRecoveryEvaluator.evaluate( + connectionState, + lifecycleState, + networkState, + ) + recoveryEffect(recovery) + } } } networkAndLifecycleMonitorHandle = @@ -200,6 +203,7 @@ internal class StreamClientImpl( private suspend fun recoveryEffect(recovery: Result) { recovery.fold( onSuccess = { recovery -> + when (recovery) { is Recovery.Connect<*> -> { logger.v { "[recovery] Connecting: $recovery" } @@ -220,6 +224,9 @@ internal class StreamClientImpl( logger.v { "[recovery] No action" } } } + if (recovery != null) { + subscriptionManager.forEach { it.onRecovery(recovery) } + } }, onFailure = { error -> logger.e(error) { "[recovery] Error: ${error.message}" } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt index 354a438..6bd5059 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt @@ -24,6 +24,7 @@ import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.subscribe.StreamObservable import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow /** diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt index 29e38e8..70deb6f 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt @@ -29,7 +29,6 @@ import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.android.core.api.utils.flatMap import io.getstream.android.core.api.utils.times import io.getstream.android.core.api.utils.update -import io.getstream.android.core.internal.subscribe.utils.forEachSuspend import kotlinx.coroutines.flow.MutableStateFlow internal class StreamNetworkAndLifecycleMonitorImpl( @@ -51,7 +50,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( val lifecycleState = StreamLifecycleState.Foreground val networkState = mutableNetworkState.value mutableLifecycleState.update(lifecycleState) - subscriptionManager.forEachSuspend { + subscriptionManager.forEach { it.onNetworkAndLifecycleState(networkState, lifecycleState) } } @@ -61,7 +60,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( val lifecycleState = StreamLifecycleState.Background val networkState = mutableNetworkState.value mutableLifecycleState.update(lifecycleState) - subscriptionManager.forEachSuspend { + subscriptionManager.forEach { it.onNetworkAndLifecycleState(networkState, lifecycleState) } } @@ -73,7 +72,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( val state = StreamNetworkState.Available(snapshot) mutableNetworkState.update(state) val lifecycleState = mutableLifecycleState.value - subscriptionManager.forEachSuspend { + subscriptionManager.forEach { it.onNetworkAndLifecycleState(state, lifecycleState) } } @@ -88,7 +87,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( } mutableNetworkState.update(state) val lifecycleState = mutableLifecycleState.value - subscriptionManager.forEachSuspend { + subscriptionManager.forEach { it.onNetworkAndLifecycleState(state, lifecycleState) } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt index cc64279..d82b41b 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorListener.kt @@ -28,7 +28,7 @@ internal interface StreamNetworkAndLifecycleMonitorListener { * @param networkState The new network state. * @param lifecycleState The new lifecycle state. */ - suspend fun onNetworkAndLifecycleState( + fun onNetworkAndLifecycleState( networkState: StreamNetworkState, lifecycleState: StreamLifecycleState, ) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt deleted file mode 100644 index 9be7279..0000000 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/subscribe/utils/ForEach.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.subscribe.utils - -import io.getstream.android.core.api.subscribe.StreamSubscriptionManager -import kotlinx.coroutines.runBlocking - -/** - * Iterates over all listeners, invoking [block] for each one. This is a convenience wrapper over - * [StreamSubscriptionManager#forEach] that allows suspending [block]s. - * - * @see StreamSubscriptionManager#forEach - */ -internal fun StreamSubscriptionManager.forEachSuspend(block: suspend (T) -> Unit) { - this.forEach { runBlocking { block(it) } } -} 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 9e5e535..928dc7b 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 @@ -80,6 +80,7 @@ class StreamClientIImplTest { socketSession = mockk(relaxed = true) logger = mockk(relaxed = true) subscriptionManager = mockk(relaxed = true) + every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) // SingleFlight: execute the lambda and wrap into Result singleFlight = mockk(relaxed = true) @@ -143,12 +144,24 @@ class StreamClientIImplTest { } } + private fun stubSubscriptionManager( + configure: (StreamClientListener) -> Unit = {} + ) { + every { subscriptionManager.forEach(any()) } answers + { + val block = firstArg<(StreamClientListener) -> Unit>() + val external = mockk(relaxed = true) + configure(external) + block(external) + Result.success(Unit) + } + } + @Test fun `connect short-circuits when already connected`() = runTest { - backgroundScope val connectedUser = mockk(relaxed = true) connFlow.value = StreamConnectionState.Connected(connectedUser, "cid-123") - val client = createClient(backgroundScope) + val client = createClient(this) val res = client.connect() @@ -156,7 +169,6 @@ class StreamClientIImplTest { assertSame(connectedUser, res.getOrThrow()) // No socket session subscribe/connect or token fetch when already connected - verify(exactly = 0) { socketSession.subscribe(any(), any()) } coVerify(exactly = 0) { socketSession.connect(any()) } coVerify(exactly = 0) { tokenManager.loadIfAbsent() } } @@ -165,7 +177,7 @@ class StreamClientIImplTest { fun `disconnect performs cleanup - updates state, clears ids, cancels handle, stops processors`() = runTest { val networkMonitor = mockNetworkMonitor() - val client = createClient(backgroundScope, networkMonitor) + val client = createClient(this, networkMonitor) // Make singleFlight actually run the provided block and return success coEvery { singleFlight.run(any(), any Any>()) } coAnswers { @@ -221,7 +233,7 @@ class StreamClientIImplTest { val listener = mockk(relaxed = true) val sub = mockk(relaxed = true) every { subscriptionManager.subscribe(listener) } returns Result.success(sub) - val client = createClient(backgroundScope) + val client = createClient(this) val res = client.subscribe(listener) @@ -233,7 +245,7 @@ class StreamClientIImplTest { @Test fun `connect success - subscribes once, calls session connect, updates state and connectionId, returns user`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // single-flight executes block and returns its result coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -276,7 +288,7 @@ class StreamClientIImplTest { @Test fun `connect early-exit when already connected - returns existing user and does not hit session or token`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // Make single-flight run the block coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -294,7 +306,6 @@ class StreamClientIImplTest { assertSame(existingUser, result.getOrNull()) // No new subscribe, no token load, no session connect, no connectionId set - verify(exactly = 0) { socketSession.subscribe(any(), any()) } coVerify(exactly = 0) { tokenManager.loadIfAbsent() } coVerify(exactly = 0) { socketSession.connect(any()) } verify(exactly = 0) { connectionIdHolder.setConnectionId(any()) } @@ -303,7 +314,7 @@ class StreamClientIImplTest { @Test fun `connect fails when token manager fails - emits Disconnected state and returns failure`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // single-flight executes block coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -339,7 +350,7 @@ class StreamClientIImplTest { @Test fun `connect fails when socket session connect fails - emits Disconnected state and returns failure`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // single-flight executes block coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -382,8 +393,13 @@ class StreamClientIImplTest { var networkListener: StreamNetworkAndLifecycleMonitorListener? = null val networkMonitor = capturingNetworkMonitor { networkListener = it } val recoveryEvaluator = mockk() + val expectedRecovery = Recovery.Connect(StreamNetworkInfo.Snapshot()) coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns - Result.success(Recovery.Connect(StreamNetworkInfo.Snapshot())) + Result.success(expectedRecovery) + val recoveries = mutableListOf() + stubSubscriptionManager { external -> + every { external.onRecovery(any()) } answers { recoveries += firstArg() } + } val error = RuntimeException("no token") coEvery { tokenManager.loadIfAbsent() } returnsMany @@ -391,7 +407,7 @@ class StreamClientIImplTest { every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + val client = createClient(this, networkMonitor, recoveryEvaluator) client.connect().onFailure {} advanceUntilIdle() @@ -403,6 +419,7 @@ class StreamClientIImplTest { coVerify(exactly = 2) { tokenManager.loadIfAbsent() } coVerify(exactly = 1) { recoveryEvaluator.evaluate(any(), any(), any()) } + assertTrue(recoveries.contains(expectedRecovery)) } @Test @@ -410,8 +427,13 @@ class StreamClientIImplTest { var networkListener: StreamNetworkAndLifecycleMonitorListener? = null val networkMonitor = capturingNetworkMonitor { networkListener = it } val recoveryEvaluator = mockk() + val expectedRecovery = Recovery.Disconnect(StreamNetworkState.Disconnected) coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns - Result.success(Recovery.Disconnect(StreamNetworkState.Disconnected)) + Result.success(expectedRecovery) + val recoveries = mutableListOf() + stubSubscriptionManager { external -> + every { external.onRecovery(any()) } answers { recoveries += firstArg() } + } val error = RuntimeException("no token") coEvery { tokenManager.loadIfAbsent() } returns Result.failure(error) @@ -419,7 +441,7 @@ class StreamClientIImplTest { every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + val client = createClient(this, networkMonitor, recoveryEvaluator) client.connect().onFailure {} advanceUntilIdle() @@ -433,6 +455,7 @@ class StreamClientIImplTest { coVerify(exactly = 1) { recoveryEvaluator.evaluate(any(), any(), any()) } verify(exactly = 1) { socketSession.disconnect() } + assertTrue(recoveries.contains(expectedRecovery)) } @Test @@ -441,25 +464,23 @@ class StreamClientIImplTest { val networkMonitor = capturingNetworkMonitor { networkListener = it } val recoveryEvaluator = mockk() val boom = RuntimeException("recovery error") + val expectedRecovery = Recovery.Error(boom) coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns - Result.success(Recovery.Error(boom)) + Result.success(expectedRecovery) val reported = mutableListOf() - every { subscriptionManager.forEach(any()) } answers - { - val block = firstArg<(StreamClientListener) -> Unit>() - val external = mockk(relaxed = true) - every { external.onError(any()) } answers { reported += firstArg() } - block(external) - Result.success(Unit) - } + val recoveries = mutableListOf() + stubSubscriptionManager { external -> + every { external.onError(any()) } answers { reported += firstArg() } + every { external.onRecovery(any()) } answers { recoveries += firstArg() } + } val tokenError = RuntimeException("token") coEvery { tokenManager.loadIfAbsent() } returns Result.failure(tokenError) every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + val client = createClient(this, networkMonitor, recoveryEvaluator) client.connect().onFailure {} advanceUntilIdle() @@ -472,6 +493,7 @@ class StreamClientIImplTest { advanceUntilIdle() assertTrue(reported.contains(boom)) + assertTrue(recoveries.contains(expectedRecovery)) every { subscriptionManager.forEach(any()) } returns Result.success(Unit) } @@ -481,13 +503,17 @@ class StreamClientIImplTest { val networkMonitor = capturingNetworkMonitor { networkListener = it } val recoveryEvaluator = mockk() coEvery { recoveryEvaluator.evaluate(any(), any(), any()) } returns Result.success(null) + val recoveries = mutableListOf() + stubSubscriptionManager { external -> + every { external.onRecovery(any()) } answers { recoveries += firstArg() } + } val tokenError = RuntimeException("token") coEvery { tokenManager.loadIfAbsent() } returns Result.failure(tokenError) every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + val client = createClient(this, networkMonitor, recoveryEvaluator) client.connect().onFailure {} advanceUntilIdle() @@ -501,6 +527,7 @@ class StreamClientIImplTest { coVerify(exactly = 1) { tokenManager.loadIfAbsent() } verify(exactly = 0) { socketSession.disconnect() } + assertTrue(recoveries.isEmpty()) } @Test @@ -526,7 +553,7 @@ class StreamClientIImplTest { every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) - val client = createClient(backgroundScope, networkMonitor, recoveryEvaluator) + val client = createClient(this, networkMonitor, recoveryEvaluator) client.connect().onFailure {} advanceUntilIdle() @@ -544,7 +571,7 @@ class StreamClientIImplTest { @Test fun `subscription onState updates client state and forwards to subscribers`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // Make single-flight execute the block coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -599,7 +626,7 @@ class StreamClientIImplTest { @Test fun `subscription onEvent forwards to subscribers`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) // Make single-flight execute the block coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { @@ -652,7 +679,7 @@ class StreamClientIImplTest { @Test fun `subscription onError forwards to subscribers`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) coEvery { singleFlight.run(any(), any StreamConnectedUser>()) } coAnswers { val block = secondArg StreamConnectedUser>() @@ -694,7 +721,7 @@ class StreamClientIImplTest { @Test fun `connect retries when token error occurs`() = runTest { - val client = createClient(backgroundScope) + val client = createClient(this) val token = StreamToken.fromString("tok-1") val refreshedToken = StreamToken.fromString("tok-2") diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt index e5845bb..cc53052 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImplTest.kt @@ -63,7 +63,7 @@ class StreamNetworkAndLifecycleMonitorImplTest { monitor .subscribe( object : StreamNetworkAndLifecycleMonitorListener { - override suspend fun onNetworkAndLifecycleState( + override fun onNetworkAndLifecycleState( networkState: StreamNetworkState, lifecycleState: StreamLifecycleState, ) { @@ -102,7 +102,7 @@ class StreamNetworkAndLifecycleMonitorImplTest { monitor .subscribe( object : StreamNetworkAndLifecycleMonitorListener { - override suspend fun onNetworkAndLifecycleState( + override fun onNetworkAndLifecycleState( networkState: StreamNetworkState, lifecycleState: StreamLifecycleState, ) { @@ -136,7 +136,7 @@ class StreamNetworkAndLifecycleMonitorImplTest { monitor .subscribe( object : StreamNetworkAndLifecycleMonitorListener { - override suspend fun onNetworkAndLifecycleState( + override fun onNetworkAndLifecycleState( networkState: StreamNetworkState, lifecycleState: StreamLifecycleState, ) { diff --git a/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt deleted file mode 100644 index 089e8ba..0000000 --- a/stream-android-core/src/test/java/io/getstream/android/core/internal/subscribe/utils/StreamSubscriptionManagerExtensionsTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.subscribe.utils - -import io.getstream.android.core.api.subscribe.StreamSubscriptionManager -import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options -import io.getstream.android.core.api.subscribe.StreamSubscriptionManager.Options.Retention -import io.getstream.android.core.testing.TestLogger -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlinx.coroutines.yield - -class StreamSubscriptionManagerExtensionsTest { - - @Test - fun `forEachSuspend invokes suspending block for each listener`() { - val manager = StreamSubscriptionManager<(String) -> Unit>(TestLogger) - val recorded = mutableListOf() - val options = Options(retention = Retention.KEEP_UNTIL_CANCELLED) - - val first = { value: String -> recorded += "first:$value" } - val second = { value: String -> recorded += "second:$value" } - - manager.subscribe(first, options).getOrThrow() - manager.subscribe(second, options).getOrThrow() - - manager.forEachSuspend { listener -> - yield() - listener("event") - } - - assertEquals(setOf("first:event", "second:event"), recorded.toSet()) - } -} From 398e9d107347c3c917939ff87e7e641f15a5c2b5 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 12:10:21 +0100 Subject: [PATCH 40/42] Update stream-android-core/src/main/java/io/getstream/android/core/internal/client/StreamClientImpl.kt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../getstream/android/core/internal/client/StreamClientImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 51fbae1..6082a56 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 @@ -194,7 +194,7 @@ internal class StreamClientImpl( logger.e(error) { "Token error: $code" } tokenManager.invalidate() tokenManager.refresh().flatMap { newToken -> - // One retry once with new token + // Retry once with new token socketSession.connect(data.copy(token = newToken.rawValue)) } } From a0a25d3ac0ea9c982dbb868f9b816642e0fd9223 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 12:10:38 +0100 Subject: [PATCH 41/42] Update stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com> --- .../internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt index 578d4f0..214fa83 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt @@ -73,7 +73,8 @@ internal class StreamConnectionRecoveryEvaluatorImpl( hasConnectedBefore && isDisconnected && lifecycleForeground && - (networkBecameAvailable || returningToForeground && networkAvailable) + networkAvailable && + (networkWasUnavailable || lifecycleWasBackground) val connectSnapshot = when (networkState) { From faca6d8c2974eb8fe30c4ed5bcb934d1fe8ecc14 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 26 Nov 2025 12:14:21 +0100 Subject: [PATCH 42/42] Spotless & condition update --- .../android/core/sample/SampleActivity.kt | 19 ++++++++++------- .../android/core/api/utils/Result.kt | 21 ++++++++++--------- .../core/internal/client/StreamClientImpl.kt | 1 - .../StreamNetworkAndLifeCycleMonitor.kt | 9 ++++---- .../StreamNetworkAndLifecycleMonitorImpl.kt | 8 ++----- .../StreamConnectionRecoveryEvaluatorImpl.kt | 3 +-- .../internal/client/StreamClientIImplTest.kt | 7 +++---- 7 files changed, 33 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt b/app/src/main/java/io/getstream/android/core/sample/SampleActivity.kt index 6aee6fe..299a613 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 @@ -110,11 +110,17 @@ class SampleActivity : ComponentActivity(), StreamClientListener { } if (handle == null) { - handle = streamClient2.subscribe( - this, options = StreamSubscriptionManager.Options( - retention = StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED, - ) - ).getOrThrow() + handle = + streamClient2 + .subscribe( + this, + options = + StreamSubscriptionManager.Options( + retention = + StreamSubscriptionManager.Options.Retention.KEEP_UNTIL_CANCELLED + ), + ) + .getOrThrow() } enableEdgeToEdge() setContent { @@ -123,8 +129,7 @@ class SampleActivity : ComponentActivity(), StreamClientListener { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Column( modifier = - Modifier - .fillMaxSize() + Modifier.fillMaxSize() .padding(innerPadding) .verticalScroll(scrollState) .padding(16.dp), diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt index ea56237..048bebe 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/utils/Result.kt @@ -49,13 +49,14 @@ public inline fun Result.flatMap(transform: (T) -> Result): Result< @StreamInternalApi public suspend fun Result.onTokenError( function: suspend (exception: StreamEndpointException, code: Int) -> Result -): Result = fold( - onSuccess = { this }, - onFailure = { throwable -> - if (throwable is StreamEndpointException && throwable.apiError?.code?.div(10) == 4) { - function(throwable, throwable.apiError.code) - } else { - this - } - } -) +): Result = + fold( + onSuccess = { this }, + onFailure = { throwable -> + if (throwable is StreamEndpointException && throwable.apiError?.code?.div(10) == 4) { + function(throwable, throwable.apiError.code) + } else { + this + } + }, + ) 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 6082a56..d9fc88a 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 @@ -203,7 +203,6 @@ internal class StreamClientImpl( private suspend fun recoveryEffect(recovery: Result) { recovery.fold( onSuccess = { recovery -> - when (recovery) { is Recovery.Connect<*> -> { logger.v { "[recovery] Connecting: $recovery" } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt index 050f6a6..ed05e44 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifeCycleMonitor.kt @@ -24,17 +24,16 @@ import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor import io.getstream.android.core.api.observers.network.StreamNetworkMonitor import io.getstream.android.core.api.subscribe.StreamObservable import io.getstream.android.core.api.subscribe.StreamSubscriptionManager -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow /** * Coordinates lifecycle and network signals to make connection recovery decisions. * * ### Responsibilities - * - Bridges callbacks from [StreamNetworkMonitor] and [StreamLifecycleMonitor] into internal state flows - * for network and lifecycle state. - * - Notifies registered [StreamNetworkAndLifecycleMonitorListener]s when reconnect/teardown actions should - * occur. + * - Bridges callbacks from [StreamNetworkMonitor] and [StreamLifecycleMonitor] into internal state + * flows for network and lifecycle state. + * - Notifies registered [StreamNetworkAndLifecycleMonitorListener]s when reconnect/teardown actions + * should occur. * - Implements [StreamStartableComponent] so callers can hook into their own lifecycle. * * ### Usage diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt index 70deb6f..48183d5 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/observers/StreamNetworkAndLifecycleMonitorImpl.kt @@ -72,9 +72,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( val state = StreamNetworkState.Available(snapshot) mutableNetworkState.update(state) val lifecycleState = mutableLifecycleState.value - subscriptionManager.forEach { - it.onNetworkAndLifecycleState(state, lifecycleState) - } + subscriptionManager.forEach { it.onNetworkAndLifecycleState(state, lifecycleState) } } override suspend fun onNetworkLost(permanent: Boolean) { @@ -87,9 +85,7 @@ internal class StreamNetworkAndLifecycleMonitorImpl( } mutableNetworkState.update(state) val lifecycleState = mutableLifecycleState.value - subscriptionManager.forEach { - it.onNetworkAndLifecycleState(state, lifecycleState) - } + subscriptionManager.forEach { it.onNetworkAndLifecycleState(state, lifecycleState) } } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt index 214fa83..578d4f0 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/recovery/StreamConnectionRecoveryEvaluatorImpl.kt @@ -73,8 +73,7 @@ internal class StreamConnectionRecoveryEvaluatorImpl( hasConnectedBefore && isDisconnected && lifecycleForeground && - networkAvailable && - (networkWasUnavailable || lifecycleWasBackground) + (networkBecameAvailable || returningToForeground && networkAvailable) val connectSnapshot = when (networkState) { 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 928dc7b..68498eb 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 @@ -80,7 +80,8 @@ class StreamClientIImplTest { socketSession = mockk(relaxed = true) logger = mockk(relaxed = true) subscriptionManager = mockk(relaxed = true) - every { socketSession.subscribe(any(), any()) } returns Result.success(mockk(relaxed = true)) + every { socketSession.subscribe(any(), any()) } returns + Result.success(mockk(relaxed = true)) // SingleFlight: execute the lambda and wrap into Result singleFlight = mockk(relaxed = true) @@ -144,9 +145,7 @@ class StreamClientIImplTest { } } - private fun stubSubscriptionManager( - configure: (StreamClientListener) -> Unit = {} - ) { + private fun stubSubscriptionManager(configure: (StreamClientListener) -> Unit = {}) { every { subscriptionManager.forEach(any()) } answers { val block = firstArg<(StreamClientListener) -> Unit>()