diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index ab7564d2d7132..87e605e97fd48 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -537,23 +537,32 @@ - (void)applicationBecameActive:(NSNotification*)notification { TRACE_EVENT0("flutter", "applicationBecameActive"); if (_viewportMetrics.physical_width) [self surfaceUpdated:YES]; - [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.resumed"]; + [self goToApplicationLifecycle:@"AppLifecycleState.resumed"]; } - (void)applicationWillResignActive:(NSNotification*)notification { TRACE_EVENT0("flutter", "applicationWillResignActive"); [self surfaceUpdated:NO]; - [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"]; + [self goToApplicationLifecycle:@"AppLifecycleState.inactive"]; } - (void)applicationDidEnterBackground:(NSNotification*)notification { TRACE_EVENT0("flutter", "applicationDidEnterBackground"); - [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"]; + [self goToApplicationLifecycle:@"AppLifecycleState.paused"]; } - (void)applicationWillEnterForeground:(NSNotification*)notification { TRACE_EVENT0("flutter", "applicationWillEnterForeground"); - [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"]; + [self goToApplicationLifecycle:@"AppLifecycleState.inactive"]; +} + +// Make this transition only while this current view controller is visible. +- (void)goToApplicationLifecycle:(nonnull NSString*)state { + // Accessing self.view will create the view. Check whether the view is organically loaded + // first before checking whether the view is attached to window. + if (self.isViewLoaded && self.view.window) { + [[_engine.get() lifecycleChannel] sendMessage:state]; + } } #pragma mark - Touch event handling diff --git a/testing/scenario_app/ios/Scenarios/Scenarios/ScreenBeforeFlutter.m b/testing/scenario_app/ios/Scenarios/Scenarios/ScreenBeforeFlutter.m index f5eef154390c9..c937b57405143 100644 --- a/testing/scenario_app/ios/Scenarios/Scenarios/ScreenBeforeFlutter.m +++ b/testing/scenario_app/ios/Scenarios/Scenarios/ScreenBeforeFlutter.m @@ -7,7 +7,7 @@ @implementation ScreenBeforeFlutter -FlutterEngine* _engine; +@synthesize engine = _engine; - (id)initWithEngineRunCompletion:(void (^)(void))engineRunCompletion { self = [super init]; diff --git a/testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m b/testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m index 2d405efc2bc91..dfd00ce516e22 100644 --- a/testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m +++ b/testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m @@ -6,6 +6,28 @@ #import #import "ScreenBeforeFlutter.h" +@interface XCAppLifecycleTestExpectation : XCTestExpectation + +- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step; +@property(nonatomic, readonly, copy) NSString* expectedLifecycle; + +@end + +@implementation XCAppLifecycleTestExpectation + +@synthesize expectedLifecycle = _expectedLifecycle; +- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step { + // The step is here because the callbacks into the handler which checks these expectations isn't + // synchronous with the executions in the test, so it's hard to find the cause in the test + // otherwise. + self = [super initWithDescription:[NSString stringWithFormat:@"Expected state %@ during step %@", + expectedLifecycle, step]]; + _expectedLifecycle = [expectedLifecycle copy]; + return self; +} + +@end + @interface AppLifecycleTests : XCTestCase @end @@ -16,7 +38,7 @@ - (void)setUp { self.continueAfterFailure = NO; } -- (void)testLifecycleChannel { +- (void)testDismissedFlutterViewControllerNotRespondingToApplicationLifecycle { XCTestExpectation* engineStartedExpectation = [self expectationWithDescription:@"Engine started"]; // Let the engine finish booting (at the end of which the channels are properly set-up) before @@ -32,121 +54,173 @@ - (void)testLifecycleChannel { FlutterEngine* engine = rootVC.engine; NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10]; - NSMutableArray* lifecycleEvents = [NSMutableArray arrayWithCapacity:10]; - - [lifecycleExpectations addObject:[[XCTestExpectation alloc] - initWithDescription:@"A loading FlutterViewController goes " - @"through AppLifecycleState.inactive"]]; - [lifecycleExpectations - addObject:[[XCTestExpectation alloc] - initWithDescription: - @"A loading FlutterViewController goes through AppLifecycleState.resumed"]]; + // Expected sequence from showing the FlutterViewController is inactive and resumed. + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"showing a FlutterViewController"], + [[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed" + forStep:@"showing a FlutterViewController"] + ]]; + + // Holding onto this FlutterViewController is consequential here. Since a released + // FlutterViewController wouldn't keep listening to the application lifecycle events and produce + // false positives for the application lifecycle tests further below. FlutterViewController* flutterVC = [rootVC showFlutter]; [engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) { if (lifecycleExpectations.count == 0) { XCTFail(@"Unexpected lifecycle transition: %@", message); + return; + } + XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0]; + if (![[nextExpectation expectedLifecycle] isEqualToString:message]) { + XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle], + message); + return; } - [lifecycleEvents addObject:message]; - [[lifecycleExpectations objectAtIndex:0] fulfill]; + + [nextExpectation fulfill]; [lifecycleExpectations removeObjectAtIndex:0]; }]; - [self waitForExpectations:lifecycleExpectations timeout:5]; - - // Expected sequence from showing the FlutterViewController is inactive and resumed. - NSArray* expectedStates = @[ @"AppLifecycleState.inactive", @"AppLifecycleState.resumed" ]; - XCTAssertEqualObjects(lifecycleEvents, expectedStates, - @"AppLifecycleState transitions while presenting not as expected"); + // The expectations list isn't dequeued by the message handler yet. + [self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES]; // Now dismiss the FlutterViewController again and expect another inactive and paused. - [lifecycleExpectations - addObject:[[XCTestExpectation alloc] - initWithDescription:@"A dismissed FlutterViewController goes through " - @"AppLifecycleState.inactive"]]; - [lifecycleExpectations - addObject:[[XCTestExpectation alloc] - initWithDescription:@"A dismissed FlutterViewController goes through " - @"AppLifecycleState.paused"]]; + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"dismissing a FlutterViewController"], + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.paused" + forStep:@"dismissing a FlutterViewController"] + ]]; [flutterVC dismissViewControllerAnimated:NO completion:nil]; - [self waitForExpectations:lifecycleExpectations timeout:5]; - expectedStates = @[ - @"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive", - @"AppLifecycleState.paused" - ]; - XCTAssertEqualObjects(lifecycleEvents, expectedStates, - @"AppLifecycleState transitions while dismissing not as expected"); + [self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES]; // Now put the app in the background (while the engine is still running) and bring it back to // the foreground. Granted, we're not winning any awards for hyper-realism but at least we're // checking that we aren't observing the UIApplication notifications and double registering // for AppLifecycleState events. - // However the production is temporarily wrong. https://github.com/flutter/flutter/issues/37226. - // It will be fixed in a next PR that removes the wrong asserts. - [lifecycleExpectations - addObject: - [[XCTestExpectation alloc] - initWithDescription:@"Current implementation sends another AppLifecycleState event"]]; + // These operations are synchronous so if they trigger any lifecycle events, they should trigger + // failures in the message handler immediately. [[NSNotificationCenter defaultCenter] postNotificationName:UIApplicationWillResignActiveNotification object:nil]; - [lifecycleExpectations - addObject: - [[XCTestExpectation alloc] - initWithDescription:@"Current implementation sends another AppLifecycleState event"]]; [[NSNotificationCenter defaultCenter] postNotificationName:UIApplicationDidEnterBackgroundNotification object:nil]; - [lifecycleExpectations - addObject: - [[XCTestExpectation alloc] - initWithDescription:@"Current implementation sends another AppLifecycleState event"]]; [[NSNotificationCenter defaultCenter] postNotificationName:UIApplicationWillEnterForegroundNotification object:nil]; - [lifecycleExpectations - addObject: - [[XCTestExpectation alloc] - initWithDescription:@"Current implementation sends another AppLifecycleState event"]]; [[NSNotificationCenter defaultCenter] postNotificationName:UIApplicationDidBecomeActiveNotification object:nil]; // There's no timing latch for our semi-fake background-foreground cycle so launch the // FlutterViewController again to check the complete event list again. - [lifecycleExpectations addObject:[[XCTestExpectation alloc] - initWithDescription:@"A second FlutterViewController goes " - @"through AppLifecycleState.inactive"]]; - [lifecycleExpectations - addObject:[[XCTestExpectation alloc] - initWithDescription: - @"A second FlutterViewController goes through AppLifecycleState.resumed"]]; + + // Expect only lifecycle events from showing the FlutterViewController again, not from any + // backgrounding/foregrounding. + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"showing a FlutterViewController a second time after backgrounding"], + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.resumed" + forStep:@"showing a FlutterViewController a second time after backgrounding"] + ]]; flutterVC = [rootVC showFlutter]; + [self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES]; + + // Dismantle. + [engine.lifecycleChannel setMessageHandler:nil]; + [flutterVC dismissViewControllerAnimated:NO completion:nil]; + [engine setViewController:nil]; +} + +- (void)testVisibleFlutterViewControllerRespondsToApplicationLifecycle { + XCTestExpectation* engineStartedExpectation = [self expectationWithDescription:@"Engine started"]; + + // Let the engine finish booting (at the end of which the channels are properly set-up) before + // moving onto the next step of showing the next view controller. + ScreenBeforeFlutter* rootVC = [[ScreenBeforeFlutter alloc] initWithEngineRunCompletion:^void() { + [engineStartedExpectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:5 handler:nil]; + + UIApplication* application = UIApplication.sharedApplication; + application.delegate.window.rootViewController = rootVC; + FlutterEngine* engine = rootVC.engine; + + NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10]; + + // Expected sequence from showing the FlutterViewController is inactive and resumed. + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"showing a FlutterViewController"], + [[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed" + forStep:@"showing a FlutterViewController"] + ]]; + + FlutterViewController* flutterVC = [rootVC showFlutter]; + [engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) { + if (lifecycleExpectations.count == 0) { + XCTFail(@"Unexpected lifecycle transition: %@", message); + return; + } + XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0]; + if (![[nextExpectation expectedLifecycle] isEqualToString:message]) { + XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle], + message); + return; + } + + [nextExpectation fulfill]; + [lifecycleExpectations removeObjectAtIndex:0]; + }]; + + [self waitForExpectations:lifecycleExpectations timeout:5]; + + // Now put the FlutterViewController into background. + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"putting FlutterViewController to the background"], + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.paused" + forStep:@"putting FlutterViewController to the background"] + ]]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationWillResignActiveNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidEnterBackgroundNotification + object:nil]; + [self waitForExpectations:lifecycleExpectations timeout:5]; + + // Now restore to foreground + [lifecycleExpectations addObjectsFromArray:@[ + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.inactive" + forStep:@"putting FlutterViewController back to foreground"], + [[XCAppLifecycleTestExpectation alloc] + initForLifecycle:@"AppLifecycleState.resumed" + forStep:@"putting FlutterViewController back to foreground"] + ]]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationWillEnterForegroundNotification + object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification + object:nil]; [self waitForExpectations:lifecycleExpectations timeout:5]; - expectedStates = @[ - @"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive", - @"AppLifecycleState.paused", - - // The production code currently misbehaves. https://github.com/flutter/flutter/issues/37226. - // It will be fixed in a next PR that removes the wrong asserts. - @"AppLifecycleState.inactive", @"AppLifecycleState.paused", @"AppLifecycleState.inactive", - @"AppLifecycleState.resumed", - - // We only added 2 from re-launching the FlutterViewController - // and none from the background-foreground cycle. - @"AppLifecycleState.inactive", @"AppLifecycleState.resumed" - ]; - XCTAssertEqualObjects( - lifecycleEvents, expectedStates, - @"AppLifecycleState transitions while presenting a second time not as expected"); // Dismantle. [engine.lifecycleChannel setMessageHandler:nil]; [flutterVC dismissViewControllerAnimated:NO completion:nil]; - flutterVC = nil; [engine setViewController:nil]; - [rootVC dismissViewControllerAnimated:NO completion:nil]; - rootVC = nil; } + @end