diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 4b19d7e67cb24..2f1942034a7e5 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -14,6 +14,7 @@ import android.text.format.DateFormat; import android.util.AttributeSet; import android.util.SparseArray; +import android.view.DisplayCutout; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.PointerIcon; @@ -508,6 +509,15 @@ private int guessBottomKeyboardInset(WindowInsets insets) { public final WindowInsets onApplyWindowInsets(@NonNull WindowInsets insets) { WindowInsets newInsets = super.onApplyWindowInsets(insets); + // getSystemGestureInsets() was introduced in API 29 and immediately deprecated in 30. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + Insets systemGestureInsets = insets.getSystemGestureInsets(); + viewportMetrics.systemGestureInsetTop = systemGestureInsets.top; + viewportMetrics.systemGestureInsetRight = systemGestureInsets.right; + viewportMetrics.systemGestureInsetBottom = systemGestureInsets.bottom; + viewportMetrics.systemGestureInsetLeft = systemGestureInsets.left; + } + boolean statusBarVisible = (SYSTEM_UI_FLAG_FULLSCREEN & getWindowSystemUiVisibility()) == 0; boolean navigationBarVisible = (SYSTEM_UI_FLAG_HIDE_NAVIGATION & getWindowSystemUiVisibility()) == 0; @@ -520,18 +530,48 @@ public final WindowInsets onApplyWindowInsets(@NonNull WindowInsets insets) { if (statusBarVisible) { mask = mask | android.view.WindowInsets.Type.statusBars(); } - mask = mask | android.view.WindowInsets.Type.ime(); - - Insets finalInsets = insets.getInsets(mask); - viewportMetrics.paddingTop = finalInsets.top; - viewportMetrics.paddingRight = finalInsets.right; - viewportMetrics.paddingBottom = 0; - viewportMetrics.paddingLeft = finalInsets.left; + Insets uiInsets = insets.getInsets(mask); + viewportMetrics.paddingTop = uiInsets.top; + viewportMetrics.paddingRight = uiInsets.right; + viewportMetrics.paddingBottom = uiInsets.bottom; + viewportMetrics.paddingLeft = uiInsets.left; + + Insets imeInsets = insets.getInsets(android.view.WindowInsets.Type.ime()); + viewportMetrics.viewInsetTop = imeInsets.top; + viewportMetrics.viewInsetRight = imeInsets.right; + viewportMetrics.viewInsetBottom = imeInsets.bottom; // Typically, only bottom is non-zero + viewportMetrics.viewInsetLeft = imeInsets.left; + + Insets systemGestureInsets = + insets.getInsets(android.view.WindowInsets.Type.systemGestures()); + viewportMetrics.systemGestureInsetTop = systemGestureInsets.top; + viewportMetrics.systemGestureInsetRight = systemGestureInsets.right; + viewportMetrics.systemGestureInsetBottom = systemGestureInsets.bottom; + viewportMetrics.systemGestureInsetLeft = systemGestureInsets.left; - viewportMetrics.viewInsetTop = 0; - viewportMetrics.viewInsetRight = 0; - viewportMetrics.viewInsetBottom = finalInsets.bottom; - viewportMetrics.viewInsetLeft = 0; + // TODO(garyq): Expose the full rects of the display cutout. + + // Take the max of the display cutout insets and existing padding to merge them + DisplayCutout cutout = insets.getDisplayCutout(); + if (cutout != null) { + Insets waterfallInsets = cutout.getWaterfallInsets(); + viewportMetrics.paddingTop = + Math.max( + Math.max(viewportMetrics.paddingTop, waterfallInsets.top), + cutout.getSafeInsetTop()); + viewportMetrics.paddingRight = + Math.max( + Math.max(viewportMetrics.paddingRight, waterfallInsets.right), + cutout.getSafeInsetRight()); + viewportMetrics.paddingBottom = + Math.max( + Math.max(viewportMetrics.paddingBottom, waterfallInsets.bottom), + cutout.getSafeInsetBottom()); + viewportMetrics.paddingLeft = + Math.max( + Math.max(viewportMetrics.paddingLeft, waterfallInsets.left), + cutout.getSafeInsetLeft()); + } } else { // We zero the left and/or right sides to prevent the padding the // navigation bar would have caused. @@ -563,14 +603,6 @@ public final WindowInsets onApplyWindowInsets(@NonNull WindowInsets insets) { viewportMetrics.viewInsetLeft = 0; } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Insets systemGestureInsets = insets.getSystemGestureInsets(); - viewportMetrics.systemGestureInsetTop = systemGestureInsets.top; - viewportMetrics.systemGestureInsetRight = systemGestureInsets.right; - viewportMetrics.systemGestureInsetBottom = systemGestureInsets.bottom; - viewportMetrics.systemGestureInsetLeft = systemGestureInsets.left; - } - Log.v( TAG, "Updating window insets (onApplyWindowInsets()):\n" diff --git a/shell/platform/android/io/flutter/view/FlutterView.java b/shell/platform/android/io/flutter/view/FlutterView.java index 9c20937cac8b8..2ed917b9f918d 100644 --- a/shell/platform/android/io/flutter/view/FlutterView.java +++ b/shell/platform/android/io/flutter/view/FlutterView.java @@ -21,6 +21,7 @@ import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; +import android.view.DisplayCutout; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.PointerIcon; @@ -588,47 +589,100 @@ private int guessBottomKeyboardInset(WindowInsets insets) { @RequiresApi(20) @SuppressLint({"InlinedApi", "NewApi"}) public final WindowInsets onApplyWindowInsets(WindowInsets insets) { - boolean statusBarHidden = (SYSTEM_UI_FLAG_FULLSCREEN & getWindowSystemUiVisibility()) != 0; - boolean navigationBarHidden = - (SYSTEM_UI_FLAG_HIDE_NAVIGATION & getWindowSystemUiVisibility()) != 0; - - // We zero the left and/or right sides to prevent the padding the - // navigation bar would have caused. - ZeroSides zeroSides = ZeroSides.NONE; - if (navigationBarHidden) { - zeroSides = calculateShouldZeroSides(); + // getSystemGestureInsets() was introduced in API 29 and immediately deprecated in 30. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + Insets systemGestureInsets = insets.getSystemGestureInsets(); + mMetrics.systemGestureInsetTop = systemGestureInsets.top; + mMetrics.systemGestureInsetRight = systemGestureInsets.right; + mMetrics.systemGestureInsetBottom = systemGestureInsets.bottom; + mMetrics.systemGestureInsetLeft = systemGestureInsets.left; } - // The padding on top should be removed when the statusbar is hidden. - mMetrics.physicalPaddingTop = statusBarHidden ? 0 : insets.getSystemWindowInsetTop(); - mMetrics.physicalPaddingRight = - zeroSides == ZeroSides.RIGHT || zeroSides == ZeroSides.BOTH - ? 0 - : insets.getSystemWindowInsetRight(); - mMetrics.physicalPaddingBottom = 0; - mMetrics.physicalPaddingLeft = - zeroSides == ZeroSides.LEFT || zeroSides == ZeroSides.BOTH - ? 0 - : insets.getSystemWindowInsetLeft(); - - // Bottom system inset (keyboard) should adjust scrollable bottom edge (inset). - mMetrics.physicalViewInsetTop = 0; - mMetrics.physicalViewInsetRight = 0; - // We perform hidden navbar and keyboard handling if the navbar is set to hidden. Otherwise, - // the navbar padding should always be provided. - mMetrics.physicalViewInsetBottom = - navigationBarHidden - ? guessBottomKeyboardInset(insets) - : insets.getSystemWindowInsetBottom(); - mMetrics.physicalViewInsetLeft = 0; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Insets systemGestureInsets = insets.getSystemGestureInsets(); + boolean statusBarVisible = (SYSTEM_UI_FLAG_FULLSCREEN & getWindowSystemUiVisibility()) == 0; + boolean navigationBarVisible = + (SYSTEM_UI_FLAG_HIDE_NAVIGATION & getWindowSystemUiVisibility()) == 0; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + int mask = 0; + if (navigationBarVisible) { + mask = mask | android.view.WindowInsets.Type.navigationBars(); + } + if (statusBarVisible) { + mask = mask | android.view.WindowInsets.Type.statusBars(); + } + Insets uiInsets = insets.getInsets(mask); + mMetrics.physicalPaddingTop = uiInsets.top; + mMetrics.physicalPaddingRight = uiInsets.right; + mMetrics.physicalPaddingBottom = uiInsets.bottom; + mMetrics.physicalPaddingLeft = uiInsets.left; + + Insets imeInsets = insets.getInsets(android.view.WindowInsets.Type.ime()); + mMetrics.physicalViewInsetTop = imeInsets.top; + mMetrics.physicalViewInsetRight = imeInsets.right; + mMetrics.physicalViewInsetBottom = imeInsets.bottom; // Typically, only bottom is non-zero + mMetrics.physicalViewInsetLeft = imeInsets.left; + + Insets systemGestureInsets = + insets.getInsets(android.view.WindowInsets.Type.systemGestures()); mMetrics.systemGestureInsetTop = systemGestureInsets.top; mMetrics.systemGestureInsetRight = systemGestureInsets.right; mMetrics.systemGestureInsetBottom = systemGestureInsets.bottom; mMetrics.systemGestureInsetLeft = systemGestureInsets.left; + + // TODO(garyq): Expose the full rects of the display cutout. + + // Take the max of the display cutout insets and existing padding to merge them + DisplayCutout cutout = insets.getDisplayCutout(); + if (cutout != null) { + Insets waterfallInsets = cutout.getWaterfallInsets(); + mMetrics.physicalPaddingTop = + Math.max( + Math.max(mMetrics.physicalPaddingTop, waterfallInsets.top), + cutout.getSafeInsetTop()); + mMetrics.physicalPaddingRight = + Math.max( + Math.max(mMetrics.physicalPaddingRight, waterfallInsets.right), + cutout.getSafeInsetRight()); + mMetrics.physicalPaddingBottom = + Math.max( + Math.max(mMetrics.physicalPaddingBottom, waterfallInsets.bottom), + cutout.getSafeInsetBottom()); + mMetrics.physicalPaddingLeft = + Math.max( + Math.max(mMetrics.physicalPaddingLeft, waterfallInsets.left), + cutout.getSafeInsetLeft()); + } + } else { + // We zero the left and/or right sides to prevent the padding the + // navigation bar would have caused. + ZeroSides zeroSides = ZeroSides.NONE; + if (!navigationBarVisible) { + zeroSides = calculateShouldZeroSides(); + } + + // Status bar (top) and left/right system insets should partially obscure the content + // (padding). + mMetrics.physicalPaddingTop = statusBarVisible ? insets.getSystemWindowInsetTop() : 0; + mMetrics.physicalPaddingRight = + zeroSides == ZeroSides.RIGHT || zeroSides == ZeroSides.BOTH + ? 0 + : insets.getSystemWindowInsetRight(); + mMetrics.physicalPaddingBottom = 0; + mMetrics.physicalPaddingLeft = + zeroSides == ZeroSides.LEFT || zeroSides == ZeroSides.BOTH + ? 0 + : insets.getSystemWindowInsetLeft(); + + // Bottom system inset (keyboard) should adjust scrollable bottom edge (inset). + mMetrics.physicalViewInsetTop = 0; + mMetrics.physicalViewInsetRight = 0; + mMetrics.physicalViewInsetBottom = + navigationBarVisible + ? insets.getSystemWindowInsetBottom() + : guessBottomKeyboardInset(insets); + mMetrics.physicalViewInsetLeft = 0; } + updateViewportMetrics(); return super.onApplyWindowInsets(insets); } 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 8129ecc9a3ed3..ccafbc1c16039 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -22,6 +22,7 @@ import android.media.Image; import android.media.Image.Plane; import android.media.ImageReader; +import android.view.DisplayCutout; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; @@ -483,6 +484,67 @@ public void systemInsetGetInsetsFullscreenLegacy() { assertEquals(103, viewportMetricsCaptor.getValue().paddingRight); } + // This test uses the API 30+ Algorithm for window insets. The legacy algorithm is + // set to -1 values, so it is clear if the wrong algorithm is used. + @Test + @TargetApi(30) + @Config(sdk = 30) + public void systemInsetDisplayCutoutSimple() { + RuntimeEnvironment.setQualifiers("+land"); + FlutterView flutterView = spy(new FlutterView(RuntimeEnvironment.systemContext)); + ShadowDisplay display = + Shadows.shadowOf( + ((WindowManager) + RuntimeEnvironment.systemContext.getSystemService(Context.WINDOW_SERVICE)) + .getDefaultDisplay()); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()).thenReturn(0); + when(flutterView.getContext()).thenReturn(RuntimeEnvironment.systemContext); + + FlutterEngine flutterEngine = + spy(new FlutterEngine(RuntimeEnvironment.application, 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().paddingTop); + + Insets insets = Insets.of(100, 100, 100, 100); + Insets systemGestureInsets = Insets.of(110, 110, 110, 110); + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + DisplayCutout displayCutout = mock(DisplayCutout.class); + when(windowInsets.getSystemWindowInsetTop()).thenReturn(-1); + when(windowInsets.getSystemWindowInsetBottom()).thenReturn(-1); + when(windowInsets.getSystemWindowInsetLeft()).thenReturn(-1); + when(windowInsets.getSystemWindowInsetRight()).thenReturn(-1); + when(windowInsets.getInsets(anyInt())).thenReturn(insets); + when(windowInsets.getSystemGestureInsets()).thenReturn(systemGestureInsets); + when(windowInsets.getDisplayCutout()).thenReturn(displayCutout); + + Insets waterfallInsets = Insets.of(200, 0, 200, 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); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + assertEquals(150, viewportMetricsCaptor.getValue().paddingTop); + assertEquals(150, viewportMetricsCaptor.getValue().paddingBottom); + assertEquals(200, viewportMetricsCaptor.getValue().paddingLeft); + assertEquals(200, viewportMetricsCaptor.getValue().paddingRight); + + assertEquals(100, viewportMetricsCaptor.getValue().viewInsetTop); + } + @Test public void flutterImageView_acquiresImageAndInvalidates() { final ImageReader mockReader = mock(ImageReader.class);