Supported network restoration observer
commit_hash:eb5598267f2eabc885995201f4c9a8af70f7764a
@@ -604,6 +604,7 @@
|
||||
"client/android/compose/build.gradle.kts":"divkit/public/client/android/compose/build.gradle.kts",
|
||||
"client/android/compose/proguard-rules.pro":"divkit/public/client/android/compose/proguard-rules.pro",
|
||||
"client/android/compose/src/androidTest/kotlin/com/yandex/div/compose/DivViewPreview.kt":"divkit/public/client/android/compose/src/androidTest/kotlin/com/yandex/div/compose/DivViewPreview.kt",
|
||||
"client/android/compose/src/main/AndroidManifest.xml":"divkit/public/client/android/compose/src/main/AndroidManifest.xml",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/DivComposeConfiguration.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/DivComposeConfiguration.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/DivContext.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/DivContext.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/DivException.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/DivException.kt",
|
||||
@@ -640,11 +641,13 @@
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/expressions/EvaluatorWarningSender.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/expressions/EvaluatorWarningSender.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/extensions/DivExtensionEnvironment.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/extensions/DivExtensionEnvironment.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/extensions/DivExtensionHandler.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/extensions/DivExtensionHandler.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageNetworkRestoration.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageNetworkRestoration.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageRequestListener.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageRequestListener.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageRequestParams.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/images/ImageRequestParams.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/DivDebugConfiguration.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/DivDebugConfiguration.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/DivDebugFeatures.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/DivDebugFeatures.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/ImageLoaderProvider.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/ImageLoaderProvider.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/NetworkRestorationController.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/internal/NetworkRestorationController.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/triggers/DivTriggerStorage.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/triggers/DivTriggerStorage.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/utils/Alignment.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/utils/Alignment.kt",
|
||||
"client/android/compose/src/main/kotlin/com/yandex/div/compose/utils/Context.kt":"divkit/public/client/android/compose/src/main/kotlin/com/yandex/div/compose/utils/Context.kt",
|
||||
@@ -729,6 +732,8 @@
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/actions/UpdateStructureActionHandlerTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/actions/UpdateStructureActionHandlerTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/actions/VisibilityActionTrackerTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/actions/VisibilityActionTrackerTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/expressions/DivComposeExpressionResolverTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/expressions/DivComposeExpressionResolverTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/images/ImageNetworkRestorationTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/images/ImageNetworkRestorationTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/internal/NetworkRestorationControllerTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/internal/NetworkRestorationControllerTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/triggers/DivTriggerStorageTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/triggers/DivTriggerStorageTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/utils/DivReporterTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/utils/DivReporterTest.kt",
|
||||
"client/android/compose/src/test/kotlin/com/yandex/div/compose/utils/ExpressionUtilsTest.kt":"divkit/public/client/android/compose/src/test/kotlin/com/yandex/div/compose/utils/ExpressionUtilsTest.kt",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.3 KiB |