diff --git a/DEPS b/DEPS index 297af528b902e..3d56e8fd1dbc6 100644 --- a/DEPS +++ b/DEPS @@ -34,7 +34,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/master/DEPS. # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': '52130c19ca593b185ea9cf72b26b1d02455551ef', + 'dart_revision': '4215dca724fb80de592f51a6cdba51e7638d1723', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/ci/licenses_golden/licenses_third_party b/ci/licenses_golden/licenses_third_party index f5805269c9196..69672b2b72f6f 100644 --- a/ci/licenses_golden/licenses_third_party +++ b/ci/licenses_golden/licenses_third_party @@ -1,4 +1,4 @@ -Signature: a1bbcd05a2657658be7c5f38e0d366f4 +Signature: 52ed6d65d7e96daef749ee003a3463a0 UNUSED LICENSES: diff --git a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java index 094b8ef1c53e8..14d7effe56c11 100644 --- a/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java +++ b/shell/platform/android/io/flutter/embedding/android/AndroidKeyProcessor.java @@ -70,6 +70,15 @@ public AndroidKeyProcessor( this.keyEventChannel.setEventResponseHandler(eventResponder); } + /** + * Detaches the key processor from the Flutter engine. + * + *

The AndroidKeyProcessor instance should not be used after calling this. + */ + public void destroy() { + keyEventChannel.setEventResponseHandler(null); + } + /** * Called when a key up event is received by the {@link FlutterView}. * diff --git a/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 2f1942034a7e5..c5c92a05d9dc3 100644 --- a/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -992,6 +992,8 @@ public void detachFromFlutterEngine() { textInputPlugin.getInputMethodManager().restartInput(this); textInputPlugin.destroy(); + androidKeyProcessor.destroy(); + if (mouseCursorPlugin != null) { mouseCursorPlugin.destroy(); } diff --git a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java index c11d264c21988..3090c6fba81fa 100644 --- a/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java +++ b/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java @@ -28,6 +28,7 @@ import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodSubtype; +import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -192,6 +193,7 @@ public void sendAppPrivateCommand(String action, Bundle data) { @TargetApi(30) @RequiresApi(30) @SuppressLint({"NewApi", "Override"}) + @Keep class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback implements View.OnApplyWindowInsetsListener { private int overlayInsetTypes; @@ -199,7 +201,17 @@ class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback private View view; private WindowInsets lastWindowInsets; - private boolean started = false; + // True when an animation that matches deferredInsetTypes is active. + // + // While this is active, this class will capture the initial window inset + // sent into lastWindowInsets by flagging needsSave to true, and will hold + // onto the intitial inset until the animation is completed, when it will + // re-dispatch the inset change. + private boolean animating = false; + // When an animation begins, android sends a WindowInset with the final + // state of the animation. When needsSave is true, we know to capture this + // initial WindowInset. + private boolean needsSave = false; ImeSyncDeferringInsetsCallback( @NonNull View view, int overlayInsetTypes, int deferredInsetTypes) { @@ -212,34 +224,38 @@ class ImeSyncDeferringInsetsCallback extends WindowInsetsAnimation.Callback @Override public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) { this.view = view; - if (started) { + if (needsSave) { + // Store the view and insets for us in onEnd() below. This captured inset + // is not part of the animation and instead, represents the final state + // of the inset after the animation is completed. Thus, we defer the processing + // of this WindowInset until the animation completes. + lastWindowInsets = windowInsets; + needsSave = false; + } + if (animating) { // While animation is running, we consume the insets to prevent disrupting // the animation, which skips this implementation and calls the view's // onApplyWindowInsets directly to avoid being consumed here. return WindowInsets.CONSUMED; } - // Store the view and insets for us in onEnd() below - lastWindowInsets = windowInsets; - // If no animation is happening, pass the insets on to the view's own // inset handling. return view.onApplyWindowInsets(windowInsets); } @Override - public WindowInsetsAnimation.Bounds onStart( - WindowInsetsAnimation animation, WindowInsetsAnimation.Bounds bounds) { + public void onPrepare(WindowInsetsAnimation animation) { if ((animation.getTypeMask() & deferredInsetTypes) != 0) { - started = true; + animating = true; + needsSave = true; } - return bounds; } @Override public WindowInsets onProgress( WindowInsets insets, List runningAnimations) { - if (!started) { + if (!animating || needsSave) { return insets; } boolean matching = false; @@ -280,10 +296,10 @@ public WindowInsets onProgress( @Override public void onEnd(WindowInsetsAnimation animation) { - if (started && (animation.getTypeMask() & deferredInsetTypes) != 0) { + if (animating && (animation.getTypeMask() & deferredInsetTypes) != 0) { // If we deferred the IME insets and an IME animation has finished, we need to reset // the flags - started = false; + animating = false; // And finally dispatch the deferred insets to the view now. // Ideally we would just call view.requestApplyInsets() and let the normal dispatch diff --git a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java index 23369a2d3109b..8eddb009dc828 100644 --- a/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java +++ b/shell/platform/android/test/io/flutter/embedding/android/AndroidKeyProcessorTest.java @@ -2,7 +2,9 @@ import static junit.framework.TestCase.assertEquals; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.notNull; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -56,6 +58,22 @@ public void respondsTrueWhenHandlingNewEvents() { verify(fakeView, times(0)).dispatchKeyEvent(any(KeyEvent.class)); } + @Test + public void destroyTest() { + FlutterEngine flutterEngine = mockFlutterEngine(); + KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); + View fakeView = mock(View.class); + + AndroidKeyProcessor processor = + new AndroidKeyProcessor(fakeView, fakeKeyEventChannel, mock(TextInputPlugin.class)); + + verify(fakeKeyEventChannel, times(1)) + .setEventResponseHandler(notNull(KeyEventChannel.EventResponseHandler.class)); + processor.destroy(); + verify(fakeKeyEventChannel, times(1)) + .setEventResponseHandler(isNull(KeyEventChannel.EventResponseHandler.class)); + } + public void synthesizesEventsWhenKeyDownNotHandled() { FlutterEngine flutterEngine = mockFlutterEngine(); KeyEventChannel fakeKeyEventChannel = flutterEngine.getKeyEventChannel(); diff --git a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java index 562f1f51dcf13..b038eb56000f3 100644 --- a/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java +++ b/shell/platform/android/test/io/flutter/plugin/editing/TextInputPluginTest.java @@ -669,6 +669,8 @@ public void ime_windowInsetsSync() { WindowInsets.Builder builder = new WindowInsets.Builder(); WindowInsets noneInsets = builder.build(); + // imeInsets0, 1, and 2 contain unique IME bottom insets, and are used + // to distinguish which insets were sent at each stage. builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 100)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); WindowInsets imeInsets0 = builder.build(); @@ -677,6 +679,10 @@ public void ime_windowInsetsSync() { builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); WindowInsets imeInsets1 = builder.build(); + builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 50)); + builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 40)); + WindowInsets imeInsets2 = builder.build(); + builder.setInsets(WindowInsets.Type.ime(), Insets.of(0, 0, 0, 200)); builder.setInsets(WindowInsets.Type.navigationBars(), Insets.of(10, 10, 10, 0)); WindowInsets deferredInsets = builder.build(); @@ -696,6 +702,8 @@ public void ime_windowInsetsSync() { imeSyncCallback.onPrepare(animation); imeSyncCallback.onApplyWindowInsets(testView, deferredInsets); imeSyncCallback.onStart(animation, null); + // Only the final state call is saved, extra calls are passed on. + imeSyncCallback.onApplyWindowInsets(testView, imeInsets2); verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); // No change, as deferredInset is stored to be passed in onEnd() @@ -723,7 +731,7 @@ public void ime_windowInsetsSync() { imeSyncCallback.onEnd(animation); verify(flutterRenderer).setViewportMetrics(viewportMetricsCaptor.capture()); - // Values should be of deferredInsets + // Values should be of deferredInsets, not imeInsets2 assertEquals(0, viewportMetricsCaptor.getValue().paddingBottom); assertEquals(10, viewportMetricsCaptor.getValue().paddingTop); assertEquals(200, viewportMetricsCaptor.getValue().viewInsetBottom); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm index 0179debf2c5a4..53cbff2202720 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm @@ -90,6 +90,15 @@ - (instancetype)initWithFrame:(CGRect)frame { return self; } +// In some scenarios, when we add this view as a maskView of the ChildClippingView, iOS added +// this view as a subview of the ChildClippingView. +// This results this view blocking touch events on the ChildClippingView. +// So we should always ignore any touch events sent to this view. +// See https://github.com/flutter/flutter/issues/66044 +- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event { + return NO; +} + - (void)drawRect:(CGRect)rect { CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSaveGState(context); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index ba7a8b5ca9471..792256082fbf7 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -326,7 +326,11 @@ - (void)pushRoute:(NSString*)route { auto placeholder = [[[UIView alloc] init] autorelease]; placeholder.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - placeholder.backgroundColor = UIColor.whiteColor; + if (@available(iOS 13.0, *)) { + placeholder.backgroundColor = UIColor.systemBackgroundColor; + } else { + placeholder.backgroundColor = UIColor.whiteColor; + } placeholder.autoresizesSubviews = YES; // Only add the label when we know we have failed to enable tracing (and it was necessary). @@ -339,9 +343,9 @@ - (void)pushRoute:(NSString*)route { messageLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; messageLabel.text = - @"In iOS 14+, Flutter application in debug mode can only be launched from Flutter tooling, " + @"In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling, " @"IDEs with Flutter plugins or from Xcode.\n\nAlternatively, build in profile or release " - @"modes to enable re-launching from the home screen."; + @"modes to enable launching from the home screen."; [placeholder addSubview:messageLabel]; } diff --git a/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m b/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m index d09cc4db033c4..b981938a05bfc 100644 --- a/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m +++ b/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m @@ -23,7 +23,9 @@ @implementation AppDelegate - (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; - + if ([[[NSProcessInfo processInfo] arguments] containsObject:@"--maskview-blocking"]) { + self.window.tintColor = UIColor.systemPinkColor; + } NSDictionary* launchArgsMap = @{ // The Platform view golden test args should match `PlatformViewGoldenTestManager`. @"--locale-initialization" : @"locale_initialization", @@ -58,7 +60,6 @@ - (BOOL)application:(UIApplication*)application *stop = YES; } }]; - if (flutterViewControllerTestName) { [self setupFlutterViewControllerTest:flutterViewControllerTestName]; } else if ([[[NSProcessInfo processInfo] arguments] containsObject:@"--screen-before-flutter"]) { diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/PlatformViewGestureRecognizerTests.m b/testing/scenario_app/ios/Scenarios/ScenariosUITests/PlatformViewGestureRecognizerTests.m index 3d583e1d5e824..6771454c40318 100644 --- a/testing/scenario_app/ios/Scenarios/ScenariosUITests/PlatformViewGestureRecognizerTests.m +++ b/testing/scenario_app/ios/Scenarios/ScenariosUITests/PlatformViewGestureRecognizerTests.m @@ -110,9 +110,54 @@ - (void)testAccept { [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:platformView]; [platformView tap]; + + [self waitForExpectations:@[ expection ] timeout:kSecondsToWaitForPlatformView]; + XCTAssertEqualObjects(platformView.label, + @"-gestureTouchesBegan-gestureTouchesEnded-platformViewTapped"); +} + +- (void)testGestureWithMaskViewBlockingPlatformView { + XCUIApplication* app = [[XCUIApplication alloc] init]; + app.launchArguments = @[ @"--gesture-accept", @"--maskview-blocking" ]; + [app launch]; + + NSPredicate* predicateToFindPlatformView = + [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, + NSDictionary* _Nullable bindings) { + XCUIElement* element = evaluatedObject; + return [element.identifier hasPrefix:@"platform_view"]; + }]; + XCUIElement* platformView = [app.textViews elementMatchingPredicate:predicateToFindPlatformView]; + if (![platformView waitForExistenceWithTimeout:kSecondsToWaitForPlatformView]) { + NSLog(@"%@", app.debugDescription); + XCTFail(@"Failed due to not able to find any platformView with %@ seconds", + @(kSecondsToWaitForPlatformView)); + } + + XCTAssertNotNil(platformView); + XCTAssertEqualObjects(platformView.label, @""); + + NSPredicate* predicate = [NSPredicate + predicateWithFormat:@"label == %@", + @"-gestureTouchesBegan-gestureTouchesEnded-platformViewTapped"]; + XCTNSPredicateExpectation* expection = + [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:platformView]; + + XCUICoordinate* coordinate = + [self getNormalizedCoordinate:app + point:CGVectorMake(platformView.frame.origin.x + 10, + platformView.frame.origin.y + 10)]; + [coordinate tap]; + [self waitForExpectations:@[ expection ] timeout:kSecondsToWaitForPlatformView]; XCTAssertEqualObjects(platformView.label, @"-gestureTouchesBegan-gestureTouchesEnded-platformViewTapped"); } +- (XCUICoordinate*)getNormalizedCoordinate:(XCUIApplication*)app point:(CGVector)vector { + XCUICoordinate* appZero = [app coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; + XCUICoordinate* coordinate = [appZero coordinateWithOffset:vector]; + return coordinate; +} + @end diff --git a/testing/scenario_app/lib/src/platform_view.dart b/testing/scenario_app/lib/src/platform_view.dart index e2d0d6a0e3d7b..90fc0693e0433 100644 --- a/testing/scenario_app/lib/src/platform_view.dart +++ b/testing/scenario_app/lib/src/platform_view.dart @@ -336,9 +336,9 @@ class MultiPlatformViewBackgroundForegroundScenario extends Scenario with _BaseP MultiPlatformViewBackgroundForegroundScenario(Window window, {this.firstId, this.secondId}) : assert(window != null), super(window) { + _nextFrame = _firstFrame; createPlatformView(window, 'platform view 1', firstId); createPlatformView(window, 'platform view 2', secondId); - _nextFrame = _firstFrame; } /// The platform view identifier to use for the first platform view. @@ -532,6 +532,8 @@ class PlatformViewForTouchIOSScenario extends Scenario int _viewId; bool _accept; + + VoidCallback _nextFrame; /// Creates the PlatformView scenario. /// /// The [window] parameter must not be null. @@ -545,14 +547,24 @@ class PlatformViewForTouchIOSScenario extends Scenario } else { createPlatformView(window, text, id); } + _nextFrame = _firstFrame; } @override void onBeginFrame(Duration duration) { - final SceneBuilder builder = SceneBuilder(); + _nextFrame(); + } - builder.pushOffset(0, 0); - finishBuilderByAddingPlatformViewAndPicture(builder, _viewId); + @override + void onDrawFrame() { + // Some iOS gesture recognizers bugs are introduced in the second frame (with a different platform view rect) after laying out the platform view. + // So in this test, we load 2 frames to ensure that we cover those cases. + // See https://github.com/flutter/flutter/issues/66044 + if (_nextFrame == _firstFrame) { + _nextFrame = _secondFrame; + window.scheduleFrame(); + } + super.onDrawFrame(); } @override @@ -585,6 +597,20 @@ class PlatformViewForTouchIOSScenario extends Scenario } } + + void _firstFrame() { + final SceneBuilder builder = SceneBuilder(); + + builder.pushOffset(0, 0); + finishBuilderByAddingPlatformViewAndPicture(builder, _viewId); + } + + void _secondFrame() { + final SceneBuilder builder = SceneBuilder(); + + builder.pushOffset(5, 5); + finishBuilderByAddingPlatformViewAndPicture(builder, _viewId); + } } mixin _BasePlatformViewScenarioMixin on Scenario {