diff --git a/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java new file mode 100644 index 0000000000000..c27eef4cdbd16 --- /dev/null +++ b/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -0,0 +1,946 @@ +package io.flutter.embedding.android; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +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.when; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Canvas; +import android.graphics.Insets; +import android.graphics.Rect; +import android.graphics.Region; +import android.hardware.HardwareBuffer; +import android.media.Image; +import android.media.Image.Plane; +import android.media.ImageReader; +import android.os.Build; +import android.provider.Settings; +import android.view.DisplayCutout; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.FrameLayout; +import androidx.core.util.Consumer; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.window.layout.FoldingFeature; +import androidx.window.layout.WindowLayoutInfo; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.FlutterJNI; +import io.flutter.embedding.engine.loader.FlutterLoader; +import io.flutter.embedding.engine.renderer.FlutterRenderer; +import io.flutter.embedding.engine.systemchannels.SettingsChannel; +import io.flutter.plugin.platform.PlatformViewsController; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.Robolectric; +import org.robolectric.Shadows; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.shadows.ShadowDisplay; + +@Config(manifest = Config.NONE) +@RunWith(AndroidJUnit4.class) +@TargetApi(30) +public class FlutterViewTest { + private final Context ctx = ApplicationProvider.getApplicationContext(); + @Mock FlutterJNI mockFlutterJni; + @Mock FlutterLoader mockFlutterLoader; + @Spy PlatformViewsController platformViewsController; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(mockFlutterJni.isAttached()).thenReturn(true); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void attachToFlutterEngine_alertsPlatformViews() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + when(flutterEngine.getPlatformViewsController()).thenReturn(platformViewsController); + + flutterView.attachToFlutterEngine(flutterEngine); + + verify(platformViewsController, times(1)).attachToView(flutterView); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void flutterView_importantForAutofillDoesNotExcludeDescendants() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + + // Value should not exclude descendants because platform views are added as child views and + // can be eligible for autofill (e.g. a WebView). + assertEquals(View.IMPORTANT_FOR_AUTOFILL_YES, flutterView.getImportantForAutofill()); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void detachFromFlutterEngine_alertsPlatformViews() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + when(flutterEngine.getPlatformViewsController()).thenReturn(platformViewsController); + + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.detachFromFlutterEngine(); + + verify(platformViewsController, times(1)).detachFromView(); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void detachFromFlutterEngine_turnsOffA11y() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.detachFromFlutterEngine(); + + verify(flutterRenderer, times(1)).setSemanticsEnabled(false); + } + + @Test + public void detachFromFlutterEngine_revertImageView() { + FlutterView flutterView = new FlutterView(ctx); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + + flutterView.attachToFlutterEngine(flutterEngine); + assertFalse(flutterView.renderSurface instanceof FlutterImageView); + + flutterView.convertToImageView(); + assertTrue(flutterView.renderSurface instanceof FlutterImageView); + + flutterView.detachFromFlutterEngine(); + assertFalse(flutterView.renderSurface instanceof FlutterImageView); + } + + @Test + public void detachFromFlutterEngine_removeImageView() { + FlutterView flutterView = new FlutterView(ctx); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.convertToImageView(); + assertEquals(flutterView.getChildCount(), 2); + View view = flutterView.getChildAt(1); + assertTrue(view instanceof FlutterImageView); + + flutterView.detachFromFlutterEngine(); + assertEquals(flutterView.getChildCount(), 1); + view = flutterView.getChildAt(0); + assertFalse(view instanceof FlutterImageView); + } + + @Test + public void detachFromFlutterEngine_closesImageView() { + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + FlutterImageView imageViewMock = mock(FlutterImageView.class); + when(imageViewMock.getAttachedRenderer()).thenReturn(flutterRenderer); + + FlutterView flutterView = spy(new FlutterView(ctx)); + when(flutterView.createImageView()).thenReturn(imageViewMock); + + flutterView.attachToFlutterEngine(flutterEngine); + + assertFalse(flutterView.renderSurface == imageViewMock); + + flutterView.convertToImageView(); + assertTrue(flutterView.renderSurface == imageViewMock); + + flutterView.detachFromFlutterEngine(); + assertFalse(flutterView.renderSurface == imageViewMock); + verify(imageViewMock, times(1)).closeImageReader(); + } + + @Test + public void flutterImageView_revertImageViewAndAvoidNPE() { + FlutterView flutterView = new FlutterView(ctx); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.convertToImageView(); + assertTrue(flutterView.renderSurface instanceof FlutterImageView); + + // Register a `FlutterUiDisplayListener` callback. + // During callback execution it will invoke `flutterImageView.detachFromRenderer()`. + flutterView.revertImageView( + () -> { + // No-op + }); + assertFalse(flutterView.renderSurface instanceof FlutterImageView); + + flutterView.detachFromFlutterEngine(); + assertEquals(null, flutterView.getCurrentImageSurface()); + + // Invoke all registered `FlutterUiDisplayListener` callback + mockFlutterJni.onFirstFrame(); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void onConfigurationChanged_fizzlesWhenNullEngine() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + + Configuration configuration = ctx.getResources().getConfiguration(); + // 1 invocation of channels. + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.onConfigurationChanged(configuration); + flutterView.detachFromFlutterEngine(); + + // Should fizzle. + flutterView.onConfigurationChanged(configuration); + + verify(flutterEngine, times(1)).getLocalizationPlugin(); + verify(flutterEngine, times(2)).getSettingsChannel(); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + @Test + public void onConfigurationChanged_notifiesEngineOfDisplaySize() { + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + + Configuration configuration = ctx.getResources().getConfiguration(); + + flutterView.attachToFlutterEngine(flutterEngine); + flutterView.onConfigurationChanged(configuration); + + verify(flutterEngine, times(1)) + .updateDisplayMetrics(any(Float.class), any(Float.class), any(Float.class)); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + public void itSendsTextHidePasswordToFrameworkOnAttach() { + // Setup test. + AtomicReference reportedShowPassword = new AtomicReference<>(); + + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + Settings.System.putInt( + flutterView.getContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 0); + + SettingsChannel fakeSettingsChannel = mock(SettingsChannel.class); + SettingsChannel.MessageBuilder fakeMessageBuilder = mock(SettingsChannel.MessageBuilder.class); + when(fakeMessageBuilder.setTextScaleFactor(any(Float.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setPlatformBrightness(any(SettingsChannel.PlatformBrightness.class))) + .thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setUse24HourFormat(any(Boolean.class))).thenReturn(fakeMessageBuilder); + when(fakeMessageBuilder.setBrieflyShowPassword(any(Boolean.class))) + .thenAnswer( + new Answer() { + @Override + public SettingsChannel.MessageBuilder answer(InvocationOnMock invocation) + throws Throwable { + reportedShowPassword.set((Boolean) invocation.getArguments()[0]); + return fakeMessageBuilder; + } + }); + when(fakeSettingsChannel.startMessage()).thenReturn(fakeMessageBuilder); + when(flutterEngine.getSettingsChannel()).thenReturn(fakeSettingsChannel); + + flutterView.attachToFlutterEngine(flutterEngine); + + // Verify results. + assertFalse(reportedShowPassword.get()); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity, getSystemUiVisibility + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + // 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 reportSystemInsetWhenNotFullscreen() { + // Without custom shadows, the default system ui visibility flags is 0. + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + assertEquals(0, flutterView.getSystemUiVisibility()); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = + new WindowInsets.Builder() + .setInsets( + android.view.WindowInsets.Type.navigationBars() + | android.view.WindowInsets.Type.statusBars(), + Insets.of(100, 100, 100, 100)) + .build(); + flutterView.onApplyWindowInsets(windowInsets); + + // Verify. + verify(flutterRenderer, times(3)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Top padding is reported as-is. + validateViewportMetricPadding(viewportMetricsCaptor, 100, 100, 100, 100); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity, getSystemUiVisibility + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + // This test uses the pre-API 30 Algorithm for window insets. + @Test + @TargetApi(28) + @Config(sdk = 28) + public void reportSystemInsetWhenNotFullscreenLegacy() { + // Without custom shadows, the default system ui visibility flags is 0. + FlutterView flutterView = new FlutterView(Robolectric.setupActivity(Activity.class)); + assertEquals(0, flutterView.getSystemUiVisibility()); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, 100, 100, 100, 100); + flutterView.onApplyWindowInsets(windowInsets); + + // Verify. + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Top padding is reported as-is. + validateViewportMetricPadding(viewportMetricsCaptor, 100, 100, 100, 0); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility, required to test pre api 30 behavior. + @Test + @Config(minSdk = 23, maxSdk = 29, qualifiers = "land") + public void systemInsetHandlesFullscreenNavbarRight() { + FlutterView flutterView = spy(new FlutterView(ctx)); + setExpectedDisplayRotation(Surface.ROTATION_90); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()) + .thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, 100, 100, 100, 100); + mockSystemGestureInsetsIfNeed(windowInsets); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Top padding is removed due to full screen. + // Right padding is zero because the rotation is 90deg + // Bottom padding is removed due to hide navigation. + validateViewportMetricPadding(viewportMetricsCaptor, 100, 0, 0, 0); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility required to test pre api 30 behavior. + @Test + @Config(minSdk = 20, maxSdk = 22, qualifiers = "land") + public void systemInsetHandlesFullscreenNavbarRightBelowSDK23() { + FlutterView flutterView = spy(new FlutterView(ctx)); + setExpectedDisplayRotation(Surface.ROTATION_270); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()) + .thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, 100, 100, 100, 100); + mockSystemGestureInsetsIfNeed(windowInsets); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Top padding is removed due to full screen. + // Right padding is zero because the rotation is 270deg under SDK 23 + // Bottom padding is removed due to hide navigation. + validateViewportMetricPadding(viewportMetricsCaptor, 100, 0, 0, 0); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility required to test pre api 30 behavior. + @Test + @Config(minSdk = 23, maxSdk = 29, qualifiers = "land") + public void systemInsetHandlesFullscreenNavbarLeft() { + FlutterView flutterView = spy(new FlutterView(ctx)); + setExpectedDisplayRotation(Surface.ROTATION_270); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()) + .thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, 100, 100, 100, 100); + mockSystemGestureInsetsIfNeed(windowInsets); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Left padding is zero because the rotation is 270deg + // Top padding is removed due to full screen. + // Bottom padding is removed due to hide navigation. + validateViewportMetricPadding(viewportMetricsCaptor, 0, 0, 100, 0); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility required to test interop with api 30 + // behavior. + // 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, qualifiers = "land") + public void systemInsetGetInsetsFullscreen() { + FlutterView flutterView = spy(new FlutterView(ctx)); + setExpectedDisplayRotation(Surface.ROTATION_270); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()) + .thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + Insets insets = Insets.of(10, 20, 30, 40); + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, -1, -1, -1, -1); + when(windowInsets.getInsets(anyInt())).thenReturn(insets); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + validateViewportMetricPadding(viewportMetricsCaptor, 10, 20, 30, 40); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility required to test pre api 30 behavior. + // This test uses the pre-API 30 Algorithm for window insets. + @Test + @TargetApi(28) + @Config(sdk = 28, qualifiers = "land") + public void systemInsetGetInsetsFullscreenLegacy() { + FlutterView flutterView = spy(new FlutterView(ctx)); + setExpectedDisplayRotation(Surface.ROTATION_270); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()) + .thenReturn(View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + // Then we simulate the system applying a window inset. + WindowInsets windowInsets = mock(WindowInsets.class); + mockSystemWindowInsets(windowInsets, 102, 100, 103, 101); + + flutterView.onApplyWindowInsets(windowInsets); + + verify(flutterRenderer, times(2)).setViewportMetrics(viewportMetricsCaptor.capture()); + // Left padding is zero because the rotation is 270deg + // Top padding is removed due to full screen. + // Bottom padding is removed due to hide navigation. + validateViewportMetricPadding(viewportMetricsCaptor, 0, 0, 103, 0); + } + + @SuppressWarnings("deprecation") + // getSystemUiVisibility, getWindowSystemUiVisibility, getSystemGestureInsets required + // to test pre api 30 interop behavior. + // 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, qualifiers = "land") + public void systemInsetDisplayCutoutSimple() { + FlutterView flutterView = spy(new FlutterView(ctx)); + assertEquals(0, flutterView.getSystemUiVisibility()); + when(flutterView.getWindowSystemUiVisibility()).thenReturn(0); + when(flutterView.getContext()).thenReturn(ctx); + + 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); + + 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); + mockSystemWindowInsets(windowInsets, -1, -1, -1, -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()); + validateViewportMetricPadding(viewportMetricsCaptor, 200, 150, 200, 150); + + assertEquals(100, viewportMetricsCaptor.getValue().viewInsetTop); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + // getDefaultDisplay + // TODO(jesswrd): https://github.com/flutter/flutter/issues/99421 + @Test + public void itRegistersAndUnregistersToWindowManager() { + Context context = Robolectric.setupActivity(Activity.class); + FlutterView flutterView = spy(new FlutterView(context)); + ShadowDisplay display = + Shadows.shadowOf( + ((WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay()); + WindowInfoRepositoryCallbackAdapterWrapper windowInfoRepo = + mock(WindowInfoRepositoryCallbackAdapterWrapper.class); + // For reasoning behing using doReturn instead of when, read "Important gotcha" at + // https://www.javadoc.io/doc/org.mockito/mockito-core/1.10.19/org/mockito/Mockito.html#13 + doReturn(windowInfoRepo).when(flutterView).createWindowInfoRepo(); + + // When a new FlutterView is attached to the window + flutterView.onAttachedToWindow(); + + // Then the WindowManager callback is registered + verify(windowInfoRepo, times(1)).addWindowLayoutInfoListener(any(), any(), any()); + + // When the FlutterView is detached from the window + flutterView.onDetachedFromWindow(); + + // Then the WindowManager callback is unregistered + verify(windowInfoRepo, times(1)).removeWindowLayoutInfoListener(any()); + } + + @SuppressWarnings("deprecation") + // Robolectric.setupActivity + // TODO(reidbaker): https://github.com/flutter/flutter/issues/133151 + // getDefaultDisplay + // TODO(jesswrd): https://github.com/flutter/flutter/issues/99421 + @Test + public void itSendsHingeDisplayFeatureToFlutter() { + Context context = Robolectric.setupActivity(Activity.class); + FlutterView flutterView = spy(new FlutterView(context)); + ShadowDisplay display = + Shadows.shadowOf( + ((WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay()); + when(flutterView.getContext()).thenReturn(context); + WindowInfoRepositoryCallbackAdapterWrapper windowInfoRepo = + mock(WindowInfoRepositoryCallbackAdapterWrapper.class); + doReturn(windowInfoRepo).when(flutterView).createWindowInfoRepo(); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + FoldingFeature displayFeature = mock(FoldingFeature.class); + when(displayFeature.getBounds()).thenReturn(new Rect(0, 0, 100, 100)); + when(displayFeature.getOcclusionType()).thenReturn(FoldingFeature.OcclusionType.FULL); + when(displayFeature.getState()).thenReturn(FoldingFeature.State.FLAT); + + WindowLayoutInfo testWindowLayout = new WindowLayoutInfo(Arrays.asList(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); + verify(windowInfoRepo).addWindowLayoutInfoListener(any(), any(), wmConsumerCaptor.capture()); + Consumer wmConsumer = wmConsumerCaptor.getValue(); + 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); + } + + @Test + public void flutterImageView_acquiresImageAndInvalidates() { + final ImageReader mockReader = mock(ImageReader.class); + when(mockReader.getMaxImages()).thenReturn(2); + + final FlutterImageView imageView = + spy(new FlutterImageView(ctx, mockReader, FlutterImageView.SurfaceKind.background)); + + final FlutterJNI jni = mock(FlutterJNI.class); + imageView.attachToRenderer(new FlutterRenderer(jni)); + + final Image mockImage = mock(Image.class); + when(mockReader.acquireLatestImage()).thenReturn(mockImage); + + assertTrue(imageView.acquireLatestImage()); + verify(mockReader, times(1)).acquireLatestImage(); + verify(imageView, times(1)).invalidate(); + } + + @Test + @SuppressLint("WrongCall") /*View#onDraw*/ + public void flutterImageView_acquiresImageClosesPreviousImageUnlessNoNewImage() { + final ImageReader mockReader = mock(ImageReader.class); + when(mockReader.getMaxImages()).thenReturn(3); + + final Image mockImage = mock(Image.class); + when(mockImage.getPlanes()).thenReturn(new Plane[0]); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + final HardwareBuffer mockHardwareBuffer = mock(HardwareBuffer.class); + when(mockHardwareBuffer.getUsage()).thenReturn(HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE); + when(mockImage.getHardwareBuffer()).thenReturn(mockHardwareBuffer); + } + // Mock no latest image on the second time + when(mockReader.acquireLatestImage()) + .thenReturn(mockImage) + .thenReturn(null) + .thenReturn(mockImage); + + final FlutterImageView imageView = + spy(new FlutterImageView(ctx, mockReader, FlutterImageView.SurfaceKind.background)); + + final FlutterJNI jni = mock(FlutterJNI.class); + imageView.attachToRenderer(new FlutterRenderer(jni)); + doNothing().when(imageView).invalidate(); + + assertTrue(imageView.acquireLatestImage()); // No previous, acquire latest image + assertFalse( + imageView.acquireLatestImage()); // Mock no image when acquire, don't close, and assertFalse + assertTrue(imageView.acquireLatestImage()); // Acquire latest image and close previous + assertTrue(imageView.acquireLatestImage()); // Acquire latest image and close previous + assertTrue(imageView.acquireLatestImage()); // Acquire latest image and close previous + verify(mockImage, times(3)).close(); // Close 3 times + + imageView.onDraw(mock(Canvas.class)); // Draw latest image + + assertTrue(imageView.acquireLatestImage()); // acquire latest image and close previous + + imageView.onDraw(mock(Canvas.class)); // Draw latest image + imageView.onDraw(mock(Canvas.class)); // Draw latest image + imageView.onDraw(mock(Canvas.class)); // Draw latest image + + verify(mockReader, times(6)).acquireLatestImage(); + } + + @Test + public void flutterImageView_detachFromRendererClosesPreviousImage() { + final ImageReader mockReader = mock(ImageReader.class); + when(mockReader.getMaxImages()).thenReturn(2); + + final Image mockImage = mock(Image.class); + when(mockReader.acquireLatestImage()).thenReturn(mockImage); + + final FlutterImageView imageView = + spy(new FlutterImageView(ctx, mockReader, FlutterImageView.SurfaceKind.background)); + + final FlutterJNI jni = mock(FlutterJNI.class); + imageView.attachToRenderer(new FlutterRenderer(jni)); + + doNothing().when(imageView).invalidate(); + imageView.acquireLatestImage(); + imageView.acquireLatestImage(); + verify(mockImage, times(1)).close(); + + imageView.detachFromRenderer(); + // There's an acquireLatestImage() in detachFromRenderer(), + // so it will be 2 times called close() inside detachFromRenderer() + verify(mockImage, times(3)).close(); + } + + @Test + public void flutterImageView_workaroundWithOnePixelWhenResizeWithZero() { + final ImageReader mockReader = mock(ImageReader.class); + when(mockReader.getMaxImages()).thenReturn(2); + + final FlutterImageView imageView = + spy(new FlutterImageView(ctx, mockReader, FlutterImageView.SurfaceKind.background)); + + final FlutterJNI jni = mock(FlutterJNI.class); + imageView.attachToRenderer(new FlutterRenderer(jni)); + + final Image mockImage = mock(Image.class); + when(mockReader.acquireLatestImage()).thenReturn(mockImage); + + final int incorrectWidth = 0; + final int incorrectHeight = -100; + imageView.resizeIfNeeded(incorrectWidth, incorrectHeight); + assertEquals(1, imageView.getImageReader().getWidth()); + assertEquals(1, imageView.getImageReader().getHeight()); + } + + @Test + public void flutterImageView_closesReader() { + final ImageReader mockReader = mock(ImageReader.class); + when(mockReader.getMaxImages()).thenReturn(1); + + final FlutterImageView imageView = + spy(new FlutterImageView(ctx, mockReader, FlutterImageView.SurfaceKind.background)); + + imageView.closeImageReader(); + verify(mockReader, times(1)).close(); + } + + @Test + public void flutterSurfaceView_GathersTransparentRegion() { + final Region mockRegion = mock(Region.class); + final FlutterSurfaceView surfaceView = new FlutterSurfaceView(ctx); + + surfaceView.setAlpha(0.0f); + assertFalse(surfaceView.gatherTransparentRegion(mockRegion)); + verify(mockRegion, times(0)).op(anyInt(), anyInt(), anyInt(), anyInt(), any()); + + surfaceView.setAlpha(1.0f); + assertTrue(surfaceView.gatherTransparentRegion(mockRegion)); + verify(mockRegion, times(1)).op(0, 0, 0, 0, Region.Op.DIFFERENCE); + } + + @Test + @SuppressLint("PrivateApi") + @Config(sdk = Build.VERSION_CODES.P) + public void findViewByAccessibilityIdTraversal_returnsRootViewOnAndroid28() throws Exception { + FlutterView flutterView = new FlutterView(ctx); + + Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId"); + Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(flutterView); + + assertEquals(flutterView, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId)); + } + + @Test + @Config(sdk = Build.VERSION_CODES.P) + @SuppressLint("PrivateApi") + public void findViewByAccessibilityIdTraversal_returnsChildViewOnAndroid28() throws Exception { + FlutterView flutterView = new FlutterView(ctx); + FrameLayout childView1 = new FrameLayout(ctx); + flutterView.addView(childView1); + + FrameLayout childView2 = new FrameLayout(ctx); + childView1.addView(childView2); + + Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId"); + Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(childView2); + + assertEquals(childView2, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId)); + } + + @Test + @Config(sdk = Build.VERSION_CODES.Q) + @SuppressLint("PrivateApi") + public void findViewByAccessibilityIdTraversal_returnsRootViewOnAndroid29() throws Exception { + FlutterView flutterView = new FlutterView(ctx); + + Method getAccessibilityViewIdMethod = View.class.getDeclaredMethod("getAccessibilityViewId"); + Integer accessibilityViewId = (Integer) getAccessibilityViewIdMethod.invoke(flutterView); + + assertEquals(null, flutterView.findViewByAccessibilityIdTraversal(accessibilityViewId)); + } + + public void ViewportMetrics_initializedPhysicalTouchSlop() { + FlutterView flutterView = new FlutterView(ctx); + FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); + FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); + when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); + + flutterView.attachToFlutterEngine(flutterEngine); + ArgumentCaptor viewportMetricsCaptor = + ArgumentCaptor.forClass(FlutterRenderer.ViewportMetrics.class); + verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); + + assertFalse(-1 == viewportMetricsCaptor.getValue().physicalTouchSlop); + } + + @SuppressWarnings("deprecation") + private void setExpectedDisplayRotation(int rotation) { + ShadowDisplay display = + Shadows.shadowOf( + ((WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay()); + display.setRotation(rotation); + } + + private void validateViewportMetricPadding( + ArgumentCaptor viewportMetricsCaptor, + int left, + int top, + int right, + int bottom) { + assertEquals(left, viewportMetricsCaptor.getValue().viewPaddingLeft); + assertEquals(top, viewportMetricsCaptor.getValue().viewPaddingTop); + assertEquals(right, viewportMetricsCaptor.getValue().viewPaddingRight); + assertEquals(bottom, viewportMetricsCaptor.getValue().viewPaddingBottom); + } + + @SuppressWarnings("deprecation") + private void mockSystemWindowInsets( + WindowInsets windowInsets, int left, int top, int right, int bottom) { + when(windowInsets.getSystemWindowInsetLeft()).thenReturn(left); + when(windowInsets.getSystemWindowInsetTop()).thenReturn(top); + when(windowInsets.getSystemWindowInsetRight()).thenReturn(right); + when(windowInsets.getSystemWindowInsetBottom()).thenReturn(bottom); + } + + @SuppressWarnings("deprecation") + private void mockSystemGestureInsetsIfNeed(WindowInsets windowInsets) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + when(windowInsets.getSystemGestureInsets()).thenReturn(Insets.NONE); + } + } + + /* + * A custom shadow that reports fullscreen flag for system UI visibility + */ + @Implements(View.class) + @SuppressWarnings("deprecation") + public static class ShadowFullscreenView { + @Implementation + public int getWindowSystemUiVisibility() { + return View.SYSTEM_UI_FLAG_FULLSCREEN; + } + } + + // ViewGroup is the first shadow in the type hierarchy for FlutterView. Shadows need to mimic + // production classes' view hierarchy. + @Implements(ViewGroup.class) + public static class ShadowFullscreenViewGroup extends ShadowFullscreenView {} +}