From c18a492858e94b31e632560ad17499012e688158 Mon Sep 17 00:00:00 2001 From: Jonathan Andrew Date: Tue, 17 Aug 2021 18:09:10 -0700 Subject: [PATCH] Fix Dimensions not updating on Android (#31973) Summary: When retrieving the device dimensions through the JS `Dimensions` utility, the result of `Dimensions.get` can be incorrect on Android. ### Related issues - https://github.com/facebook/react-native/issues/29105 - https://github.com/facebook/react-native/issues/29451 - https://github.com/facebook/react-native/issues/29323 The issue is caused by the Android `DeviceInfoModule` that provides initial screen dimensions and then subsequently updates those by emitting `didUpdateDimensions` events. The assumption in that implementation is that the initial display metrics will not have changed prior to the first check for updated metrics. However that is not the case as the device may be rotated (as shown in the attached video). The solution in this PR is to keep track of the initial dimensions for comparison at the first check for updated metrics. ## Changelog [Android] [Fixed] - Fix Dimensions not updating Pull Request resolved: https://github.com/facebook/react-native/pull/31973 Test Plan: ### Steps to reproduce 1. Install the RNTester app on Android from the `main` branch. 2. Set the device auto-rotation to ON 3. Start the RNTester app 4. While the app is loading, rotate the device 5. Navigate to the `Dimensions` screen 6. Either a. Observe the screen width and height are reversed, or b. Quit the app and return to step 3. ### Verifying the fix #### Manually Using the above steps, the issue should no longer be reproducible. #### Automatically See unit tests in `ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java` ### Video https://user-images.githubusercontent.com/4940864/128485453-2ae04724-4ac5-4267-a59a-140cc3af626b.mp4 Reviewed By: JoshuaGross Differential Revision: D30319919 Pulled By: lunaleaps fbshipit-source-id: 52a2faeafc522b1c2a196ca40357027eafa1a84b --- .../modules/deviceinfo/DeviceInfoModule.java | 12 +- .../react/uimanager/DisplayMetricsHolder.java | 35 +--- .../react/bridge/ReactTestHelper.java | 1 + .../test/java/com/facebook/react/modules/BUCK | 1 + .../deviceinfo/DeviceInfoModuleTest.java | 168 ++++++++++++++++++ 5 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.java index ddc87c40e4b1ce..8e7b79822104f7 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/deviceinfo/DeviceInfoModule.java @@ -15,7 +15,7 @@ import com.facebook.react.bridge.ReactNoCrashSoftException; import com.facebook.react.bridge.ReactSoftExceptionLogger; import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.WritableNativeMap; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.modules.core.DeviceEventManagerModule; import com.facebook.react.uimanager.DisplayMetricsHolder; @@ -54,8 +54,13 @@ public String getName() { @Override public @Nullable Map getTypedExportedConstants() { + WritableMap displayMetrics = DisplayMetricsHolder.getDisplayMetricsWritableMap(mFontScale); + + // Cache the initial dimensions for later comparison in emitUpdateDimensionsEvent + mPreviousDisplayMetrics = displayMetrics.copy(); + HashMap constants = new HashMap<>(); - constants.put("Dimensions", DisplayMetricsHolder.getDisplayMetricsMap(mFontScale)); + constants.put("Dimensions", displayMetrics.toHashMap()); return constants; } @@ -85,8 +90,7 @@ public void emitUpdateDimensionsEvent() { if (mReactApplicationContext.hasActiveReactInstance()) { // Don't emit an event to JS if the dimensions haven't changed - WritableNativeMap displayMetrics = - DisplayMetricsHolder.getDisplayMetricsNativeMap(mFontScale); + WritableMap displayMetrics = DisplayMetricsHolder.getDisplayMetricsWritableMap(mFontScale); if (mPreviousDisplayMetrics == null) { mPreviousDisplayMetrics = displayMetrics.copy(); } else if (!displayMetrics.equals(mPreviousDisplayMetrics)) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java index 8ecd9cdf94221a..3caf9bcf44cb46 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java @@ -13,9 +13,8 @@ import android.view.WindowManager; import androidx.annotation.Nullable; import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeMap; -import java.util.HashMap; -import java.util.Map; /** * Holds an instance of the current DisplayMetrics so we don't have to thread it through all the @@ -81,40 +80,20 @@ public static DisplayMetrics getScreenDisplayMetrics() { return sScreenDisplayMetrics; } - public static Map> getDisplayMetricsMap(double fontScale) { + public static WritableMap getDisplayMetricsWritableMap(double fontScale) { Assertions.assertCondition( sWindowDisplayMetrics != null && sScreenDisplayMetrics != null, - "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or initDisplayMetrics"); - final Map> result = new HashMap<>(); - result.put("windowPhysicalPixels", getPhysicalPixelsMap(sWindowDisplayMetrics, fontScale)); - result.put("screenPhysicalPixels", getPhysicalPixelsMap(sScreenDisplayMetrics, fontScale)); - return result; - } - - public static WritableNativeMap getDisplayMetricsNativeMap(double fontScale) { - Assertions.assertCondition( - sWindowDisplayMetrics != null && sScreenDisplayMetrics != null, - "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or initDisplayMetrics"); + "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or" + + " initDisplayMetrics"); final WritableNativeMap result = new WritableNativeMap(); result.putMap( - "windowPhysicalPixels", getPhysicalPixelsNativeMap(sWindowDisplayMetrics, fontScale)); + "windowPhysicalPixels", getPhysicalPixelsWritableMap(sWindowDisplayMetrics, fontScale)); result.putMap( - "screenPhysicalPixels", getPhysicalPixelsNativeMap(sScreenDisplayMetrics, fontScale)); - return result; - } - - private static Map getPhysicalPixelsMap( - DisplayMetrics displayMetrics, double fontScale) { - final Map result = new HashMap<>(); - result.put("width", displayMetrics.widthPixels); - result.put("height", displayMetrics.heightPixels); - result.put("scale", displayMetrics.density); - result.put("fontScale", fontScale); - result.put("densityDpi", displayMetrics.densityDpi); + "screenPhysicalPixels", getPhysicalPixelsWritableMap(sScreenDisplayMetrics, fontScale)); return result; } - private static WritableNativeMap getPhysicalPixelsNativeMap( + private static WritableMap getPhysicalPixelsWritableMap( DisplayMetrics displayMetrics, double fontScale) { final WritableNativeMap result = new WritableNativeMap(); result.putInt("width", displayMetrics.widthPixels); diff --git a/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.java b/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.java index ffce9ea596a157..d3ab1b7fe70e5b 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.java +++ b/ReactAndroid/src/test/java/com/facebook/react/bridge/ReactTestHelper.java @@ -52,6 +52,7 @@ public void handleException(Exception e) { when(reactInstance.getReactQueueConfiguration()).thenReturn(ReactQueueConfiguration); when(reactInstance.getNativeModule(UIManagerModule.class)) .thenReturn(mock(UIManagerModule.class)); + when(reactInstance.isDestroyed()).thenReturn(false); return reactInstance; } diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK b/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK index 750600054fbf23..f50c34f262d139 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK @@ -32,6 +32,7 @@ rn_robolectric_test( react_native_target("java/com/facebook/react/modules/common:common"), react_native_target("java/com/facebook/react/modules/core:core"), react_native_target("java/com/facebook/react/modules/debug:debug"), + react_native_target("java/com/facebook/react/modules/deviceinfo:deviceinfo"), react_native_target("java/com/facebook/react/modules/dialog:dialog"), react_native_target("java/com/facebook/react/modules/network:network"), react_native_target("java/com/facebook/react/modules/share:share"), diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java b/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java new file mode 100644 index 00000000000000..6ed814abb53406 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) Facebook, Inc. and its 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.deviceinfo; + +import static org.fest.assertions.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.powermock.api.mockito.PowerMockito.mockStatic; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.CatalystInstance; +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactTestHelper; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; +import com.facebook.react.uimanager.DisplayMetricsHolder; +import java.util.Arrays; +import java.util.List; +import junit.framework.TestCase; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +@PrepareForTest({Arguments.class, DisplayMetricsHolder.class}) +@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "androidx.*", "android.*"}) +public class DeviceInfoModuleTest extends TestCase { + + @Rule public PowerMockRule rule = new PowerMockRule(); + + private DeviceInfoModule mDeviceInfoModule; + private DeviceEventManagerModule.RCTDeviceEventEmitter mRCTDeviceEventEmitterMock; + + private WritableMap fakePortraitDisplayMetrics; + private WritableMap fakeLandscapeDisplayMetrics; + + @Before + public void setUp() { + initTestData(); + + mockStatic(DisplayMetricsHolder.class); + + mRCTDeviceEventEmitterMock = mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class); + + final ReactApplicationContext context = + spy(new ReactApplicationContext(RuntimeEnvironment.application)); + CatalystInstance catalystInstanceMock = ReactTestHelper.createMockCatalystInstance(); + context.initializeWithInstance(catalystInstanceMock); + when(context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)) + .thenReturn(mRCTDeviceEventEmitterMock); + + mDeviceInfoModule = new DeviceInfoModule(context); + } + + @After + public void teardown() { + DisplayMetricsHolder.setWindowDisplayMetrics(null); + DisplayMetricsHolder.setScreenDisplayMetrics(null); + } + + @Test + public void test_itDoesNotEmitAnEvent_whenDisplayMetricsNotChanged() { + givenDisplayMetricsHolderContains(fakePortraitDisplayMetrics); + + mDeviceInfoModule.getTypedExportedConstants(); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + + verifyNoMoreInteractions(mRCTDeviceEventEmitterMock); + } + + @Test + public void test_itEmitsOneEvent_whenDisplayMetricsChangedOnce() { + givenDisplayMetricsHolderContains(fakePortraitDisplayMetrics); + + mDeviceInfoModule.getTypedExportedConstants(); + givenDisplayMetricsHolderContains(fakeLandscapeDisplayMetrics); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + + verifyUpdateDimensionsEventsEmitted(mRCTDeviceEventEmitterMock, fakeLandscapeDisplayMetrics); + } + + @Test + public void test_itEmitsJustOneEvent_whenUpdateRequestedMultipleTimes() { + givenDisplayMetricsHolderContains(fakePortraitDisplayMetrics); + mDeviceInfoModule.getTypedExportedConstants(); + givenDisplayMetricsHolderContains(fakeLandscapeDisplayMetrics); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + + verifyUpdateDimensionsEventsEmitted(mRCTDeviceEventEmitterMock, fakeLandscapeDisplayMetrics); + } + + @Test + public void test_itEmitsMultipleEvents_whenDisplayMetricsChangedBetweenUpdates() { + givenDisplayMetricsHolderContains(fakePortraitDisplayMetrics); + + mDeviceInfoModule.getTypedExportedConstants(); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + givenDisplayMetricsHolderContains(fakeLandscapeDisplayMetrics); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + givenDisplayMetricsHolderContains(fakePortraitDisplayMetrics); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + givenDisplayMetricsHolderContains(fakeLandscapeDisplayMetrics); + mDeviceInfoModule.emitUpdateDimensionsEvent(); + + verifyUpdateDimensionsEventsEmitted( + mRCTDeviceEventEmitterMock, + fakeLandscapeDisplayMetrics, + fakePortraitDisplayMetrics, + fakeLandscapeDisplayMetrics); + } + + private static void givenDisplayMetricsHolderContains(final WritableMap fakeDisplayMetrics) { + when(DisplayMetricsHolder.getDisplayMetricsWritableMap(1.0)).thenReturn(fakeDisplayMetrics); + } + + private static void verifyUpdateDimensionsEventsEmitted( + DeviceEventManagerModule.RCTDeviceEventEmitter emitter, WritableMap... expectedEvents) { + List expectedEventList = Arrays.asList(expectedEvents); + ArgumentCaptor captor = ArgumentCaptor.forClass(WritableMap.class); + verify(emitter, times(expectedEventList.size())) + .emit(eq("didUpdateDimensions"), captor.capture()); + + List actualEvents = captor.getAllValues(); + assertThat(actualEvents).isEqualTo(expectedEventList); + } + + private void initTestData() { + mockStatic(Arguments.class); + when(Arguments.createMap()) + .thenAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return new JavaOnlyMap(); + } + }); + + fakePortraitDisplayMetrics = Arguments.createMap(); + fakePortraitDisplayMetrics.putInt("width", 100); + fakePortraitDisplayMetrics.putInt("height", 200); + + fakeLandscapeDisplayMetrics = Arguments.createMap(); + fakeLandscapeDisplayMetrics.putInt("width", 200); + fakeLandscapeDisplayMetrics.putInt("height", 100); + } +}