Supported network restoration observer

commit_hash:eb5598267f2eabc885995201f4c9a8af70f7764a
This commit is contained in:
burstein
2026-04-30 12:02:18 +03:00
parent f585ff0113
commit f2bfb5b2f5
43 changed files with 489 additions and 10 deletions
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
Required by:
* com.yandex.div.compose.views.image.observeNetworkRestoration —
registers a ConnectivityManager.NetworkCallback to re-issue failed
image requests when connectivity is restored.
* coil3.network.ConnectivityChecker — used by Coil's NetworkFetcher
to short-circuit requests while offline. Without this permission,
Coil falls back to "always online" mode.
Merged into the host application's manifest at build time. Apps that
explicitly want to opt out can override with `tools:node="remove"`.
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>
@@ -7,6 +7,7 @@ import com.yandex.div.compose.DivException
import com.yandex.div.compose.DivReporter
import com.yandex.div.compose.actions.DivActionHandlingContext
import com.yandex.div.compose.context.LocalDivViewContext
import com.yandex.div.compose.internal.NetworkRestorationController
import com.yandex.div.compose.triggers.DivTriggerStorage
import com.yandex.div.compose.triggers.observe
import com.yandex.div.compose.variables.DivVariableAdapter
@@ -30,6 +31,7 @@ internal interface DivLocalComponent {
val actionHandlingContext: DivActionHandlingContext
val expressionResolver: ExpressionResolver
val functionProvider: FunctionProviderDecorator
val networkRestorationController: NetworkRestorationController
val reporter: DivReporter
val triggerStorage: DivTriggerStorage
val variableAdapter: DivVariableAdapter
@@ -0,0 +1,49 @@
@file:SuppressLint("ComposableNaming")
package com.yandex.div.compose.images
import android.Manifest
import android.annotation.SuppressLint
import androidx.annotation.RequiresPermission
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import coil3.compose.AsyncImagePainter
import coil3.network.HttpException
import com.yandex.div.compose.dagger.LocalComponent
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
@Composable
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
internal fun AsyncImagePainter.observeNetworkRestoration() {
val controller = LocalComponent.current.networkRestorationController
LaunchedEffect(this, controller) {
controller.networkRestored.collect {
val state = state.value
if (state is AsyncImagePainter.State.Error && state.result.throwable.isNetworkConnectivityError()) {
restart()
}
}
}
}
private fun Throwable?.isNetworkConnectivityError(): Boolean {
var cause: Throwable? = this
while (cause != null) {
when (cause) {
is UnknownHostException,
is ConnectException,
is SocketTimeoutException,
is SocketException,
is InterruptedIOException -> return true
is HttpException -> {
if (cause.response.code == 408 || cause.response.code == 504) return true
}
}
cause = cause.cause
}
return false
}
@@ -0,0 +1,49 @@
package com.yandex.div.compose.internal
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.yandex.div.compose.dagger.DivContextScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
/**
* Listens to system connectivity events and exposes a hot stream of "network restored" pulses.
*
* Exists as a process-wide bus so consumers (image painters, video players, etc.) don't have to
* each register their own [ConnectivityManager.NetworkCallback]. Subscribers decide locally whether
* to react.
*/
@DivContextScope
internal class NetworkRestorationController @Inject constructor(context: Context) {
private val _networkRestored = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val networkRestored: SharedFlow<Unit> = _networkRestored.asSharedFlow()
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_networkRestored.tryEmit(Unit)
}
}
init {
val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
try {
val request: NetworkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
} catch (_: Throwable) { }
}
}
@@ -1,6 +1,7 @@
package com.yandex.div.compose.views.image
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -13,8 +14,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import com.yandex.div.compose.images.ImageRequestParams
import com.yandex.div.compose.images.observeNetworkRestoration
import com.yandex.div.compose.images.rememberImageRequest
import com.yandex.div.compose.utils.divContext
import com.yandex.div.compose.utils.imageLoader
@@ -78,10 +80,13 @@ internal fun DivImageView(
Box(modifier = backgroundModifier) {
if (!isImageLoaded && previewRequest != null) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
val previewPainter = rememberAsyncImagePainter(
model = previewRequest,
imageLoader = imageLoader,
)
Image(
modifier = Modifier.fillMaxSize(),
painter = previewPainter,
contentDescription = null,
contentScale = contentScale,
alignment = alignment,
@@ -89,18 +94,22 @@ internal fun DivImageView(
)
}
AsyncImage(
modifier = Modifier.fillMaxSize(),
val imagePainter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = imageLoader,
contentDescription = null,
contentScale = contentScale,
alignment = alignment,
colorFilter = colorFilter,
onSuccess = {
isImageLoaded = true
}
)
imagePainter.observeNetworkRestoration()
Image(
modifier = Modifier.fillMaxSize(),
painter = imagePainter,
contentDescription = null,
contentScale = contentScale,
alignment = alignment,
colorFilter = colorFilter
)
}
}
@@ -17,6 +17,7 @@ import com.yandex.div.compose.utils.imageLoader
import com.yandex.div.compose.utils.observedFloatValue
import com.yandex.div.compose.utils.observedValue
import com.yandex.div.compose.utils.toAlignment
import com.yandex.div.compose.images.observeNetworkRestoration
import com.yandex.div.compose.views.image.resolveTransformations
import com.yandex.div.compose.views.image.toContentScale
import com.yandex.div2.DivImageBackground
@@ -45,6 +46,7 @@ internal fun Modifier.imageBackground(data: DivImageBackground): Modifier {
model = context.rememberImageRequest(imageRequestParams),
imageLoader = imageLoader
)
painter.observeNetworkRestoration()
return drawBehind {
val srcSize = painter.intrinsicSize
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.yandex.div.compose.dagger.DivLocalComponent
import com.yandex.div.compose.expressions.DivComposeExpressionResolver
import com.yandex.div.compose.internal.DivDebugConfiguration
import com.yandex.div.compose.internal.NetworkRestorationController
import com.yandex.div.core.expression.variables.DivVariableController
import com.yandex.div.evaluable.function.GeneratedBuiltinFunctionProvider
import com.yandex.div.internal.expressions.FunctionProviderDecorator
@@ -26,7 +27,8 @@ internal fun createExpressionResolver(
internal fun mockLocalComponent(
reporter: DivReporter = TestReporter(),
variableController: DivVariableController = DivVariableController()
variableController: DivVariableController = DivVariableController(),
networkRestorationController: NetworkRestorationController? = null,
): DivLocalComponent {
val resolver = createExpressionResolver(
reporter = reporter,
@@ -36,6 +38,9 @@ internal fun mockLocalComponent(
on { expressionResolver } doReturn resolver
on { this.reporter } doReturn reporter
on { this.variableController } doReturn variableController
networkRestorationController?.let {
on { this.networkRestorationController } doReturn it
}
}
}
@@ -0,0 +1,210 @@
package com.yandex.div.compose.images
import android.content.Context
import android.graphics.Bitmap
import android.net.ConnectivityManager
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.asImage
import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter
import coil3.network.HttpException
import coil3.network.NetworkResponse
import coil3.request.ErrorResult
import coil3.request.ImageRequest
import coil3.request.ImageResult
import coil3.request.SuccessResult
import com.yandex.div.compose.dagger.LocalComponent
import com.yandex.div.compose.internal.NetworkRestorationController
import com.yandex.div.compose.mockLocalComponent
import junit.framework.TestCase.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
import org.robolectric.shadows.ShadowConnectivityManager
import org.robolectric.shadows.ShadowNetwork
import java.net.UnknownHostException
import java.util.concurrent.atomic.AtomicInteger
@RunWith(AndroidJUnit4::class)
class ImageNetworkRestorationTest {
@get:Rule
val composeRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private val shadowConnectivityManager: ShadowConnectivityManager = Shadows.shadowOf(
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
)
private val attempts = AtomicInteger(0)
@Volatile
private var nextResult: (ImageRequest) -> ImageResult = { networkErrorResult(it) }
private val localComponent = mockLocalComponent(
networkRestorationController = NetworkRestorationController(context)
)
private val imageLoader: ImageLoader = ImageLoader.Builder(context)
.components {
add { chain ->
attempts.incrementAndGet()
nextResult(chain.request)
}
}
.build()
@Test
fun `restarts on UnknownHostException`() = expectRestart {
networkErrorResult(it)
}
@Test
fun `restarts on HTTP 504`() = expectRestart {
httpErrorResult(it, code = 504)
}
@Test
fun `restarts on HTTP 408`() = expectRestart {
httpErrorResult(it, code = 408)
}
@Test
fun `does not restart on HTTP 500`() = expectNoRestart {
httpErrorResult(it, code = 500)
}
@Test
fun `does not restart on HTTP 404`() = expectNoRestart {
httpErrorResult(it, code = 404)
}
@Test
fun `does not restart on non-network error`() = expectNoRestart {
ErrorResult(
image = null,
request = it,
throwable = IllegalStateException("decoding failed"),
)
}
@Test
fun `does not restart when painter is in success state`() {
nextResult = ::successResult
setContent { rememberObservedPainter() }
composeRule.waitForIdle()
assertEquals(1, attempts.get())
triggerNetworkAvailable()
composeRule.waitForIdle()
assertEquals(1, attempts.get())
}
@Test
fun `restarts on every network event while still in network error`() {
setContent { rememberObservedPainter() }
composeRule.waitForIdle()
triggerNetworkAvailable()
composeRule.waitForIdle()
triggerNetworkAvailable()
composeRule.waitForIdle()
assertEquals(3, attempts.get())
}
@Test
fun `stops observing when painter leaves composition`() {
val visible = mutableStateOf(true)
setContent {
if (visible.value) rememberObservedPainter()
}
composeRule.waitForIdle()
val attemptsBefore = attempts.get()
visible.value = false
composeRule.waitForIdle()
triggerNetworkAvailable()
composeRule.waitForIdle()
assertEquals(attemptsBefore, attempts.get())
}
private fun expectRestart(error: (ImageRequest) -> ErrorResult) {
nextResult = error
setContent { rememberObservedPainter() }
composeRule.waitForIdle()
assertEquals(1, attempts.get())
triggerNetworkAvailable()
composeRule.waitForIdle()
assertEquals(2, attempts.get())
}
private fun expectNoRestart(error: (ImageRequest) -> ErrorResult) {
nextResult = error
setContent { rememberObservedPainter() }
composeRule.waitForIdle()
assertEquals(1, attempts.get())
triggerNetworkAvailable()
composeRule.waitForIdle()
assertEquals(1, attempts.get())
}
@Composable
private fun rememberObservedPainter(): AsyncImagePainter {
val painter = rememberAsyncImagePainter(
model = "https://example/img",
imageLoader = imageLoader,
)
painter.observeNetworkRestoration()
Image(painter = painter, contentDescription = null)
return painter
}
private fun setContent(content: @Composable () -> Unit) {
composeRule.setContent {
CompositionLocalProvider(LocalComponent provides localComponent) {
content()
}
}
}
private fun triggerNetworkAvailable() {
val network = ShadowNetwork.newInstance(1)
shadowConnectivityManager.networkCallbacks.toList().forEach { callback ->
callback.onAvailable(network)
}
}
private fun successResult(request: ImageRequest): SuccessResult {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
return SuccessResult(image = bitmap.asImage(), request = request)
}
private fun networkErrorResult(request: ImageRequest): ErrorResult =
ErrorResult(
image = null,
request = request,
throwable = UnknownHostException("offline"),
)
private fun httpErrorResult(request: ImageRequest, code: Int): ErrorResult =
ErrorResult(
image = null,
request = request,
throwable = HttpException(NetworkResponse().copy(code = code)),
)
}
@@ -0,0 +1,130 @@
package com.yandex.div.compose.internal
import android.content.Context
import android.net.ConnectivityManager
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows
import org.robolectric.shadows.ShadowConnectivityManager
import org.robolectric.shadows.ShadowNetwork
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class NetworkRestorationControllerTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private val shadowConnectivityManager: ShadowConnectivityManager = Shadows.shadowOf(
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
)
@Test
fun `registers exactly one network callback on creation`() {
NetworkRestorationController(context)
assertEquals(1, shadowConnectivityManager.networkCallbacks.size)
}
@Test
fun `emits networkRestored when system reports onAvailable`() = runTest(
UnconfinedTestDispatcher()
) {
val controller = NetworkRestorationController(context)
val received = mutableListOf<Unit>()
val job = launch { controller.networkRestored.toList(received) }
triggerNetworkAvailable()
assertEquals(1, received.size)
job.cancel()
}
@Test
fun `emits separately for each onAvailable event`() = runTest(
UnconfinedTestDispatcher()
) {
val controller = NetworkRestorationController(context)
val received = mutableListOf<Unit>()
val job = launch { controller.networkRestored.toList(received) }
triggerNetworkAvailable()
triggerNetworkAvailable()
triggerNetworkAvailable()
assertEquals(3, received.size)
job.cancel()
}
@Test
fun `does not emit before any onAvailable event`() = runTest(
UnconfinedTestDispatcher()
) {
val controller = NetworkRestorationController(context)
val received = mutableListOf<Unit>()
val job = launch { controller.networkRestored.toList(received) }
assertTrue(received.isEmpty())
job.cancel()
}
@Test
fun `multiple subscribers all receive the same event`() = runTest(
UnconfinedTestDispatcher()
) {
val controller = NetworkRestorationController(context)
val firstReceived = mutableListOf<Unit>()
val secondReceived = mutableListOf<Unit>()
val job1 = launch { controller.networkRestored.toList(firstReceived) }
val job2 = launch { controller.networkRestored.toList(secondReceived) }
triggerNetworkAvailable()
assertEquals(1, firstReceived.size)
assertEquals(1, secondReceived.size)
job1.cancel()
job2.cancel()
}
@Test
fun `late subscriber does not receive earlier events`() = runTest(
UnconfinedTestDispatcher()
) {
val controller = NetworkRestorationController(context)
triggerNetworkAvailable()
triggerNetworkAvailable()
val received = mutableListOf<Unit>()
val job = launch { controller.networkRestored.toList(received) }
assertTrue(received.isEmpty())
triggerNetworkAvailable()
assertEquals(1, received.size)
job.cancel()
}
@Test
fun `each controller registers its own callback`() {
NetworkRestorationController(context)
NetworkRestorationController(context)
assertEquals(2, shadowConnectivityManager.networkCallbacks.size)
}
private fun triggerNetworkAvailable() {
val network = ShadowNetwork.newInstance(1)
shadowConnectivityManager.networkCallbacks.toList().forEach { callback ->
callback.onAvailable(network)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB