From aaaf728b38be89bb8487c7a930c5ffe66a7287ce 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. - 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. [Android] [Fixed] - Fix Dimensions not updating Pull Request resolved: https://github.com/facebook/react-native/pull/31973 Test Plan: 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. Using the above steps, the issue should no longer be reproducible. See unit tests in `ReactAndroid/src/test/java/com/facebook/react/modules/deviceinfo/DeviceInfoModuleTest.java` 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 | 39 +--- .../react/bridge/ReactTestHelper.java | 1 + .../test/java/com/facebook/react/modules/BUCK | 1 + .../deviceinfo/DeviceInfoModuleTest.java | 168 ++++++++++++++++++ 5 files changed, 187 insertions(+), 34 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 083c9c1d9f27b1..228f5ca378416b 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.ReactSoftException; 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.hasActiveCatalystInstance()) { // 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 ab6d1739b05af2..2cedd43c5d76c2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/DisplayMetricsHolder.java @@ -14,11 +14,10 @@ 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.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -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 @@ -103,40 +102,20 @@ public static DisplayMetrics getScreenDisplayMetrics() { return sScreenDisplayMetrics; } - public static Map> getDisplayMetricsMap(double fontScale) { - Assertions.assertNotNull( - 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.assertNotNull( - sWindowDisplayMetrics != null || sScreenDisplayMetrics != null, - "DisplayMetricsHolder must be initialized with initDisplayMetricsIfNotInitialized or initDisplayMetrics"); + public static WritableMap getDisplayMetricsWritableMap(double fontScale) { + Assertions.assertCondition( + sWindowDisplayMetrics != null && sScreenDisplayMetrics != null, + "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 3b5337ebbb64f2..54baf672a73ce5 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK @@ -35,6 +35,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); + } +}