diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 3f3f0470d8f54..a1eaed6da7f03 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -196,13 +196,7 @@ public void onFlutterUiNoLongerDisplayed() { } }; - private final Consumer windowInfoListener = - new Consumer() { - @Override - public void accept(WindowLayoutInfo layoutInfo) { - setWindowInfoListenerDisplayFeatures(layoutInfo); - } - }; + private Consumer windowInfoListener; /** * Constructs a {@code FlutterView} programmatically, without any XML attributes. @@ -514,6 +508,10 @@ protected void onAttachedToWindow() { this.windowInfoRepo = createWindowInfoRepo(); Activity activity = ViewUtils.getActivity(getContext()); if (windowInfoRepo != null && activity != null) { + // Creating windowInfoListener on-demand instead of at initialization is necessary in order to + // prevent it from capturing the wrong instance of `this` when spying for testing. + // See https://github.com/mockito/mockito/issues/3479 + windowInfoListener = this::setWindowInfoListenerDisplayFeatures; windowInfoRepo.addWindowLayoutInfoListener( activity, ContextCompat.getMainExecutor(getContext()), windowInfoListener); } @@ -526,9 +524,10 @@ protected void onAttachedToWindow() { */ @Override protected void onDetachedFromWindow() { - if (windowInfoRepo != null) { + if (windowInfoRepo != null && windowInfoListener != null) { windowInfoRepo.removeWindowLayoutInfoListener(windowInfoListener); } + windowInfoListener = null; this.windowInfoRepo = null; super.onDetachedFromWindow(); } @@ -539,12 +538,12 @@ protected void onDetachedFromWindow() { */ @TargetApi(API_LEVELS.API_28) protected void setWindowInfoListenerDisplayFeatures(WindowLayoutInfo layoutInfo) { - List displayFeatures = layoutInfo.getDisplayFeatures(); - List result = new ArrayList<>(); + List newDisplayFeatures = layoutInfo.getDisplayFeatures(); + List flutterDisplayFeatures = new ArrayList<>(); // Data from WindowInfoTracker display features. Fold and hinge areas are // populated here. - for (DisplayFeature displayFeature : displayFeatures) { + for (DisplayFeature displayFeature : newDisplayFeatures) { Log.v( TAG, "WindowInfoTracker Display Feature reported with bounds = " @@ -567,31 +566,17 @@ protected void setWindowInfoListenerDisplayFeatures(WindowLayoutInfo layoutInfo) } else { state = DisplayFeatureState.UNKNOWN; } - result.add(new FlutterRenderer.DisplayFeature(displayFeature.getBounds(), type, state)); + flutterDisplayFeatures.add( + new FlutterRenderer.DisplayFeature(displayFeature.getBounds(), type, state)); } else { - result.add( + flutterDisplayFeatures.add( new FlutterRenderer.DisplayFeature( displayFeature.getBounds(), DisplayFeatureType.UNKNOWN, DisplayFeatureState.UNKNOWN)); } } - - // Data from the DisplayCutout bounds. Cutouts for cameras and other sensors are - // populated here. DisplayCutout was introduced in API 28. - if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { - WindowInsets insets = getRootWindowInsets(); - if (insets != null) { - DisplayCutout cutout = insets.getDisplayCutout(); - if (cutout != null) { - for (Rect bounds : cutout.getBoundingRects()) { - Log.v(TAG, "DisplayCutout area reported with bounds = " + bounds.toString()); - result.add(new FlutterRenderer.DisplayFeature(bounds, DisplayFeatureType.CUTOUT)); - } - } - } - } - viewportMetrics.displayFeatures = result; + viewportMetrics.setDisplayFeatures(flutterDisplayFeatures); sendViewportMetricsToFlutter(); } @@ -784,6 +769,22 @@ navigationBarVisible && guessBottomKeyboardInset(insets) == 0 viewportMetrics.viewInsetLeft = 0; } + // Data from the DisplayCutout bounds. Cutouts for cameras and other sensors are + // populated here. DisplayCutout was introduced in API 28. + List displayCutouts = new ArrayList<>(); + if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { + DisplayCutout cutout = insets.getDisplayCutout(); + if (cutout != null) { + for (Rect bounds : cutout.getBoundingRects()) { + Log.v(TAG, "DisplayCutout area reported with bounds = " + bounds.toString()); + displayCutouts.add( + new FlutterRenderer.DisplayFeature( + bounds, DisplayFeatureType.CUTOUT, DisplayFeatureState.UNKNOWN)); + } + } + } + viewportMetrics.setDisplayCutouts(displayCutouts); + // The caption bar inset is a new addition, and the APIs called to query it utilize a list of // bounding Rects instead of an Insets object, which is a newer API method, as compared to the // existing Insets-based method calls above. diff --git a/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java b/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java index 54434c27d0594..fe1fa6428eb87 100644 --- a/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java +++ b/shell/platform/android/io/flutter/embedding/engine/renderer/FlutterRenderer.java @@ -1157,6 +1157,13 @@ public void stopRenderingToSurface() { } } + private void translateFeatureBounds(int[] displayFeatureBounds, int offset, Rect bounds) { + displayFeatureBounds[offset] = bounds.left; + displayFeatureBounds[offset + 1] = bounds.top; + displayFeatureBounds[offset + 2] = bounds.right; + displayFeatureBounds[offset + 3] = bounds.bottom; + } + /** * Notifies Flutter that the viewport metrics, e.g. window height and width, have changed. * @@ -1207,20 +1214,31 @@ public void setViewportMetrics(@NonNull ViewportMetrics viewportMetrics) { + viewportMetrics.systemGestureInsetRight + "\n" + "Display Features: " - + viewportMetrics.displayFeatures.size()); - - int[] displayFeaturesBounds = new int[viewportMetrics.displayFeatures.size() * 4]; - int[] displayFeaturesType = new int[viewportMetrics.displayFeatures.size()]; - int[] displayFeaturesState = new int[viewportMetrics.displayFeatures.size()]; + + viewportMetrics.displayFeatures.size() + + "\n" + + "Display Cutouts: " + + viewportMetrics.displayCutouts.size()); + + int totalFeaturesAndCutouts = + viewportMetrics.displayFeatures.size() + viewportMetrics.displayCutouts.size(); + int[] displayFeaturesBounds = new int[totalFeaturesAndCutouts * 4]; + int[] displayFeaturesType = new int[totalFeaturesAndCutouts]; + int[] displayFeaturesState = new int[totalFeaturesAndCutouts]; for (int i = 0; i < viewportMetrics.displayFeatures.size(); i++) { DisplayFeature displayFeature = viewportMetrics.displayFeatures.get(i); - displayFeaturesBounds[4 * i] = displayFeature.bounds.left; - displayFeaturesBounds[4 * i + 1] = displayFeature.bounds.top; - displayFeaturesBounds[4 * i + 2] = displayFeature.bounds.right; - displayFeaturesBounds[4 * i + 3] = displayFeature.bounds.bottom; + translateFeatureBounds(displayFeaturesBounds, 4 * i, displayFeature.bounds); displayFeaturesType[i] = displayFeature.type.encodedValue; displayFeaturesState[i] = displayFeature.state.encodedValue; } + int cutoutOffset = viewportMetrics.displayFeatures.size() * 4; + for (int i = 0; i < viewportMetrics.displayCutouts.size(); i++) { + DisplayFeature displayCutout = viewportMetrics.displayCutouts.get(i); + translateFeatureBounds(displayFeaturesBounds, cutoutOffset + 4 * i, displayCutout.bounds); + displayFeaturesType[viewportMetrics.displayFeatures.size() + i] = + displayCutout.type.encodedValue; + displayFeaturesState[viewportMetrics.displayFeatures.size() + i] = + displayCutout.state.encodedValue; + } flutterJNI.setViewportMetrics( viewportMetrics.devicePixelRatio, @@ -1335,7 +1353,29 @@ boolean validate() { return width > 0 && height > 0 && devicePixelRatio > 0; } - public List displayFeatures = new ArrayList<>(); + // Features + private final List displayFeatures = new ArrayList<>(); + + // Specifically display cutouts. + private final List displayCutouts = new ArrayList<>(); + + public List getDisplayFeatures() { + return displayFeatures; + } + + public List getDisplayCutouts() { + return displayCutouts; + } + + public void setDisplayFeatures(List newFeatures) { + displayFeatures.clear(); + displayFeatures.addAll(newFeatures); + } + + public void setDisplayCutouts(List newCutouts) { + displayCutouts.clear(); + displayCutouts.addAll(newCutouts); + } } /** @@ -1358,12 +1398,6 @@ public DisplayFeature(Rect bounds, DisplayFeatureType type, DisplayFeatureState this.type = type; this.state = state; } - - public DisplayFeature(Rect bounds, DisplayFeatureType type) { - this.bounds = bounds; - this.type = type; - this.state = DisplayFeatureState.UNKNOWN; - } } /** diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java index dc62638fb2e32..7247873ef1e0d 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -55,6 +55,8 @@ import io.flutter.plugin.platform.PlatformViewsController; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; @@ -634,21 +636,103 @@ public void systemInsetDisplayCutoutSimple() { when(windowInsets.getSystemGestureInsets()).thenReturn(systemGestureInsets); when(windowInsets.getDisplayCutout()).thenReturn(displayCutout); - Insets waterfallInsets = Insets.of(200, 0, 200, 0); + Insets waterfallInsets = Insets.of(200, 0, 250, 0); when(displayCutout.getWaterfallInsets()).thenReturn(waterfallInsets); - when(displayCutout.getSafeInsetTop()).thenReturn(150); - when(displayCutout.getSafeInsetBottom()).thenReturn(150); - when(displayCutout.getSafeInsetLeft()).thenReturn(150); - when(displayCutout.getSafeInsetRight()).thenReturn(150); + when(displayCutout.getSafeInsetLeft()).thenReturn(110); + when(displayCutout.getSafeInsetTop()).thenReturn(120); + when(displayCutout.getSafeInsetRight()).thenReturn(130); + when(displayCutout.getSafeInsetBottom()).thenReturn(140); flutterView.onApplyWindowInsets(windowInsets); verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); - validateViewportMetricPadding(viewportMetricsCaptor, 200, 150, 200, 150); + // Each dimension of the viewport metric paddings should be the maximum of the corresponding + // dimension from the display cutout's safe insets and waterfall insets. + validateViewportMetricPadding(viewportMetricsCaptor, 200, 120, 250, 140); assertEquals(100, viewportMetricsCaptor.getValue().viewInsetTop); } + @SuppressWarnings("deprecation") + @Test + @Config(minSdk = 28) + public void onApplyWindowInsetsSetsDisplayCutouts() { + // Use an Activity context so that FlutterView.onAttachedToWindow completes. + Context context = Robolectric.setupActivity(Activity.class); + FlutterView flutterView = spy(new FlutterView(context)); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()).thenReturn(0); + when(flutterView.getContext()).thenReturn(context); + + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + // When we attach a new FlutterView to the engine without any system insets, + // the viewport metrics default to 0. + flutterView.attachToFlutterEngine(flutterEngine); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(0, viewportMetricsCaptor.getValue().viewPaddingTop); + + // Capture flutterView.setWindowInfoListenerDisplayFeatures. + WindowInfoRepositoryCallbackAdapterWrapper windowInfoRepo = + mock(WindowInfoRepositoryCallbackAdapterWrapper.class); + doReturn(windowInfoRepo).when(flutterView).createWindowInfoRepo(); + ArgumentCaptor> consumerCaptor = + ArgumentCaptor.forClass(Consumer.class); + flutterView.onAttachedToWindow(); + verify(windowInfoRepo).addWindowLayoutInfoListener(any(), any(), consumerCaptor.capture()); + Consumer consumer = consumerCaptor.getValue(); + + // Set display features in flutterView to ensure they are not overridden by display cutouts. + FoldingFeature displayFeature = mock(FoldingFeature.class); + Rect featureBounds = new Rect(10, 20, 30, 40); + when(displayFeature.getBounds()).thenReturn(featureBounds); + when(displayFeature.getOcclusionType()).thenReturn(FoldingFeature.OcclusionType.FULL); + when(displayFeature.getState()).thenReturn(FoldingFeature.State.FLAT); + WindowLayoutInfo windowLayout = new WindowLayoutInfo(Collections.singletonList(displayFeature)); + clearInvocations(flutterRenderer); + consumer.accept(windowLayout); + + // Assert the display feature is set. + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + List features = + viewportMetricsCaptor.getValue().getDisplayFeatures(); + assertEquals(1, features.size()); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, features.get(0).type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, features.get(0).state); + assertEquals(featureBounds, features.get(0).bounds); + + // Then we simulate the system applying a window inset. + List cutoutBoundingRects = + Arrays.asList(new Rect(0, 200, 300, 400), new Rect(150, 0, 300, 150)); + WindowInsets windowInsets = setupMockDisplayCutout(cutoutBoundingRects); + + clearInvocations(flutterRenderer); + flutterView.onApplyWindowInsets(windowInsets); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + + features = viewportMetricsCaptor.getValue().getDisplayFeatures(); + + // Assert the old display feature is still present. + assertEquals(1, features.size()); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, features.get(0).type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, features.get(0).state); + assertEquals(featureBounds, features.get(0).bounds); + + List cutouts = + viewportMetricsCaptor.getValue().getDisplayCutouts(); + // Asserts for display cutouts. + assertEquals(2, cutouts.size()); + for (int i = 0; i < 2; i++) { + assertEquals(cutoutBoundingRects.get(i), cutouts.get(i).bounds); + assertEquals(FlutterRenderer.DisplayFeatureType.CUTOUT, cutouts.get(i).type); + assertEquals(FlutterRenderer.DisplayFeatureState.UNKNOWN, cutouts.get(i).state); + } + } + @SuppressWarnings("deprecation") // Robolectric.setupActivity // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 @@ -694,36 +778,59 @@ public void itSendsHingeDisplayFeatureToFlutter() { FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + // Display features should be empty on attaching to engine. + flutterView.attachToFlutterEngine(flutterEngine); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(Collections.emptyList(), viewportMetricsCaptor.getValue().getDisplayFeatures()); + clearInvocations(flutterRenderer); + + // Test that display features do not override cutouts. + List cutoutBoundingRects = Collections.singletonList(new Rect(0, 200, 300, 400)); + WindowInsets windowInsets = setupMockDisplayCutout(cutoutBoundingRects); + flutterView.onApplyWindowInsets(windowInsets); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayCutouts().size()); + assertEquals( + cutoutBoundingRects.get(0), + viewportMetricsCaptor.getValue().getDisplayCutouts().get(0).bounds); + clearInvocations(flutterRenderer); + FoldingFeature displayFeature = mock(FoldingFeature.class); - when(displayFeature.getBounds()).thenReturn(new Rect(0, 0, 100, 100)); + Rect featureRect = new Rect(0, 0, 100, 100); + when(displayFeature.getBounds()).thenReturn(featureRect); when(displayFeature.getOcclusionType()).thenReturn(FoldingFeature.OcclusionType.FULL); when(displayFeature.getState()).thenReturn(FoldingFeature.State.FLAT); - WindowLayoutInfo testWindowLayout = new WindowLayoutInfo(Arrays.asList(displayFeature)); + WindowLayoutInfo testWindowLayout = + new WindowLayoutInfo(Collections.singletonList(displayFeature)); // When FlutterView is attached to the engine and window, and a hinge display feature exists - flutterView.attachToFlutterEngine(flutterEngine); - ArgumentCaptor viewportMetricsCaptor = - ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); - verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals(Arrays.asList(), viewportMetricsCaptor.getValue().displayFeatures); flutterView.onAttachedToWindow(); ArgumentCaptor> wmConsumerCaptor = - ArgumentCaptor.forClass((Class) Consumer.class); + ArgumentCaptor.forClass(Consumer.class); verify(windowInfoRepo).addWindowLayoutInfoListener(any(), any(), wmConsumerCaptor.capture()); Consumer wmConsumer = wmConsumerCaptor.getValue(); + clearInvocations(flutterRenderer); wmConsumer.accept(testWindowLayout); // Then the Renderer receives the display feature verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); - assertEquals( - FlutterRenderer.DisplayFeatureType.HINGE, - viewportMetricsCaptor.getValue().displayFeatures.get(0).type); - assertEquals( - FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, - viewportMetricsCaptor.getValue().displayFeatures.get(0).state); - assertEquals( - new Rect(0, 0, 100, 100), viewportMetricsCaptor.getValue().displayFeatures.get(0).bounds); + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayFeatures().size()); + FlutterRenderer.DisplayFeature feature = + viewportMetricsCaptor.getValue().getDisplayFeatures().get(0); + assertEquals(FlutterRenderer.DisplayFeatureType.HINGE, feature.type); + assertEquals(FlutterRenderer.DisplayFeatureState.POSTURE_FLAT, feature.state); + assertEquals(featureRect, feature.bounds); + + // Assert the display cutout is unaffected. + assertEquals(1, viewportMetricsCaptor.getValue().getDisplayCutouts().size()); + FlutterRenderer.DisplayFeature cutout = + viewportMetricsCaptor.getValue().getDisplayCutouts().get(0); + assertEquals(cutoutBoundingRects.get(0), cutout.bounds); + assertEquals(FlutterRenderer.DisplayFeatureType.CUTOUT, cutout.type); + assertEquals(FlutterRenderer.DisplayFeatureState.UNKNOWN, cutout.state); } @Test @@ -1173,6 +1280,34 @@ private void mockSystemGestureInsetsIfNeed(WindowInsets windowInsets) { } } + @SuppressWarnings("deprecation") + private WindowInsets setupMockDisplayCutout(List boundingRects) { + WindowInsets windowInsets = mock(WindowInsets.class); + DisplayCutout displayCutout = mock(DisplayCutout.class); + when(windowInsets.getDisplayCutout()).thenReturn(displayCutout); + when(displayCutout.getBoundingRects()).thenReturn(boundingRects); + // The following mocked methods are necessary to avoid a NullPointerException when calling + // onApplyWindowInsets, but are irrelevant to the behavior this test concerns. + Insets unusedInsets = Insets.of(100, 100, 100, 100); + // WindowInsets::getSystemGestureInsets was added in API 29, deprecated in API 30. + if (Build.VERSION.SDK_INT == 29) { + when(windowInsets.getSystemGestureInsets()).thenReturn(unusedInsets); + } + // WindowInsets::getInsets was added in API 30. + if (Build.VERSION.SDK_INT >= 30) { + when(windowInsets.getInsets(anyInt())).thenReturn(unusedInsets); + } + // DisplayCutout::getWaterfallInsets was added in API 30. + if (Build.VERSION.SDK_INT >= 30) { + when(displayCutout.getWaterfallInsets()).thenReturn(unusedInsets); + } + when(displayCutout.getSafeInsetTop()).thenReturn(100); + when(displayCutout.getSafeInsetLeft()).thenReturn(100); + when(displayCutout.getSafeInsetBottom()).thenReturn(100); + when(displayCutout.getSafeInsetRight()).thenReturn(100); + return windowInsets; + } + /* * A custom shadow that reports fullscreen flag for system UI visibility */ diff --git a/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java b/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java index eaada1ef26fee..862d00a064aac 100644 --- a/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java +++ b/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java @@ -288,14 +288,20 @@ public void itConvertsDisplayFeatureArrayToPrimitiveArrays() { metrics.width = 1000; metrics.height = 1000; metrics.devicePixelRatio = 2; - metrics.displayFeatures.add( - new FlutterRenderer.DisplayFeature( - new Rect(10, 20, 30, 40), - FlutterRenderer.DisplayFeatureType.FOLD, - FlutterRenderer.DisplayFeatureState.POSTURE_HALF_OPENED)); - metrics.displayFeatures.add( - new FlutterRenderer.DisplayFeature( - new Rect(50, 60, 70, 80), FlutterRenderer.DisplayFeatureType.CUTOUT)); + metrics + .getDisplayFeatures() + .add( + new FlutterRenderer.DisplayFeature( + new Rect(10, 20, 30, 40), + FlutterRenderer.DisplayFeatureType.FOLD, + FlutterRenderer.DisplayFeatureState.POSTURE_HALF_OPENED)); + metrics + .getDisplayCutouts() + .add( + new FlutterRenderer.DisplayFeature( + new Rect(50, 60, 70, 80), + FlutterRenderer.DisplayFeatureType.CUTOUT, + FlutterRenderer.DisplayFeatureState.UNKNOWN)); // Execute the behavior under test. flutterRenderer.setViewportMetrics(metrics);