Skip to content

Commit 7995cd5

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Support resource drawable URIs in Image.getSize() on Android (#56944)
Summary: `Image.getSize()` and `Image.getSizeWithHeaders()` always failed for Android drawable resource URIs (e.g. `"res_ic_home_filled_20"`) because Fresco's `fetchEncodedImage` pipeline does not handle `res://` URIs — it throws `IllegalArgumentException("Unsupported uri scheme for encoded image fetch!")`. This adds a fast path in `ImageLoaderModule` that detects resource URIs via `ImageSource.isResource` and resolves their intrinsic dimensions through Android's `Drawable` API (`ResourceDrawableIdHelper.getResourceDrawable()` -> `Drawable.getIntrinsicWidth/Height()`). This works for all drawable types including VectorDrawables, which are compiled XML and cannot be decoded by Fresco's raster-oriented pipeline. For non-resource URIs (network, file, content), the existing Fresco `fetchEncodedImage` path is unchanged. Changelog: [Android][Fixed] - Fix `Image.getSize()` failing for local drawable resource URIs including VectorDrawables Differential Revision: D106045223
1 parent 36bec56 commit 7995cd5

4 files changed

Lines changed: 410 additions & 1 deletion

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/image/ImageLoaderModule.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.facebook.react.module.annotations.ReactModule
3535
import com.facebook.react.modules.fresco.ReactNetworkImageRequest
3636
import com.facebook.react.views.image.ReactCallerContextFactory
3737
import com.facebook.react.views.imagehelper.ImageSource
38+
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
3839

3940
@ReactModule(name = NativeImageLoaderAndroidSpec.NAME)
4041
internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventListener {
@@ -85,6 +86,13 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL
8586
return
8687
}
8788
val source = ImageSource(reactApplicationContext, uriString)
89+
// Fast path: resolve resource drawables (including VectorDrawables) via the
90+
// Android resource system instead of Fresco's encoded-image pipeline, which
91+
// does not support res:// URIs.
92+
if (source.isResource) {
93+
resolveResourceSize(uriString, promise)
94+
return
95+
}
8896
val request: ImageRequest =
8997
ImageRequestBuilder.newBuilderWithSource(source.uri)
9098
.setRotationOptions(RotationOptions.disableRotation())
@@ -109,6 +117,11 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL
109117
return
110118
}
111119
val source = ImageSource(reactApplicationContext, uriString)
120+
// Fast path: resource drawables are resolved locally; headers are not applicable.
121+
if (source.isResource) {
122+
resolveResourceSize(uriString, promise)
123+
return
124+
}
112125
val imageRequestBuilder: ImageRequestBuilder =
113126
ImageRequestBuilder.newBuilderWithSource(source.uri)
114127
.setRotationOptions(RotationOptions.disableRotation())
@@ -167,6 +180,37 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL
167180
}
168181
}
169182

