From e5dd7d68bf264669fc5c4ce5e69b24249d28558b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateo=20Guzm=C3=A1n?= Date: Thu, 31 Oct 2024 09:07:40 -0700 Subject: [PATCH] feat(image): enabling basic cache control for android (#47182) Summary: Fixes https://github.com/facebook/react-native/issues/12606 Previously, `Image` cache control options were not functional on Android, even though they were being passed to the native component via the `source` prop. This PR addresses that by implementing logic to manage cache behaviour on Android. When the `reload` option is explicitly set, the image is now evicted from both memory and disk caches before a new request is made. This ensures the image is always fetched from the source, aligning the caching behaviour between Android and iOS for the `default` and `reload` options. ## Changelog: [ANDROID][ADDED] - Enabling basic `Image` cache control for Android Pull Request resolved: https://github.com/facebook/react-native/pull/47182 Test Plan: Added a new example to the `rn-tester`, where we can notice that the image on the right is reloaded if rendered or re-rendered as the cache policy is set to `reload`. The image on the left has the cache policy set to `default` and won't be re-rendered as the image is already in the cache. See the video below: https://github.com/user-attachments/assets/88bc1d2d-0239-4deb-bcde-fe0ce521ff4d Also tested on both old and new architecture. Reviewed By: NickGerleman Differential Revision: D64915440 Pulled By: Abbondanzo fbshipit-source-id: 32e1c55dd20bf96ab0f69ef900d821c3c2552ef7 --- .../Libraries/Image/ImageSource.d.ts | 2 +- .../Libraries/Image/ImageSource.js | 2 +- .../ReactAndroid/api/ReactAndroid.api | 17 +++++- .../react/modules/fresco/ImageCacheControl.kt | 18 ++++++ .../fresco/ReactNetworkImageRequest.kt | 9 ++- .../fresco/ReactOkHttpNetworkFetcher.kt | 15 +++-- .../react/views/image/ReactImageView.kt | 29 +++++++-- .../react/views/imagehelper/ImageSource.kt | 11 ++-- .../js/examples/Image/ImageExample.js | 61 +++++++++++++++++++ 9 files changed, 144 insertions(+), 20 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ImageCacheControl.kt diff --git a/packages/react-native/Libraries/Image/ImageSource.d.ts b/packages/react-native/Libraries/Image/ImageSource.d.ts index 0e163309a87a76..1b7d51729b5d42 100644 --- a/packages/react-native/Libraries/Image/ImageSource.d.ts +++ b/packages/react-native/Libraries/Image/ImageSource.d.ts @@ -51,7 +51,7 @@ export interface ImageURISource { * to a URL load request, no attempt is made to load the data from the originating source, * and the load is considered to have failed. * - * @platform ios + * @platform ios (for `force-cache` and `only-if-cached`) */ cache?: 'default' | 'reload' | 'force-cache' | 'only-if-cached' | undefined; /** diff --git a/packages/react-native/Libraries/Image/ImageSource.js b/packages/react-native/Libraries/Image/ImageSource.js index bbb32572125c11..a489de2ff07409 100644 --- a/packages/react-native/Libraries/Image/ImageSource.js +++ b/packages/react-native/Libraries/Image/ImageSource.js @@ -66,7 +66,7 @@ export interface ImageURISource { * to a URL load request, no attempt is made to load the data from the originating source, * and the load is considered to have failed. * - * @platform ios + * @platform ios (for `force-cache` and `only-if-cached`) */ +cache?: ?('default' | 'reload' | 'force-cache' | 'only-if-cached'); diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6eba8f35ba9b8c..e6594e9757936e 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3377,14 +3377,25 @@ public final class com/facebook/react/modules/fresco/FrescoModule$Companion { public final fun hasBeenInitialized ()Z } +public final class com/facebook/react/modules/fresco/ImageCacheControl : java/lang/Enum { + public static final field DEFAULT Lcom/facebook/react/modules/fresco/ImageCacheControl; + public static final field RELOAD Lcom/facebook/react/modules/fresco/ImageCacheControl; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/facebook/react/modules/fresco/ImageCacheControl; + public static fun values ()[Lcom/facebook/react/modules/fresco/ImageCacheControl; +} + public final class com/facebook/react/modules/fresco/ReactNetworkImageRequest : com/facebook/imagepipeline/request/ImageRequest { public static final field Companion Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion; - public synthetic fun (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public static final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest; + public static final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest; } public final class com/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion { public final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest; + public final fun fromBuilderWithHeaders (Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest; + public static synthetic fun fromBuilderWithHeaders$default (Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest$Companion;Lcom/facebook/imagepipeline/request/ImageRequestBuilder;Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/modules/fresco/ImageCacheControl;ILjava/lang/Object;)Lcom/facebook/react/modules/fresco/ReactNetworkImageRequest; } public final class com/facebook/react/modules/fresco/SystraceRequestListener : com/facebook/imagepipeline/listener/BaseRequestListener { @@ -6707,8 +6718,10 @@ public class com/facebook/react/views/imagehelper/ImageSource { public fun (Landroid/content/Context;Ljava/lang/String;)V public fun (Landroid/content/Context;Ljava/lang/String;D)V public fun (Landroid/content/Context;Ljava/lang/String;DD)V - public synthetic fun (Landroid/content/Context;Ljava/lang/String;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/content/Context;Ljava/lang/String;DDLcom/facebook/react/modules/fresco/ImageCacheControl;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;DDLcom/facebook/react/modules/fresco/ImageCacheControl;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun equals (Ljava/lang/Object;)Z + public final fun getCacheControl ()Lcom/facebook/react/modules/fresco/ImageCacheControl; public final fun getSize ()D public final fun getSource ()Ljava/lang/String; public static final fun getTransparentBitmapImageSource (Landroid/content/Context;)Lcom/facebook/react/views/imagehelper/ImageSource; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ImageCacheControl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ImageCacheControl.kt new file mode 100644 index 00000000000000..689edb1d6f013d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ImageCacheControl.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.modules.fresco + +public enum class ImageCacheControl { + /** Uses OkHttp's default cache control policy with no store. */ + DEFAULT, + /** + * The data for the URL will be loaded from the originating source. No existing cache data should + * be used to satisfy a URL load request. + */ + RELOAD, +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactNetworkImageRequest.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactNetworkImageRequest.kt index 44d2b0436855ab..a5ef849a757946 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactNetworkImageRequest.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactNetworkImageRequest.kt @@ -16,16 +16,19 @@ public class ReactNetworkImageRequest private constructor( builder: ImageRequestBuilder, /** Headers for the request */ - internal val headers: ReadableMap? + internal val headers: ReadableMap?, + internal val cacheControl: ImageCacheControl, ) : ImageRequest(builder) { public companion object { @JvmStatic + @JvmOverloads public fun fromBuilderWithHeaders( builder: ImageRequestBuilder, - headers: ReadableMap? + headers: ReadableMap?, + cacheControl: ImageCacheControl = ImageCacheControl.DEFAULT, ): ReactNetworkImageRequest { - return ReactNetworkImageRequest(builder, headers) + return ReactNetworkImageRequest(builder, headers, cacheControl) } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactOkHttpNetworkFetcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactOkHttpNetworkFetcher.kt index 54f387e814d080..49717cfa51ab92 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactOkHttpNetworkFetcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/fresco/ReactOkHttpNetworkFetcher.kt @@ -35,22 +35,27 @@ internal class ReactOkHttpNetworkFetcher(private val okHttpClient: OkHttpClient) fetchState.submitTime = SystemClock.elapsedRealtime() val uri = fetchState.uri var requestHeaders: Map? = null + val cacheControlBuilder = CacheControl.Builder().noStore() if (fetchState.context.imageRequest is ReactNetworkImageRequest) { val networkImageRequest = fetchState.context.imageRequest as ReactNetworkImageRequest requestHeaders = getHeaders(networkImageRequest.headers) + when (networkImageRequest.cacheControl) { + ImageCacheControl.RELOAD -> { + cacheControlBuilder.noCache() + } + ImageCacheControl.DEFAULT -> { + // No-op + } + } } val headers = OkHttpCompat.getHeadersFromMap(requestHeaders) val request = Request.Builder() - .cacheControl(CacheControl.Builder().noStore().build()) + .cacheControl(cacheControlBuilder.build()) .url(uri.toString()) .headers(headers) .get() .build() fetchWithRequest(fetchState, callback, request) } - - private companion object { - private const val TAG = "ReactOkHttpNetworkFetcher" - } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt index 6a2a6c922ed0aa..b384b5e2f2e651 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/image/ReactImageView.kt @@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable import android.net.Uri import com.facebook.common.references.CloseableReference import com.facebook.common.util.UriUtil +import com.facebook.drawee.backends.pipeline.Fresco import com.facebook.drawee.controller.AbstractDraweeControllerBuilder import com.facebook.drawee.controller.ControllerListener import com.facebook.drawee.controller.ForwardingControllerListener @@ -49,6 +50,7 @@ import com.facebook.react.common.annotations.UnstableReactNativeAPI import com.facebook.react.common.annotations.VisibleForTesting import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.modules.fresco.ImageCacheControl import com.facebook.react.modules.fresco.ReactNetworkImageRequest import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.LengthPercentage @@ -268,7 +270,8 @@ public class ReactImageView( } else if (sources.size() == 1) { // Optimize for the case where we have just one uri, case in which we don't need the sizes val source = sources.getMap(0) - var imageSource = ImageSource(context, source.getString("uri")) + val cacheControl = computeCacheControl(source.getString("cache")) + var imageSource = ImageSource(context, source.getString("uri"), cacheControl = cacheControl) if (Uri.EMPTY == imageSource.uri) { warnImageSource(source.getString("uri")) imageSource = getTransparentBitmapImageSource(context) @@ -277,12 +280,14 @@ public class ReactImageView( } else { for (idx in 0 until sources.size()) { val source = sources.getMap(idx) + val cacheControl = computeCacheControl(source.getString("cache")) var imageSource = ImageSource( context, source.getString("uri"), source.getDouble("width"), - source.getDouble("height")) + source.getDouble("height"), + cacheControl) if (Uri.EMPTY == imageSource.uri) { warnImageSource(source.getString("uri")) imageSource = getTransparentBitmapImageSource(context) @@ -301,6 +306,15 @@ public class ReactImageView( isDirty = true } + private fun computeCacheControl(cacheControl: String?): ImageCacheControl { + return when (cacheControl) { + null, + "default" -> ImageCacheControl.DEFAULT + "reload" -> ImageCacheControl.RELOAD + else -> ImageCacheControl.DEFAULT + } + } + public fun setDefaultSource(name: String?) { val newDefaultDrawable = instance.getResourceDrawable(context, name) if (defaultImageDrawable != newDefaultDrawable) { @@ -409,7 +423,9 @@ public class ReactImageView( } private fun maybeUpdateViewFromRequest(doResize: Boolean) { - val uri = this.imageSource?.uri ?: return + val imageSource = this.imageSource ?: return + val uri = imageSource.uri + val cacheControl = imageSource.cacheControl val postprocessorList = mutableListOf() iterativeBoxBlurPostProcessor?.let { postprocessorList.add(it) } @@ -418,6 +434,11 @@ public class ReactImageView( val resizeOptions = if (doResize) resizeOptions else null + if (cacheControl == ImageCacheControl.RELOAD) { + val imagePipeline = Fresco.getImagePipeline() + imagePipeline.evictFromCache(uri) + } + val imageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(uri) .setPostprocessor(postprocessor) @@ -430,7 +451,7 @@ public class ReactImageView( } val imageRequest: ImageRequest = - ReactNetworkImageRequest.fromBuilderWithHeaders(imageRequestBuilder, headers) + ReactNetworkImageRequest.fromBuilderWithHeaders(imageRequestBuilder, headers, cacheControl) globalImageLoadListener?.onLoadAttempt(uri) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ImageSource.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ImageSource.kt index 947044cf43e0ac..289dd445bfb006 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ImageSource.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/imagehelper/ImageSource.kt @@ -9,6 +9,7 @@ package com.facebook.react.views.imagehelper import android.content.Context import android.net.Uri +import com.facebook.react.modules.fresco.ImageCacheControl import java.util.Objects /** Class describing an image source (network URI or resource) and size. */ @@ -19,7 +20,8 @@ constructor( /** Get the source of this image, as it was passed to the constructor. */ public val source: String?, width: Double = 0.0, - height: Double = 0.0 + height: Double = 0.0, + public val cacheControl: ImageCacheControl = ImageCacheControl.DEFAULT, ) { /** Get the URI for this image - can be either a parsed network URI or a resource URI. */ @@ -45,10 +47,11 @@ constructor( return java.lang.Double.compare(that.size, size) == 0 && isResource == that.isResource && uri == that.uri && - source == that.source + source == that.source && + cacheControl == that.cacheControl } - override fun hashCode(): Int = Objects.hash(uri, source, size, isResource) + override fun hashCode(): Int = Objects.hash(uri, source, size, isResource, cacheControl) private fun computeUri(context: Context): Uri = try { @@ -70,6 +73,6 @@ constructor( @JvmStatic public fun getTransparentBitmapImageSource(context: Context): ImageSource = - ImageSource(context, TRANSPARENT_BITMAP_URI) + ImageSource(context, TRANSPARENT_BITMAP_URI, cacheControl = ImageCacheControl.DEFAULT) } } diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index 1a4077b8b73fe0..814c7312ea775d 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -13,6 +13,7 @@ import type {ImageProps} from 'react-native/Libraries/Image/ImageProps'; import type {LayoutEvent} from 'react-native/Libraries/Types/CoreEventTypes'; +import RNTesterButton from '../../components/RNTesterButton'; import RNTesterText from '../../components/RNTesterText'; import ImageCapInsetsExample from './ImageCapInsetsExample'; import React from 'react'; @@ -626,6 +627,51 @@ class VectorDrawableExample extends React.Component< } } +function CacheControlAndroidExample(): React.Node { + const [reload, setReload] = React.useState(0); + + const onReload = () => { + setReload(prevReload => prevReload + 1); + }; + + return ( + <> + + + Default + + + + Reload + + + + + + + + Re-render image components + + + + + ); +} + const fullImage: ImageSource = { uri: IMAGE2, }; @@ -863,6 +909,11 @@ const styles = StyleSheet.create({ height: 100, width: '500%', }, + cachePolicyAndroidButtonContainer: { + flex: 1, + alignItems: 'center', + marginTop: 10, + }, }); exports.displayName = (undefined: ?string); @@ -1038,6 +1089,16 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'Cache Policy', + description: ('First image will be loaded and will be cached. ' + + 'Second image is the same but will be reloaded if re-rendered ' + + 'as the cache policy is set to reload.': string), + render: function (): React.Node { + return ; + }, + platform: 'android', + }, { title: 'Borders', name: 'borders',