Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
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 @@ -17,9 +17,11 @@
import android.util.SparseArray;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.View;
import android.view.ViewStructure;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.autofill.AutofillValue;
Expand Down Expand Up @@ -381,6 +383,71 @@ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight)
sendViewportMetricsToFlutter();
}

// TODO(garyq): Add support for notch cutout API: https://github.com/flutter/flutter/issues/56592
// Decide if we want to zero the padding of the sides. When in Landscape orientation,
// android may decide to place the software navigation bars on the side. When the nav
// bar is hidden, the reported insets should be removed to prevent extra useless space
// on the sides.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mirror an Android API? If so, consider adding a See: link or identifier names to help readers link the Flutter/Android concepts/APIs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This information seems to be intentionally hidden from us to prevent people from locking users out of their phone by preventing touches to the navbar

private enum ZeroSides {
NONE,
LEFT,
RIGHT,
BOTH
}

private ZeroSides calculateShouldZeroSides() {
// We get both orientation and rotation because rotation is all 4
// rotations relative to default rotation while orientation is portrait
// or landscape. By combining both, we can obtain a more precise measure
// of the rotation.
Context context = getContext();
int orientation = context.getResources().getConfiguration().orientation;
int rotation =
((WindowManager) context.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay()
.getRotation();

if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (rotation == Surface.ROTATION_90) {
return ZeroSides.RIGHT;
} else if (rotation == Surface.ROTATION_270) {
// In android API >= 23, the nav bar always appears on the "bottom" (USB) side.
return Build.VERSION.SDK_INT >= 23 ? ZeroSides.LEFT : ZeroSides.RIGHT;
}
// Ambiguous orientation due to landscape left/right default. Zero both sides.
else if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
return ZeroSides.BOTH;
}
}
// Square orientation deprecated in API 16, we will not check for it and return false
// to be safe and not remove any unique padding for the devices that do use it.
return ZeroSides.NONE;
}

// TODO(garyq): Use new Android R getInsets API
// TODO(garyq): The keyboard detection may interact strangely with
// https://github.com/flutter/flutter/issues/22061

// Uses inset heights and screen heights as a heuristic to determine if the insets should
// be padded. When the on-screen keyboard is detected, we want to include the full inset
// but when the inset is just the hidden nav bar, we want to provide a zero inset so the space
// can be used.
@TargetApi(20)
@RequiresApi(20)
private int guessBottomKeyboardInset(WindowInsets insets) {
int screenHeight = getRootView().getHeight();
// Magic number due to this being a heuristic. This should be replaced, but we have not
// found a clean way to do it yet (Sept. 2018)
final double keyboardHeightRatioHeuristic = 0.18;
if (insets.getSystemWindowInsetBottom() < screenHeight * keyboardHeightRatioHeuristic) {
// Is not a keyboard, so return zero as inset.
return 0;
} else {
// Is a keyboard, so return the full inset.
return insets.getSystemWindowInsetBottom();
}
}