183+
/**
184+
* Resolve the intrinsic size of a drawable resource by name. Works for all drawable types
185+
* including VectorDrawable, which cannot be decoded by Fresco's encoded-image pipeline.
186+
*
187+
* Drawables without intrinsic dimensions (e.g. ColorDrawable) will cause the promise to be
188+
* rejected since there is no meaningful size to return.
189+
*/
190+
private fun resolveResourceSize(name: String, promise: Promise) {
191+
val context = reactApplicationContext
192+
val drawable = ResourceDrawableIdHelper.getResourceDrawable(context, name)
193+
if (drawable == null) {
194+
promise.reject(ERROR_GET_SIZE_FAILURE, "Could not resolve drawable resource: $name")
195+
return
196+
}
197+
val width = drawable.intrinsicWidth
198+
val height = drawable.intrinsicHeight
199+
if (width < 0 || height < 0) {
200+
promise.reject(
201+
ERROR_GET_SIZE_FAILURE,
202+
"Drawable resource has no intrinsic size: $name",
203+
)
204+
return
205+
}
206+
promise.resolve(
207+
buildReadableMap {
208+
put("width", width)
209+
put("height", height)
210+
}
211+
)
212+
}
213+
170214
/**
171215
* Prefetches the given image to the Fresco image disk cache.
172216
*
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.modules.image
9+
10+
import android.graphics.drawable.Drawable
11+
import com.facebook.react.bridge.Promise
12+
import com.facebook.react.bridge.ReactTestHelper
13+
import com.facebook.react.bridge.ReadableMap
14+
import com.facebook.react.bridge.WritableMap
15+
import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
16+
import com.facebook.testutils.shadows.ShadowArguments
17+
import com.facebook.testutils.shadows.ShadowSoLoader
18+
import org.assertj.core.api.Assertions.assertThat
19+
import org.junit.After
20+
import org.junit.Before
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.mockito.MockedStatic
24+
import org.mockito.Mockito.mockStatic
25+
import org.mockito.kotlin.any
26+
import org.mockito.kotlin.eq
27+
import org.mockito.kotlin.mock
28+
import org.mockito.kotlin.whenever
29+
import org.robolectric.RobolectricTestRunner
30+
import org.robolectric.annotation.Config
31+
32+
@Config(shadows = [ShadowArguments::class, ShadowSoLoader::class])
33+
@RunWith(RobolectricTestRunner::class)
34+
class ImageLoaderModuleTest {
35+
36+
private lateinit var imageLoaderModule: ImageLoaderModule
37+
private lateinit var mockedHelper: MockedStatic<ResourceDrawableIdHelper>
38+
39+
@Before
40+
fun setUp() {
41+
val reactContext = ReactTestHelper.createCatalystContextForTest()
42+
imageLoaderModule = ImageLoaderModule(reactContext)
43+
44+
mockedHelper = mockStatic(ResourceDrawableIdHelper::class.java)
45+
// By default, getResourceDrawableUri returns a res:// URI so ImageSource.isResource is true
46+
// when the source string has no scheme. We need getResourceDrawableId to return a valid ID
47+
// for the source to be treated as a resource.
48+
mockedHelper
49+
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), any()) }
50+
.thenReturn(0)
51+
}
52+
53+
@After
54+
fun tearDown() {
55+
mockedHelper.close()
56+
}
57+
58+
@Test
59+
fun testGetSizeWithVectorDrawableResource() {
60+
val drawableName = "res_ic_home_filled_20"
61+
val expectedWidth = 20
62+
val expectedHeight = 20
63+
64+
val mockDrawable = mock<Drawable>()
65+
whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth)
66+
whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight)
67+
68+
mockedHelper
69+
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
70+
.thenReturn(12345)
71+
mockedHelper
72+
.`when`<Drawable?> { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) }
73+
.thenReturn(mockDrawable)
74+
75+
val promise = SimplePromise()
76+
imageLoaderModule.getSize(drawableName, promise)
77+
78+
assertThat(promise.resolved).isEqualTo(1)
79+
assertThat(promise.rejected).isEqualTo(0)
80+
81+
val result = promise.value as ReadableMap
82+
assertThat(result.getInt("width")).isEqualTo(expectedWidth)
83+
assertThat(result.getInt("height")).isEqualTo(expectedHeight)
84+
}
85+
86+
@Test
87+
fun testGetSizeWithHeadersWithVectorDrawableResource() {
88+
val drawableName = "res_ic_home_filled_20"
89+
val expectedWidth = 48
90+
val expectedHeight = 48
91+
92+
val mockDrawable = mock<Drawable>()
93+
whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth)
94+
whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight)
95+
96+
mockedHelper
97+
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
98+
.thenReturn(12345)
99+
mockedHelper
100+
.`when`<Drawable?> { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) }
101+
.thenReturn(mockDrawable)
102+
103+
val promise = SimplePromise()
104+
imageLoaderModule.getSizeWithHeaders(drawableName, null, promise)
105+
106+
assertThat(promise.resolved).isEqualTo(1)
107+
assertThat(promise.rejected).isEqualTo(0)
108+
109+
val result = promise.value as ReadableMap
110+
assertThat(result.getInt("width")).isEqualTo(expectedWidth)
111+
assertThat(result.getInt("height")).isEqualTo(expectedHeight)
112+
}
113+
114+
@Test
115+
fun testGetSizeWithNonExistentResource() {
116+
val drawableName = "res_nonexistent_icon"
117+
118+
// getResourceDrawableId returns 0 for unknown resources; getResourceDrawable returns null
119+
mockedHelper
120+
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
121+
.thenReturn(0)
122+
mockedHelper
123+
.`when`<Drawable?> { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) }
124+
.thenReturn(null)
125+
126+
val promise = SimplePromise()
127+
imageLoaderModule.getSize(drawableName, promise)
128+
129+
assertThat(promise.rejected).isEqualTo(1)
130+
assertThat(promise.resolved).isEqualTo(0)
131+
assertThat(promise.errorCode).isEqualTo("E_GET_SIZE_FAILURE")
132+
}
133+
134+
@Test
135+
fun testGetSizeWithDrawableWithNoIntrinsicSize() {
136+
val drawableName = "res_color_drawable"
137+
138+
val mockDrawable = mock<Drawable>()
139+
// ColorDrawable and similar return -1 for intrinsic dimensions
140+
whenever(mockDrawable.intrinsicWidth).thenReturn(-1)
141+
whenever(mockDrawable.intrinsicHeight).thenReturn(-1)
142+
143+
mockedHelper
144+
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
145+
.thenReturn(12345)
146+
mockedHelper
147+
.`when`<Drawable?> { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) }
148+
.thenReturn(mockDrawable)
149+
150+
val promise = SimplePromise()
151+
imageLoaderModule.getSize(drawableName, promise)
152+
153+
assertThat(promise.rejected).isEqualTo(1)
154+
assertThat(promise.resolved).isEqualTo(0)
155+
assertThat(promise.errorCode).isEqualTo("E_GET_SIZE_FAILURE")
156+
assertThat(promise.errorMessage).contains("no intrinsic size")
157+
}
158+
159+
@Test
160+
fun testGetSizeWithEmptyUri() {
161+
val promise = SimplePromise()
162+
imageLoaderModule.getSize("", promise)
163+
164+
assertThat(promise.rejected).isEqualTo(1)
165+
assertThat(promise.resolved).isEqualTo(0)
166+
assertThat(promise.errorCode).isEqualTo("E_INVALID_URI")
167+
}
168+
169+
@Test
170+
fun testGetSizeWithNullUri() {
171+
val promise = SimplePromise()
172+
imageLoaderModule.getSize(null, promise)
173+
174+
assertThat(promise.rejected).isEqualTo(1)
175+
assertThat(promise.resolved).isEqualTo(0)
176+
assertThat(promise.errorCode).isEqualTo("E_INVALID_URI")
177+
}
178+
179+
internal class SimplePromise : Promise {
180+
companion object {
181+
private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED"
182+
private const val ERROR_DEFAULT_MESSAGE = "Error not specified."
183+
}
184+
185+
var resolved = 0
186+
private set
187+
188+
var rejected = 0
189+
private set
190+
191+
var value: Any? = null
192+
private set
193+
194+
var errorCode: String? = null
195+
private set
196+
197+
var errorMessage: String? = null
198+
private set
199+
200+
override fun resolve(value: Any?) {
201+
resolved++
202+
this.value = value
203+
}
204+
205+
override fun reject(code: String?, message: String?) {
206+
reject(code, message, null, null)
207+
}
208+
209+
override fun reject(code: String?, throwable: Throwable?) {
210+
reject(code, null, throwable, null)
211+
}
212+
213+
override fun reject(code: String?, message: String?, throwable: Throwable?) {
214+
reject(code, message, throwable, null)
215+
}
216+
217+
override fun reject(throwable: Throwable) {
218+
reject(null, null, throwable, null)
219+
}
220+
221+
override fun reject(throwable: Throwable, userInfo: WritableMap) {
222+
reject(null, null, throwable, userInfo)
223+
}
224+
225+
override fun reject(code: String?, userInfo: WritableMap) {
226+
reject(code, null, null, userInfo)
227+
}
228+
229+
override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) {
230+
reject(code, null, throwable, userInfo)
231+
}
232+
233+
override fun reject(code: String?, message: String?, userInfo: WritableMap) {
234+
reject(code, message, null, userInfo)
235+
}
236+
237+
override fun reject(
238+
code: String?,
239+
message: String?,
240+
throwable: Throwable?,
241+
userInfo: WritableMap?,
242+
) {
243+
rejected++
244+
errorCode = code ?: ERROR_DEFAULT_CODE
245+
errorMessage = message ?: throwable?.message ?: ERROR_DEFAULT_MESSAGE
246+
}
247+
248+
@Deprecated("Method deprecated", ReplaceWith("reject(code, message)"))
249+
override fun reject(message: String) {
250+
reject(null, message, null, null)
251+
}
252+
}
253+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="24"
5+
android:viewportHeight="24">
6+
<path
7+
android:fillColor="#4CAF50"
8+
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
9+
</vector>

0 commit comments

Comments
 (0)