From 66bc8bc0ae9fda64e8c780927bc4147afaae22ec Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 22 May 2026 08:39:22 -0700 Subject: [PATCH 1/2] Support resource drawable URIs in `Image.getSize()` on Android (#56944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../react/modules/image/ImageLoaderModule.kt | 48 ++++ .../modules/image/ImageLoaderModuleTest.kt | 253 ++++++++++++++++++ .../main/res/drawable/ic_vector_test_24.xml | 9 + .../js/examples/Image/ImageExample.js | 105 +++++++- 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/image/ImageLoaderModuleTest.kt create mode 100644 packages/rn-tester/android/app/src/main/res/drawable/ic_vector_test_24.xml diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/image/ImageLoaderModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/image/ImageLoaderModule.kt index 509efc1f7b06..e94c836cdfe0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/image/ImageLoaderModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/image/ImageLoaderModule.kt @@ -35,6 +35,7 @@ import com.facebook.react.module.annotations.ReactModule import com.facebook.react.modules.fresco.ReactNetworkImageRequest import com.facebook.react.views.image.ReactCallerContextFactory import com.facebook.react.views.imagehelper.ImageSource +import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper @ReactModule(name = NativeImageLoaderAndroidSpec.NAME) internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventListener { @@ -85,6 +86,13 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL return } val source = ImageSource(reactApplicationContext, uriString) + // Fast path: resolve resource drawables (including VectorDrawables) via the + // Android resource system instead of Fresco's encoded-image pipeline, which + // does not support res:// URIs. + if (source.isResource) { + resolveResourceSize(uriString, promise) + return + } val request: ImageRequest = ImageRequestBuilder.newBuilderWithSource(source.uri) .setRotationOptions(RotationOptions.disableRotation()) @@ -109,6 +117,11 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL return } val source = ImageSource(reactApplicationContext, uriString) + // Fast path: resource drawables are resolved locally; headers are not applicable. + if (source.isResource) { + resolveResourceSize(uriString, promise) + return + } val imageRequestBuilder: ImageRequestBuilder = ImageRequestBuilder.newBuilderWithSource(source.uri) .setRotationOptions(RotationOptions.disableRotation()) @@ -167,6 +180,41 @@ internal class ImageLoaderModule : NativeImageLoaderAndroidSpec, LifecycleEventL } } + /** + * Resolve the intrinsic size of a drawable resource by name. Works for all drawable types + * including VectorDrawable, which cannot be decoded by Fresco's encoded-image pipeline. + * + * Drawables without intrinsic dimensions (e.g. ColorDrawable) will cause the promise to be + * rejected since there is no meaningful size to return. + */ + private fun resolveResourceSize(name: String, promise: Promise) { + val context = reactApplicationContext + if (context == null) { + promise.reject(ERROR_GET_SIZE_FAILURE, "React context is not available") + return + } + val drawable = ResourceDrawableIdHelper.getResourceDrawable(context, name) + if (drawable == null) { + promise.reject(ERROR_GET_SIZE_FAILURE, "Could not resolve drawable resource: $name") + return + } + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + if (width < 0 || height < 0) { + promise.reject( + ERROR_GET_SIZE_FAILURE, + "Drawable resource has no intrinsic size: $name", + ) + return + } + promise.resolve( + buildReadableMap { + put("width", width) + put("height", height) + } + ) + } + /** * Prefetches the given image to the Fresco image disk cache. * diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/image/ImageLoaderModuleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/image/ImageLoaderModuleTest.kt new file mode 100644 index 000000000000..1ec9bc74037a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/modules/image/ImageLoaderModuleTest.kt @@ -0,0 +1,253 @@ +/* + * 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.image + +import android.graphics.drawable.Drawable +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper +import com.facebook.testutils.shadows.ShadowArguments +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Config(shadows = [ShadowArguments::class, ShadowSoLoader::class]) +@RunWith(RobolectricTestRunner::class) +class ImageLoaderModuleTest { + + private lateinit var imageLoaderModule: ImageLoaderModule + private lateinit var mockedHelper: MockedStatic + + @Before + fun setUp() { + val reactContext = ReactTestHelper.createCatalystContextForTest() + imageLoaderModule = ImageLoaderModule(reactContext) + + mockedHelper = mockStatic(ResourceDrawableIdHelper::class.java) + // By default, getResourceDrawableUri returns a res:// URI so ImageSource.isResource is true + // when the source string has no scheme. We need getResourceDrawableId to return a valid ID + // for the source to be treated as a resource. + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawableId(any(), any()) } + .thenReturn(0) + } + + @After + fun tearDown() { + mockedHelper.close() + } + + @Test + fun testGetSizeWithVectorDrawableResource() { + val drawableName = "res_ic_home_filled_20" + val expectedWidth = 20 + val expectedHeight = 20 + + val mockDrawable = mock() + whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight) + + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) } + .thenReturn(12345) + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) } + .thenReturn(mockDrawable) + + val promise = SimplePromise() + imageLoaderModule.getSize(drawableName, promise) + + assertThat(promise.resolved).isEqualTo(1) + assertThat(promise.rejected).isEqualTo(0) + + val result = promise.value as ReadableMap + assertThat(result.getInt("width")).isEqualTo(expectedWidth) + assertThat(result.getInt("height")).isEqualTo(expectedHeight) + } + + @Test + fun testGetSizeWithHeadersWithVectorDrawableResource() { + val drawableName = "res_ic_home_filled_20" + val expectedWidth = 48 + val expectedHeight = 48 + + val mockDrawable = mock() + whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth) + whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight) + + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) } + .thenReturn(12345) + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) } + .thenReturn(mockDrawable) + + val promise = SimplePromise() + imageLoaderModule.getSizeWithHeaders(drawableName, null, promise) + + assertThat(promise.resolved).isEqualTo(1) + assertThat(promise.rejected).isEqualTo(0) + + val result = promise.value as ReadableMap + assertThat(result.getInt("width")).isEqualTo(expectedWidth) + assertThat(result.getInt("height")).isEqualTo(expectedHeight) + } + + @Test + fun testGetSizeWithNonExistentResource() { + val drawableName = "res_nonexistent_icon" + + // getResourceDrawableId returns 0 for unknown resources; getResourceDrawable returns null + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) } + .thenReturn(0) + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) } + .thenReturn(null) + + val promise = SimplePromise() + imageLoaderModule.getSize(drawableName, promise) + + assertThat(promise.rejected).isEqualTo(1) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.errorCode).isEqualTo("E_GET_SIZE_FAILURE") + } + + @Test + fun testGetSizeWithDrawableWithNoIntrinsicSize() { + val drawableName = "res_color_drawable" + + val mockDrawable = mock() + // ColorDrawable and similar return -1 for intrinsic dimensions + whenever(mockDrawable.intrinsicWidth).thenReturn(-1) + whenever(mockDrawable.intrinsicHeight).thenReturn(-1) + + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) } + .thenReturn(12345) + mockedHelper + .`when` { ResourceDrawableIdHelper.getResourceDrawable(any(), eq(drawableName)) } + .thenReturn(mockDrawable) + + val promise = SimplePromise() + imageLoaderModule.getSize(drawableName, promise) + + assertThat(promise.rejected).isEqualTo(1) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.errorCode).isEqualTo("E_GET_SIZE_FAILURE") + assertThat(promise.errorMessage).contains("no intrinsic size") + } + + @Test + fun testGetSizeWithEmptyUri() { + val promise = SimplePromise() + imageLoaderModule.getSize("", promise) + + assertThat(promise.rejected).isEqualTo(1) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.errorCode).isEqualTo("E_INVALID_URI") + } + + @Test + fun testGetSizeWithNullUri() { + val promise = SimplePromise() + imageLoaderModule.getSize(null, promise) + + assertThat(promise.rejected).isEqualTo(1) + assertThat(promise.resolved).isEqualTo(0) + assertThat(promise.errorCode).isEqualTo("E_INVALID_URI") + } + + internal class SimplePromise : Promise { + companion object { + private const val ERROR_DEFAULT_CODE = "EUNSPECIFIED" + private const val ERROR_DEFAULT_MESSAGE = "Error not specified." + } + + var resolved = 0 + private set + + var rejected = 0 + private set + + var value: Any? = null + private set + + var errorCode: String? = null + private set + + var errorMessage: String? = null + private set + + override fun resolve(value: Any?) { + resolved++ + this.value = value + } + + override fun reject(code: String?, message: String?) { + reject(code, message, null, null) + } + + override fun reject(code: String?, throwable: Throwable?) { + reject(code, null, throwable, null) + } + + override fun reject(code: String?, message: String?, throwable: Throwable?) { + reject(code, message, throwable, null) + } + + override fun reject(throwable: Throwable) { + reject(null, null, throwable, null) + } + + override fun reject(throwable: Throwable, userInfo: WritableMap) { + reject(null, null, throwable, userInfo) + } + + override fun reject(code: String?, userInfo: WritableMap) { + reject(code, null, null, userInfo) + } + + override fun reject(code: String?, throwable: Throwable?, userInfo: WritableMap) { + reject(code, null, throwable, userInfo) + } + + override fun reject(code: String?, message: String?, userInfo: WritableMap) { + reject(code, message, null, userInfo) + } + + override fun reject( + code: String?, + message: String?, + throwable: Throwable?, + userInfo: WritableMap?, + ) { + rejected++ + errorCode = code ?: ERROR_DEFAULT_CODE + errorMessage = message ?: throwable?.message ?: ERROR_DEFAULT_MESSAGE + } + + @Deprecated("Method deprecated", ReplaceWith("reject(code, message)")) + override fun reject(message: String) { + reject(null, message, null, null) + } + } +} diff --git a/packages/rn-tester/android/app/src/main/res/drawable/ic_vector_test_24.xml b/packages/rn-tester/android/app/src/main/res/drawable/ic_vector_test_24.xml new file mode 100644 index 000000000000..2a569ecc42c1 --- /dev/null +++ b/packages/rn-tester/android/app/src/main/res/drawable/ic_vector_test_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index a628ed492c22..44301cbe3f1f 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -20,7 +20,14 @@ import RNTesterPlatformTest from '../Experimental/PlatformTest/RNTesterPlatformT import ImageCapInsetsExample from './ImageCapInsetsExample'; import * as React from 'react'; import {useEffect, useState} from 'react'; -import {Image, ImageBackground, StyleSheet, Text, View} from 'react-native'; +import { + Image, + ImageBackground, + PixelRatio, + StyleSheet, + Text, + View, +} from 'react-native'; const IMAGE1 = 'https://www.facebook.com/assets/fb_lite_messaging/E2EE-settings@3x.png'; @@ -597,6 +604,76 @@ const VectorDrawableExample = () => { ); }; +const VectorDrawableGetSizeExample = () => { + const [results, setResults] = useState< + Array<{name: string, status: string, width?: number, height?: number}>, + >([]); + + const testResources = [ + {name: 'ic_vector_test_24', label: 'VectorDrawable (24dp circle)'}, + { + name: 'ic_launcher_foreground', + label: 'VectorDrawable (108dp React logo)', + }, + {name: 'ic_launcher_background', label: 'VectorDrawable (108dp grid)'}, + {name: 'ic_menu_black_24dp', label: 'PNG drawable (24dp menu icon)'}, + { + name: 'ic_settings_black_48dp', + label: 'PNG drawable (48dp settings icon)', + }, + {name: 'nonexistent_drawable', label: 'Non-existent resource'}, + ]; + + const runTest = () => { + setResults([]); + const scale = PixelRatio.get(); + testResources.forEach(({name, label}) => { + Image.getSize( + name, + (width, height) => { + setResults(prev => [ + ...prev, + { + name: label, + status: 'success', + width: Math.round(width / scale), + height: Math.round(height / scale), + }, + ]); + }, + (error: unknown) => { + setResults(prev => [ + ...prev, + {name: label, status: `error: ${String(error)}`}, + ]); + }, + ); + }); + }; + + return ( + + + Run Image.getSize on local drawable resources + + {results.map((result, index) => ( + + {result.name} + {result.status === 'success' ? ( + + {result.width}x{result.height} dp + + ) : ( + + {result.status} + + )} + + ))} + + ); +}; + function CacheControlExample(): React.Node { const [reload, setReload] = useState(0); @@ -993,6 +1070,22 @@ const styles = StyleSheet.create({ height: 64, width: 64, }, + getSizeRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 4, + paddingHorizontal: 8, + }, + getSizeLabel: { + flex: 1, + }, + getSizeSuccess: { + color: 'green', + fontWeight: 'bold', + }, + getSizeError: { + color: 'red', + }, resizedImage: { height: 100, width: '500%', @@ -1814,6 +1907,16 @@ exports.examples = [ }, platform: 'android', }, + { + title: 'Image.getSize with local drawables', + name: 'vector-drawable-getsize', + description: + 'Calls Image.getSize() on Android drawable resource names (both VectorDrawable and raster PNG) and displays dimensions in density-independent pixels (dp).', + render: function (): React.Node { + return ; + }, + platform: 'android', + }, { title: 'Large image with different resize methods', name: 'resize-method', From 56db1bde3edf147c64d1da78870c54cced1ca421 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 22 May 2026 08:39:22 -0700 Subject: [PATCH 2/2] Add Maestro test for `Image.getSize` with local drawable resources Summary: Adds a Maestro E2E test that exercises the new RNTester example for `Image.getSize()` with Android drawable resources. The test navigates to the Image examples, opens the "Image.getSize with local drawables" example, taps the run button, and verifies that VectorDrawable and PNG drawable dimensions are returned correctly in dp, and that the error case for a nonexistent drawable shows an error message. Changelog: [Internal] Differential Revision: D106089813 --- .../image-getsize-local-drawables.yml | 43 +++++++++++++++++++ .../rn-tester/scripts/maestro-test-ios.sh | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/rn-tester/.maestro/image-getsize-local-drawables.yml diff --git a/packages/rn-tester/.maestro/image-getsize-local-drawables.yml b/packages/rn-tester/.maestro/image-getsize-local-drawables.yml new file mode 100644 index 000000000000..514b6d6827d7 --- /dev/null +++ b/packages/rn-tester/.maestro/image-getsize-local-drawables.yml @@ -0,0 +1,43 @@ +appId: ${APP_ID} # iOS: com.meta.RNTester.localDevelopment | Android: com.facebook.react.uiapp +tags: + - android-only +--- +# Navigate to Image examples +- runFlow: ./helpers/launch-app-and-search.yml +- inputText: + text: "Image" +- assertVisible: + id: "Image" +- tapOn: + id: "Image" + +# Search for the local drawables example +- assertVisible: + id: "example_search" +- tapOn: + id: "example_search" +- inputText: + text: "local drawables" +- hideKeyboard + +# Navigate to the example +- scrollUntilVisible: + element: "Image.getSize with local drawables" + direction: DOWN + speed: 40 + timeout: 10000 +- tapOn: "Image.getSize with local drawables" + +# Tap the run button +- tapOn: "Run Image.getSize on local drawable resources" + +# Assert success results contain dp dimensions +- extendedWaitUntil: + visible: "24x24 dp" + timeout: 10000 +- assertVisible: "108x108 dp" + +# Assert error case shows error message +- assertVisible: + text: "error:.*" + regex: true diff --git a/packages/rn-tester/scripts/maestro-test-ios.sh b/packages/rn-tester/scripts/maestro-test-ios.sh index cbce75d57efd..576db766fd7f 100755 --- a/packages/rn-tester/scripts/maestro-test-ios.sh +++ b/packages/rn-tester/scripts/maestro-test-ios.sh @@ -5,4 +5,4 @@ # LICENSE file in the root directory of this source tree. UDID=$(xcrun simctl list devices booted -j | jq -r '[.devices[]] | add | first | .udid') -maestro --udid="$UDID" test .maestro/ -e APP_ID=com.meta.RNTester.localDevelopment +maestro --udid="$UDID" test --exclude-tags android-only .maestro/ -e APP_ID=com.meta.RNTester.localDevelopment