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/.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/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', 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