/**
* Invoked when Android's desired window insets change, i.e., padding.
*
Expand All @@ -400,19 +467,37 @@ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight)
@SuppressLint({"InlinedApi", "NewApi"})
@NonNull
public final WindowInsets onApplyWindowInsets(@NonNull WindowInsets insets) {
boolean statusBarHidden = (SYSTEM_UI_FLAG_FULLSCREEN & getWindowSystemUiVisibility()) != 0;
WindowInsets newInsets = super.onApplyWindowInsets(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();
}

// Status bar (top) and left/right system insets should partially obscure the content (padding).
viewportMetrics.paddingTop = statusBarHidden ? 0 : insets.getSystemWindowInsetTop();
viewportMetrics.paddingRight = insets.getSystemWindowInsetRight();
viewportMetrics.paddingRight =
zeroSides == ZeroSides.RIGHT || zeroSides == ZeroSides.BOTH
? 0
: insets.getSystemWindowInsetRight();
viewportMetrics.paddingBottom = 0;
viewportMetrics.paddingLeft = insets.getSystemWindowInsetLeft();
viewportMetrics.paddingLeft =
zeroSides == ZeroSides.LEFT || zeroSides == ZeroSides.BOTH
? 0
: insets.getSystemWindowInsetLeft();

// Bottom system inset (keyboard) should adjust scrollable bottom edge (inset).
viewportMetrics.viewInsetTop = 0;
viewportMetrics.viewInsetRight = 0;
viewportMetrics.viewInsetBottom = insets.getSystemWindowInsetBottom();
viewportMetrics.viewInsetBottom =
navigationBarHidden
? guessBottomKeyboardInset(insets)
: insets.getSystemWindowInsetBottom();
viewportMetrics.viewInsetLeft = 0;

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Expand Down
20 changes: 12 additions & 8 deletions shell/platform/android/io/flutter/view/FlutterView.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import android.view.View;
import android.view.ViewStructure;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.autofill.AutofillValue;
Expand Down Expand Up @@ -535,21 +536,24 @@ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight)
// android may decide to place the software navigation bars on the side. When the nav
// bar is hidden, the reported insets should be removed to prevent extra useless space
// on the sides.
enum ZeroSides {
private enum ZeroSides {
NONE,
LEFT,
RIGHT,
BOTH
}

ZeroSides calculateShouldZeroSides() {
private ZeroSides calculateShouldZeroSides() {
// We get both orientation and rotation because rotation is all 4
// rotations relative to default rotation while orientation is portrait
// or landscape. By combining both, we can obtain a more precise measure
// of the rotation.
Activity activity = (Activity) getContext();
int orientation = activity.getResources().getConfiguration().orientation;
int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
Context context = getContext();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This avoids casting to Activity which can be unsafe

int orientation = context.getResources().getConfiguration().orientation;
int rotation =
((WindowManager) context.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay()
.getRotation();

if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (rotation == Surface.ROTATION_90) {
Expand All @@ -568,7 +572,7 @@ else if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
return ZeroSides.NONE;
}

// TODO(garyq): Use clean ways to detect keyboard instead of heuristics if possible
// TODO(garyq): Use new Android R getInsets API
// TODO(garyq): The keyboard detection may interact strangely with
// https://github.com/flutter/flutter/issues/22061

Expand All @@ -578,7 +582,7 @@ else if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) {
// can be used.
@TargetApi(20)
@RequiresApi(20)
int calculateBottomKeyboardInset(WindowInsets insets) {
private int guessBottomKeyboardInset(WindowInsets insets) {
int screenHeight = getRootView().getHeight();
// Magic number due to this being a heuristic. This should be replaced, but we have not
// found a clean way to do it yet (Sept. 2018)
Expand Down Expand Up @@ -632,7 +636,7 @@ public final WindowInsets onApplyWindowInsets(WindowInsets insets) {
// the navbar padding should always be provided.
mMetrics.physicalViewInsetBottom =
navigationBarHidden
? calculateBottomKeyboardInset(insets)
? guessBottomKeyboardInset(insets)
: insets.getSystemWindowInsetBottom();
mMetrics.physicalViewInsetLeft = 0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.embedding.engine.FlutterJNI;
import io.flutter.embedding.engine.loader.FlutterLoader;
Expand All @@ -33,9 +34,11 @@
import org.mockito.stubbing.Answer;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.shadows.ShadowDisplay;

// TODO(xster): we have 2 versions of robolectric Android shadows in
// shell/platform/android/embedding_bundle/build.gradle. Remove the older
Expand Down Expand Up @@ -278,6 +281,100 @@ public void reportSystemInsetWhenNotFullscreen() {
assertEquals(100, viewportMetricsCaptor.getValue().paddingRight);
}

@Test
public void systemInsetHandlesFullscreenNavbarRight() {
RuntimeEnvironment.setQualifiers("+land");
FlutterView flutterView = spy(new FlutterView(RuntimeEnvironment.systemContext));
ShadowDisplay display =
Shadows.shadowOf(
((WindowManager)
RuntimeEnvironment.systemContext.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay());
display.setRotation(1);
assertEquals(0, flutterView.getSystemUiVisibility());
when(flutterView.getWindowSystemUiVisibility())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure this works? You're altering the response of the spy instance but I'm assuming FlutterView is calling getWindowSystemUiVisibility() against itself internally and not the spy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it does. It is getting the correct values internally, and executing the right branches of code. Removing this breaks it.

.thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
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<FlutterRenderer.ViewportMetrics> viewportMetricsCaptor =
ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class);
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);

// Then we simulate the system applying a window inset.
WindowInsets windowInsets = mock(WindowInsets.class);
when(windowInsets.getSystemWindowInsetTop()).thenReturn(100);
when(windowInsets.getSystemWindowInsetBottom()).thenReturn(100);
when(windowInsets.getSystemWindowInsetLeft()).thenReturn(100);
when(windowInsets.getSystemWindowInsetRight()).thenReturn(100);

flutterView.onApplyWindowInsets(windowInsets);

verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture());
// Top padding is removed due to full screen.
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);
// Padding bottom is always 0.
assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom);
assertEquals(100, viewportMetricsCaptor.getValue().paddingLeft);
// Right padding is zero because the rotation is 270deg
assertEquals(0, viewportMetricsCaptor.getValue().paddingRight);
}

@Test
public void systemInsetHandlesFullscreenNavbarLeft() {
RuntimeEnvironment.setQualifiers("+land");
FlutterView flutterView = spy(new FlutterView(RuntimeEnvironment.systemContext));
ShadowDisplay display =
Shadows.shadowOf(
((WindowManager)
RuntimeEnvironment.systemContext.getSystemService(Context.WINDOW_SERVICE))
.getDefaultDisplay());
display.setRotation(3);
assertEquals(0, flutterView.getSystemUiVisibility());
when(flutterView.getWindowSystemUiVisibility())
.thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
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<FlutterRenderer.ViewportMetrics> viewportMetricsCaptor =
ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class);
verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture());
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);

// Then we simulate the system applying a window inset.
WindowInsets windowInsets = mock(WindowInsets.class);
when(windowInsets.getSystemWindowInsetTop()).thenReturn(100);
when(windowInsets.getSystemWindowInsetBottom()).thenReturn(100);
when(windowInsets.getSystemWindowInsetLeft()).thenReturn(100);
when(windowInsets.getSystemWindowInsetRight()).thenReturn(100);

flutterView.onApplyWindowInsets(windowInsets);

verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture());
// Top padding is removed due to full screen.
assertEquals(0, viewportMetricsCaptor.getValue().paddingTop);
// Padding bottom is always 0.
assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom);
// Left padding is zero because the rotation is 270deg
assertEquals(0, viewportMetricsCaptor.getValue().paddingLeft);
assertEquals(100, viewportMetricsCaptor.getValue().paddingRight);
}

/*
* A custom shadow that reports fullscreen flag for system UI visibility
*/
Expand Down