Skip to content

Commit 27720cc

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 21dfc2a commit 27720cc

4 files changed

Lines changed: 437 additions & 1 deletion

File tree

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

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