Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ResourceDrawableIdHelper>

@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`<Int> { 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<Drawable>()
whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth)
whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight)

mockedHelper
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
.thenReturn(12345)
mockedHelper
.`when`<Drawable?> { 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<Drawable>()
whenever(mockDrawable.intrinsicWidth).thenReturn(expectedWidth)
whenever(mockDrawable.intrinsicHeight).thenReturn(expectedHeight)

mockedHelper
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
.thenReturn(12345)
mockedHelper
.`when`<Drawable?> { 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`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
.thenReturn(0)
mockedHelper
.`when`<Drawable?> { 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<Drawable>()
// ColorDrawable and similar return -1 for intrinsic dimensions
whenever(mockDrawable.intrinsicWidth).thenReturn(-1)
whenever(mockDrawable.intrinsicHeight).thenReturn(-1)

mockedHelper
.`when`<Int> { ResourceDrawableIdHelper.getResourceDrawableId(any(), eq(drawableName)) }
.thenReturn(12345)
mockedHelper
.`when`<Drawable?> { 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)
}
}
}
43 changes: 43 additions & 0 deletions packages/rn-tester/.maestro/image-getsize-local-drawables.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#4CAF50"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
</vector>
Loading
Loading