From 975a67752917e661181fb6a2c8a9ccdd08aa946b Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Thu, 27 Mar 2025 13:51:17 -0500 Subject: [PATCH] Make iOS Flutter framework extension-safe (#165346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our current [adoption/documentation](https://docs.flutter.dev/platform-integration/ios/app-extensions) for iOS Extensions does not currently work because it's disallowed to nest bundles (see https://github.com/flutter/flutter/issues/142531). As of [Xcode 13](https://developer.apple.com/documentation/xcode-release-notes/xcode-13-release-notes/#:~:text=Linking%20Swift%20packages%20from%20application%20extension%20targets%20or%20watchOS%20applications%20no%20longer%20emits%20unresolvable%20warnings%20about%20linking%20to%20libraries%20not%20safe%20for%20use%20in%20application%20extensions%2E), linking to frameworks that contain non-extension-safe code no longer gives warnings (or blocks from App Store it seems). Therefore, it has become a runtime issue to ensure non-extension-safe code is not used. Previously, we were building and shipping 2 separate Flutter.xcframeworks. One that was extension-safe and one that was not. This PR removes the "extension_safe" framework and instead makes the entire framework extension-safe by annotating non-extension-safe code with `NS_EXTENSION_UNAVAILABLE_IOS` and ensuring that code is not used at runtime if the bundle is for an app extension. This PR also disables wide gamut for app extensions to decrease the chances of crashes (see https://github.com/flutter/flutter/issues/165086). Fixes https://github.com/flutter/flutter/issues/142531. --- For reference: App extensions were first evaluated in https://flutter.dev/go/app-extensions. Here is the reasoning why neither method described there is opportune. Option 1 - I did look into splitting the framework into 2 frameworks (one with all extension-safe code and one with the non-extension-safe code). However, the original idea was to use objc Categories/Extensions - this doesn’t quite fit our needs. Categories/Extensions only allow you to add new methods/properties, not override existing ones. We could hypothetically add new methods, but that would require the user to change their code to use the new methods. I also looked into subclasses which does allow overrides, but it would also require the user to change their code to use the new class. We could do method swizzling, but opinion of that on the internet is that it's not very safe. I’m of the opinion that anything that requires the user to change code isn’t super feasible due to plugins. Option 2 - We could still do the 2 frameworks but rename one to `FlutterExtentionSafe`. This works without users needing to change any code (including imports like `@import Flutter` / `#import `). I believe the reason this works is because at compile time, it finds the `Flutter` framework on the framework search path and it imports in the headers. Then at link time, `FlutterExtentionSafe` is explicitly linked so it uses that framework first when checking for symbols and since it finds all the symbols in `FlutterExtentionSafe`, it doesn’t need/try to auto-link the `Flutter` framework (despite `Flutter` being the framework imported). This seems precarious to me since we’re relying on Xcode to not auto-link the `Flutter` framework. If for some reason `Flutter` framework did get auto-linked (such as the user using a symbol that’s not in the `FlutterExtensionSafe` framework but is in the `Flutter` framework - this is unlikely though), we’d get name collision issues ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- engine/src/build/config/ios/BUILD.gn | 6 + .../ci/licenses_golden/licenses_flutter | 6 + engine/src/flutter/common/config.gni | 6 - .../shell/platform/darwin/ios/BUILD.gn | 34 +- .../framework/Source/FlutterAppDelegate.mm | 11 +- .../ios/framework/Source/FlutterEngine.mm | 57 ++- .../ios/framework/Source/FlutterEngineTest.mm | 98 ++++-- .../framework/Source/FlutterPlatformPlugin.mm | 63 ++-- .../Source/FlutterPlatformPluginTest.mm | 133 +++++-- .../FlutterPluginAppLifeCycleDelegate.mm | 25 +- .../FlutterPluginAppLifeCycleDelegateTest.mm | 3 - .../Source/FlutterSharedApplication.h | 29 ++ .../Source/FlutterSharedApplication.mm | 37 ++ .../Source/FlutterSharedApplicationTest.mm | 66 ++++ .../Source/FlutterTextInputPlugin.mm | 15 +- .../ios/framework/Source/FlutterView.mm | 7 + .../framework/Source/FlutterViewController.mm | 143 +++++--- .../Source/FlutterViewControllerTest.mm | 325 +++++++++++++++--- .../Source/FlutterViewController_Internal.h | 2 + .../IosUnitTests.xcodeproj/project.pbxproj | 2 + 20 files changed, 794 insertions(+), 274 deletions(-) create mode 100644 engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h create mode 100644 engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm create mode 100644 engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm diff --git a/engine/src/build/config/ios/BUILD.gn b/engine/src/build/config/ios/BUILD.gn index deff58788e..6f02af6f25 100644 --- a/engine/src/build/config/ios/BUILD.gn +++ b/engine/src/build/config/ios/BUILD.gn @@ -5,3 +5,9 @@ config("sdk") { cflags_cc = [ "-stdlib=libc++" ] } + +config("ios_application_extension") { + cflags_objc = [ "-fapplication-extension" ] + cflags_objcc = [ "-fapplication-extension" ] + ldflags = [ "-fapplication-extension" ] +} diff --git a/engine/src/flutter/ci/licenses_golden/licenses_flutter b/engine/src/flutter/ci/licenses_golden/licenses_flutter index 8fe7bf3855..78919cf0fb 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_flutter +++ b/engine/src/flutter/ci/licenses_golden/licenses_flutter @@ -52788,6 +52788,9 @@ ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterResto ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPluginTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm + ../../../flutter/LICENSE @@ -55789,6 +55792,9 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterRestora FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterRestorationPluginTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSemanticsScrollView.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm +FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.mm FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPluginTest.mm diff --git a/engine/src/flutter/common/config.gni b/engine/src/flutter/common/config.gni index b2ae1118e2..318d305bd3 100644 --- a/engine/src/flutter/common/config.gni +++ b/engine/src/flutter/common/config.gni @@ -23,12 +23,6 @@ declare_args() { # Whether to include backtrace support. enable_backtrace = true - # Whether to include --fapplication-extension when build iOS framework. - # This is currently a test flag and does not work properly. - #TODO(cyanglaz): Remove above comment about test flag when the entire iOS embedder supports app extension - #https://github.com/flutter/flutter/issues/124289 - darwin_extension_safe = false - # Whether binary size optimizations with the assumption that Impeller is the # only supported rendering engine are enabled. # diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index edcdbb71d0..900fc461b9 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -45,14 +45,13 @@ source_set("flutter_framework_source") { cflags_objcc = flutter_cflags_objcc defines = [ "FLUTTER_FRAMEWORK=1" ] - if (darwin_extension_safe) { - defines += [ "APPLICATION_EXTENSION_API_ONLY=1" ] - } public_configs = [ ":ios_gpu_configuration_config", "//flutter:config", ] + configs += [ "//build/config/ios:ios_application_extension" ] + sources = [ "framework/Source/FlutterAppDelegate.mm", "framework/Source/FlutterCallbackCache.mm", @@ -88,6 +87,8 @@ source_set("flutter_framework_source") { "framework/Source/FlutterRestorationPlugin.mm", "framework/Source/FlutterSemanticsScrollView.h", "framework/Source/FlutterSemanticsScrollView.mm", + "framework/Source/FlutterSharedApplication.h", + "framework/Source/FlutterSharedApplication.mm", "framework/Source/FlutterSpellCheckPlugin.h", "framework/Source/FlutterSpellCheckPlugin.mm", "framework/Source/FlutterTextInputDelegate.h", @@ -225,6 +226,7 @@ shared_library("ios_test_flutter") { "framework/Source/FlutterPlatformViewsTest.mm", "framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm", "framework/Source/FlutterRestorationPluginTest.mm", + "framework/Source/FlutterSharedApplicationTest.mm", "framework/Source/FlutterSpellCheckPluginTest.mm", "framework/Source/FlutterTextInputPluginTest.mm", "framework/Source/FlutterTextureRegistryRelayTest.mm", @@ -262,10 +264,6 @@ shared_library("ios_test_flutter") { ":ios_gpu_configuration_config", "//flutter:config", ] - - if (darwin_extension_safe) { - defines = [ "APPLICATION_EXTENSION_API_ONLY=1" ] - } } shared_library("create_flutter_framework_dylib") { @@ -275,15 +273,12 @@ shared_library("create_flutter_framework_dylib") { ldflags = [ "-Wl,-install_name,@rpath/Flutter.framework/Flutter" ] - if (darwin_extension_safe) { - ldflags += [ "-fapplication-extension" ] - } - public = _flutter_framework_headers deps = [ ":flutter_framework_source" ] public_configs = [ "//flutter:config" ] + configs += [ "//build/config/ios:ios_application_extension" ] } copy("copy_dylib") { @@ -375,9 +370,11 @@ copy("copy_license") { shared_library("copy_and_verify_framework_module") { framework_search_path = rebase_path("$root_out_dir") visibility = [ ":*" ] - cflags_objc = [ + cflags_objc = [ "-F$framework_search_path" ] + ldflags = [ "-F$framework_search_path", - "-fapplication-extension", + "-Xlinker", + "-fatal_warnings", ] sources = [ "framework/Source/FlutterUmbrellaImport.m" ] @@ -388,16 +385,7 @@ shared_library("copy_and_verify_framework_module") { ":copy_framework_privacy_manifest", ] - if (darwin_extension_safe) { - ldflags = [ - "-F$framework_search_path", - "-fapplication-extension", - "-Xlinker", - "-fatal_warnings", - ] - deps += [ ":copy_dylib" ] - frameworks = [ "Flutter.framework" ] - } + configs += [ "//build/config/ios:ios_application_extension" ] } group("universal_flutter_framework") { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm index c4ef0790cb..74e6902c18 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm @@ -10,6 +10,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate_Test.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate_internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h" FLUTTER_ASSERT_ARC @@ -154,6 +155,10 @@ static NSString* const kRestorationStateAppModificationKey = @"mod-date"; - (BOOL)handleOpenURL:(NSURL*)url options:(NSDictionary*)options relayToSystemIfUnhandled:(BOOL)throwBack { + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication == nil) { + return NO; + } if (![self isFlutterDeepLinkingEnabled]) { return NO; } @@ -164,9 +169,9 @@ static NSString* const kRestorationStateAppModificationKey = @"mod-date"; completionHandler:^(BOOL success) { if (!success && throwBack) { // throw it back to iOS - [UIApplication.sharedApplication openURL:url - options:@{} - completionHandler:nil]; + [flutterApplication openURL:url + options:@{} + completionHandler:nil]; } }]; } else { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 505a453487..c791cf2861 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -26,6 +26,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterDartVMServicePublisher.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterIndirectScribbleDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSpellCheckPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextureRegistryRelay.h" @@ -227,15 +228,7 @@ static constexpr int kNumProfilerSamplesPerSec = 5; name:UIApplicationDidReceiveMemoryWarningNotification object:nil]; -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 13.0, *)) { - [self setUpSceneLifecycleNotifications:center]; - } else { - [self setUpApplicationLifecycleNotifications:center]; - } -#else - [self setUpApplicationLifecycleNotifications:center]; -#endif + [self setUpLifecycleNotifications:center]; [center addObserver:self selector:@selector(onLocaleUpdated:) @@ -250,18 +243,21 @@ static constexpr int kNumProfilerSamplesPerSec = 5; return (__bridge FlutterEngine*)reinterpret_cast(identifier); } -- (void)setUpSceneLifecycleNotifications:(NSNotificationCenter*)center API_AVAILABLE(ios(13.0)) { - [center addObserver:self - selector:@selector(sceneWillEnterForeground:) - name:UISceneWillEnterForegroundNotification - object:nil]; - [center addObserver:self - selector:@selector(sceneDidEnterBackground:) - name:UISceneDidEnterBackgroundNotification - object:nil]; -} - -- (void)setUpApplicationLifecycleNotifications:(NSNotificationCenter*)center { +- (void)setUpLifecycleNotifications:(NSNotificationCenter*)center { + // If the application is not available, use the scene for lifecycle notifications if available. + if (!FlutterSharedApplication.isAvailable) { + if (@available(iOS 13.0, *)) { + [center addObserver:self + selector:@selector(sceneWillEnterForeground:) + name:UISceneWillEnterForegroundNotification + object:nil]; + [center addObserver:self + selector:@selector(sceneDidEnterBackground:) + name:UISceneDidEnterBackgroundNotification + object:nil]; + return; + } + } [center addObserver:self selector:@selector(applicationWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification @@ -855,19 +851,8 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS _threadHost->io_thread->GetTaskRunner() // io ); -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 13.0, *)) { - _isGpuDisabled = self.viewController.flutterWindowSceneIfViewLoaded.activationState == - UISceneActivationStateBackground; - } else { - // [UIApplication sharedApplication API is not available for app extension. - // We intialize the shell assuming the GPU is required. - _isGpuDisabled = NO; - } -#else - _isGpuDisabled = - [UIApplication sharedApplication].applicationState == UIApplicationStateBackground; -#endif + // Disable GPU if the app or scene is running in the background. + self.isGpuDisabled = self.viewController.stateIsBackground; // Create the shell. This is a blocking operation. std::unique_ptr shell = flutter::Shell::Create( @@ -1342,7 +1327,6 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS #pragma mark - Notifications -#if APPLICATION_EXTENSION_API_ONLY - (void)sceneWillEnterForeground:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { [self flutterWillEnterForeground:notification]; } @@ -1350,7 +1334,7 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS - (void)sceneDidEnterBackground:(NSNotification*)notification API_AVAILABLE(ios(13.0)) { [self flutterDidEnterBackground:notification]; } -#else + - (void)applicationWillEnterForeground:(NSNotification*)notification { [self flutterWillEnterForeground:notification]; } @@ -1358,7 +1342,6 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS - (void)applicationDidEnterBackground:(NSNotification*)notification { [self flutterDidEnterBackground:notification]; } -#endif - (void)flutterWillEnterForeground:(NSNotification*)notification { [self setIsGpuDisabled:NO]; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm index 785b084ec7..3770bf8c6a 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngineTest.mm @@ -15,9 +15,9 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject_Internal.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Test.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" #import "flutter/shell/platform/darwin/ios/platform_view_ios.h" - FLUTTER_ASSERT_ARC @interface FlutterEngineSpy : FlutterEngine @@ -381,7 +381,7 @@ FLUTTER_ASSERT_ARC OCMVerify(times(2), [mockEngine updateDisplays]); } -- (void)testLifeCycleNotificationDidEnterBackground { +- (void)testLifeCycleNotificationDidEnterBackgroundForApplication { FlutterDartProject* project = [[FlutterDartProject alloc] init]; FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; [engine run]; @@ -394,23 +394,49 @@ FLUTTER_ASSERT_ARC object:nil userInfo:nil]; id mockEngine = OCMPartialMock(engine); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify(times(1), [mockEngine sceneDidEnterBackground:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMVerify(times(1), [mockEngine applicationDidEnterBackground:[OCMArg any]]); -#endif XCTAssertTrue(engine.isGpuDisabled); - bool switch_value = false; + BOOL gpuDisabled = NO; [engine shell].GetIsGpuDisabledSyncSwitch()->Execute( - fml::SyncSwitch::Handlers().SetIfTrue([&] { switch_value = true; }).SetIfFalse([&] { - switch_value = false; + fml::SyncSwitch::Handlers().SetIfTrue([&] { gpuDisabled = YES; }).SetIfFalse([&] { + gpuDisabled = NO; })); - XCTAssertTrue(switch_value); + XCTAssertTrue(gpuDisabled); } -- (void)testLifeCycleNotificationWillEnterForeground { +- (void)testLifeCycleNotificationDidEnterBackgroundForScene { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterDartProject* project = [[FlutterDartProject alloc] init]; + FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; + [engine run]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneDidEnterBackgroundNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationDidEnterBackgroundNotification + object:nil + userInfo:nil]; + id mockEngine = OCMPartialMock(engine); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify(times(1), [mockEngine sceneDidEnterBackground:[OCMArg any]]); + XCTAssertTrue(engine.isGpuDisabled); + BOOL gpuDisabled = NO; + [engine shell].GetIsGpuDisabledSyncSwitch()->Execute( + fml::SyncSwitch::Handlers().SetIfTrue([&] { gpuDisabled = YES; }).SetIfFalse([&] { + gpuDisabled = NO; + })); + XCTAssertTrue(gpuDisabled); + [mockBundle stopMocking]; +} + +- (void)testLifeCycleNotificationWillEnterForegroundForApplication { FlutterDartProject* project = [[FlutterDartProject alloc] init]; FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; [engine run]; @@ -423,20 +449,46 @@ FLUTTER_ASSERT_ARC object:nil userInfo:nil]; id mockEngine = OCMPartialMock(engine); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify(times(1), [mockEngine sceneWillEnterForeground:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMVerify(times(1), [mockEngine applicationWillEnterForeground:[OCMArg any]]); -#endif XCTAssertFalse(engine.isGpuDisabled); - bool switch_value = true; + BOOL gpuDisabled = YES; [engine shell].GetIsGpuDisabledSyncSwitch()->Execute( - fml::SyncSwitch::Handlers().SetIfTrue([&] { switch_value = true; }).SetIfFalse([&] { - switch_value = false; + fml::SyncSwitch::Handlers().SetIfTrue([&] { gpuDisabled = YES; }).SetIfFalse([&] { + gpuDisabled = NO; })); - XCTAssertFalse(switch_value); + XCTAssertFalse(gpuDisabled); +} + +- (void)testLifeCycleNotificationWillEnterForegroundForScene { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterDartProject* project = [[FlutterDartProject alloc] init]; + FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"foobar" project:project]; + [engine run]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneWillEnterForegroundNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationWillEnterForegroundNotification + object:nil + userInfo:nil]; + id mockEngine = OCMPartialMock(engine); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify(times(1), [mockEngine sceneWillEnterForeground:[OCMArg any]]); + XCTAssertFalse(engine.isGpuDisabled); + BOOL gpuDisabled = YES; + [engine shell].GetIsGpuDisabledSyncSwitch()->Execute( + fml::SyncSwitch::Handlers().SetIfTrue([&] { gpuDisabled = YES; }).SetIfFalse([&] { + gpuDisabled = NO; + })); + XCTAssertFalse(gpuDisabled); + [mockBundle stopMocking]; } - (void)testSpawnsShareGpuContext { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm index 6a662381fd..f716407e3a 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm @@ -11,6 +11,7 @@ #include "flutter/fml/logging.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h" #import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h" @@ -22,9 +23,7 @@ namespace { constexpr char kTextPlainFormat[] = "text/plain"; const UInt32 kKeyPressClickSoundId = 1306; -#if not APPLICATION_EXTENSION_API_ONLY NSString* const kSearchURLPrefix = @"x-web-search://?"; -#endif } // namespace @@ -45,21 +44,23 @@ const char* const kOverlayStyleUpdateNotificationKey = using namespace flutter; static void SetStatusBarHiddenForSharedApplication(BOOL hidden) { -#if not APPLICATION_EXTENSION_API_ONLY - [UIApplication sharedApplication].statusBarHidden = hidden; -#else - FML_LOG(WARNING) << "Application based status bar styling is not available in app extension."; -#endif + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + flutterApplication.statusBarHidden = hidden; + } else { + FML_LOG(WARNING) << "Application based status bar styling is not available in app extension."; + } } static void SetStatusBarStyleForSharedApplication(UIStatusBarStyle style) { -#if not APPLICATION_EXTENSION_API_ONLY - // Note: -[UIApplication setStatusBarStyle] is deprecated in iOS9 - // in favor of delegating to the view controller. - [[UIApplication sharedApplication] setStatusBarStyle:style]; -#else - FML_LOG(WARNING) << "Application based status bar styling is not available in app extension."; -#endif + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + // Note: -[UIApplication setStatusBarStyle] is deprecated in iOS9 + // in favor of delegating to the view controller. + [flutterApplication setStatusBarStyle:style]; + } else { + FML_LOG(WARNING) << "Application based status bar styling is not available in app extension."; + } } @interface FlutterPlatformPlugin () @@ -220,18 +221,18 @@ static void SetStatusBarStyleForSharedApplication(UIStatusBarStyle style) { } - (void)searchWeb:(NSString*)searchTerm { -#if APPLICATION_EXTENSION_API_ONLY - FML_LOG(WARNING) << "SearchWeb.invoke is not availabe in app extension."; -#else + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication == nil) { + FML_LOG(WARNING) << "SearchWeb.invoke is not availabe in app extension."; + return; + } + NSString* escapedText = [searchTerm stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLHostAllowedCharacterSet]]; NSString* searchURL = [NSString stringWithFormat:@"%@%@", kSearchURLPrefix, escapedText]; - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:searchURL] - options:@{} - completionHandler:nil]; -#endif + [flutterApplication openURL:[NSURL URLWithString:searchURL] options:@{} completionHandler:nil]; } - (void)playSystemSound:(NSString*)soundType { @@ -381,17 +382,19 @@ static void SetStatusBarStyleForSharedApplication(UIStatusBarStyle style) { [navigationController popViewControllerAnimated:isAnimated]; } else { UIViewController* rootViewController = nil; -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 15.0, *)) { - rootViewController = - [engineViewController flutterWindowSceneIfViewLoaded].keyWindow.rootViewController; + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + rootViewController = flutterApplication.keyWindow.rootViewController; } else { - FML_LOG(WARNING) - << "rootViewController is not available in application extension prior to iOS 15.0."; + if (@available(iOS 15.0, *)) { + rootViewController = + [engineViewController flutterWindowSceneIfViewLoaded].keyWindow.rootViewController; + } else { + FML_LOG(WARNING) + << "rootViewController is not available in application extension prior to iOS 15.0."; + } } -#else - rootViewController = [UIApplication sharedApplication].keyWindow.rootViewController; -#endif + if (engineViewController != rootViewController) { [engineViewController dismissViewControllerAnimated:isAnimated completion:nil]; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm index 779a2158c5..d3eec569a9 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm @@ -31,6 +31,7 @@ FLUTTER_ASSERT_ARC @end @implementation FlutterPlatformPluginTest + - (void)testSearchWebInvokedWithEscapedTerm { id mockApplication = OCMClassMock([UIApplication class]); OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); @@ -49,11 +50,9 @@ FLUTTER_ASSERT_ARC FlutterResult result = ^(id result) { OCMVerify([mockPlugin searchWeb:@"Testing Word!"]); -#if not APPLICATION_EXTENSION_API_ONLY OCMVerify([mockApplication openURL:[NSURL URLWithString:@"x-web-search://?Testing%20Word!"] options:@{} completionHandler:nil]); -#endif [invokeExpectation fulfill]; }; @@ -62,10 +61,14 @@ FLUTTER_ASSERT_ARC [mockApplication stopMocking]; } -- (void)testSearchWebInvokedWithNonEscapedTerm { +- (void)testSearchWebSkippedIfAppExtension { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); id mockApplication = OCMClassMock([UIApplication class]); OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); - + OCMReject([mockApplication openURL:OCMOCK_ANY options:OCMOCK_ANY completionHandler:OCMOCK_ANY]); FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil]; [engine runWithEntrypoint:nil]; @@ -80,16 +83,13 @@ FLUTTER_ASSERT_ARC FlutterResult result = ^(id result) { OCMVerify([mockPlugin searchWeb:@"Test"]); -#if not APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockApplication openURL:[NSURL URLWithString:@"x-web-search://?Test"] - options:@{} - completionHandler:nil]); -#endif + [invokeExpectation fulfill]; }; [mockPlugin handleMethodCall:methodCall result:result]; [self waitForExpectationsWithTimeout:1 handler:nil]; + [mockBundle stopMocking]; [mockApplication stopMocking]; } @@ -389,37 +389,83 @@ FLUTTER_ASSERT_ARC FlutterViewController* flutterViewController = [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; - // Update to hidden. + // Update the visibility of the status bar to hidden. FlutterPlatformPlugin* plugin = [engine platformPlugin]; - XCTestExpectation* enableSystemUIOverlaysCalled = + XCTestExpectation* systemOverlaysBottomExpectation = [self expectationWithDescription:@"setEnabledSystemUIOverlays"]; - FlutterResult resultSet = ^(id result) { - [enableSystemUIOverlaysCalled fulfill]; + FlutterResult systemOverlaysBottomResult = ^(id result) { + [systemOverlaysBottomExpectation fulfill]; }; - FlutterMethodCall* methodCallSet = + FlutterMethodCall* setSystemOverlaysBottomCall = [FlutterMethodCall methodCallWithMethodName:@"SystemChrome.setEnabledSystemUIOverlays" arguments:@[ @"SystemUiOverlay.bottom" ]]; - [plugin handleMethodCall:methodCallSet result:resultSet]; + [plugin handleMethodCall:setSystemOverlaysBottomCall result:systemOverlaysBottomResult]; [self waitForExpectationsWithTimeout:1 handler:nil]; -#if not APPLICATION_EXTENSION_API_ONLY OCMVerify([mockApplication setStatusBarHidden:YES]); -#endif - // Update to shown. - XCTestExpectation* enableSystemUIOverlaysCalled2 = + // Update the visibility of the status bar to shown. + XCTestExpectation* systemOverlaysTopExpectation = [self expectationWithDescription:@"setEnabledSystemUIOverlays"]; - FlutterResult resultSet2 = ^(id result) { - [enableSystemUIOverlaysCalled2 fulfill]; + FlutterResult systemOverlaysTopResult = ^(id result) { + [systemOverlaysTopExpectation fulfill]; }; - FlutterMethodCall* methodCallSet2 = + FlutterMethodCall* setSystemOverlaysTopCall = [FlutterMethodCall methodCallWithMethodName:@"SystemChrome.setEnabledSystemUIOverlays" arguments:@[ @"SystemUiOverlay.top" ]]; - [plugin handleMethodCall:methodCallSet2 result:resultSet2]; + [plugin handleMethodCall:setSystemOverlaysTopCall result:systemOverlaysTopResult]; [self waitForExpectationsWithTimeout:1 handler:nil]; -#if not APPLICATION_EXTENSION_API_ONLY OCMVerify([mockApplication setStatusBarHidden:NO]); -#endif + + [flutterViewController deregisterNotifications]; + [mockApplication stopMocking]; + [bundleMock stopMocking]; +} + +- (void)testStatusBarHiddenNotUpdatedInAppExtension { + id bundleMock = OCMPartialMock([NSBundle mainBundle]); + OCMStub([bundleMock objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"]) + .andReturn(@NO); + OCMStub([bundleMock objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + OCMReject([mockApplication setStatusBarHidden:OCMOCK_ANY]); + + // Enabling system UI overlays to update status bar. + FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + + // Update the visibility of the status bar to hidden does not occur with extensions. + FlutterPlatformPlugin* plugin = [engine platformPlugin]; + + XCTestExpectation* systemOverlaysBottomExpectation = + [self expectationWithDescription:@"setEnabledSystemUIOverlays"]; + FlutterResult systemOverlaysBottomResult = ^(id result) { + [systemOverlaysBottomExpectation fulfill]; + }; + FlutterMethodCall* setSystemOverlaysBottomCall = + [FlutterMethodCall methodCallWithMethodName:@"SystemChrome.setEnabledSystemUIOverlays" + arguments:@[ @"SystemUiOverlay.bottom" ]]; + [plugin handleMethodCall:setSystemOverlaysBottomCall result:systemOverlaysBottomResult]; + [self waitForExpectationsWithTimeout:1 handler:nil]; + OCMReject([mockApplication setStatusBarHidden:YES]); + + // Update the visibility of the status bar to shown does not occur with extensions. + XCTestExpectation* systemOverlaysTopExpectation = + [self expectationWithDescription:@"setEnabledSystemUIOverlays"]; + FlutterResult systemOverlaysTopResult = ^(id result) { + [systemOverlaysTopExpectation fulfill]; + }; + FlutterMethodCall* setSystemOverlaysTopCall = + [FlutterMethodCall methodCallWithMethodName:@"SystemChrome.setEnabledSystemUIOverlays" + arguments:@[ @"SystemUiOverlay.top" ]]; + [plugin handleMethodCall:setSystemOverlaysTopCall result:systemOverlaysTopResult]; + [self waitForExpectationsWithTimeout:1 handler:nil]; + OCMReject([mockApplication setStatusBarHidden:NO]); [flutterViewController deregisterNotifications]; [mockApplication stopMocking]; @@ -452,9 +498,42 @@ FLUTTER_ASSERT_ARC [plugin handleMethodCall:methodCallSet result:resultSet]; [self waitForExpectationsWithTimeout:1 handler:nil]; -#if not APPLICATION_EXTENSION_API_ONLY OCMVerify([mockApplication setStatusBarStyle:UIStatusBarStyleLightContent]); -#endif + + [flutterViewController deregisterNotifications]; + [mockApplication stopMocking]; + [bundleMock stopMocking]; +} + +- (void)testStatusBarStyleNotUpdatedInAppExtension { + id bundleMock = OCMPartialMock([NSBundle mainBundle]); + OCMStub([bundleMock objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"]) + .andReturn(@NO); + OCMStub([bundleMock objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + OCMReject([mockApplication setStatusBarHidden:OCMOCK_ANY]); + + FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + XCTAssertFalse(flutterViewController.prefersStatusBarHidden); + + FlutterPlatformPlugin* plugin = [engine platformPlugin]; + + XCTestExpectation* enableSystemUIModeCalled = + [self expectationWithDescription:@"setSystemUIOverlayStyle"]; + FlutterResult resultSet = ^(id result) { + [enableSystemUIModeCalled fulfill]; + }; + FlutterMethodCall* methodCallSet = + [FlutterMethodCall methodCallWithMethodName:@"SystemChrome.setSystemUIOverlayStyle" + arguments:@{@"statusBarBrightness" : @"Brightness.dark"}]; + [plugin handleMethodCall:methodCallSet result:resultSet]; + [self waitForExpectationsWithTimeout:1 handler:nil]; [flutterViewController deregisterNotifications]; [mockApplication stopMocking]; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm index 11451d38e4..7b9dcdf074 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm @@ -8,6 +8,7 @@ #include "flutter/fml/paths.h" #include "flutter/lib/ui/plugins/callback_cache.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterCallbackCache_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" FLUTTER_ASSERT_ARC @@ -45,18 +46,18 @@ static const SEL kSelectorsHandledByPlugins[] = { if (self = [super init]) { std::string cachePath = fml::paths::JoinPaths({getenv("HOME"), kCallbackCacheSubDir}); [FlutterCallbackCache setCachePath:[NSString stringWithUTF8String:cachePath.c_str()]]; -#if not APPLICATION_EXTENSION_API_ONLY - [self addObserverFor:UIApplicationDidEnterBackgroundNotification - selector:@selector(handleDidEnterBackground:)]; - [self addObserverFor:UIApplicationWillEnterForegroundNotification - selector:@selector(handleWillEnterForeground:)]; - [self addObserverFor:UIApplicationWillResignActiveNotification - selector:@selector(handleWillResignActive:)]; - [self addObserverFor:UIApplicationDidBecomeActiveNotification - selector:@selector(handleDidBecomeActive:)]; - [self addObserverFor:UIApplicationWillTerminateNotification - selector:@selector(handleWillTerminate:)]; -#endif + if (FlutterSharedApplication.isAvailable) { + [self addObserverFor:UIApplicationDidEnterBackgroundNotification + selector:@selector(handleDidEnterBackground:)]; + [self addObserverFor:UIApplicationWillEnterForegroundNotification + selector:@selector(handleWillEnterForeground:)]; + [self addObserverFor:UIApplicationWillResignActiveNotification + selector:@selector(handleWillResignActive:)]; + [self addObserverFor:UIApplicationDidBecomeActiveNotification + selector:@selector(handleDidBecomeActive:)]; + [self addObserverFor:UIApplicationWillTerminateNotification + selector:@selector(handleWillTerminate:)]; + } _delegates = [NSPointerArray weakObjectsPointerArray]; _debugBackgroundTask = UIBackgroundTaskInvalid; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm index d96ae4cb90..e82861b24d 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm @@ -26,7 +26,6 @@ FLUTTER_ASSERT_ARC XCTAssertNotNil(delegate); } -#if not APPLICATION_EXTENSION_API_ONLY - (void)testDidEnterBackground { XCTNSNotificationExpectation* expectation = [[XCTNSNotificationExpectation alloc] initWithName:UIApplicationDidEnterBackgroundNotification]; @@ -110,6 +109,4 @@ FLUTTER_ASSERT_ARC XCTAssertNil(weakDelegate); } -#endif - @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h new file mode 100644 index 0000000000..f7882505da --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h @@ -0,0 +1,29 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHAREDAPPLICATION_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHAREDAPPLICATION_H_ + +#import + +@interface FlutterSharedApplication : NSObject + +/** + * Returns YES if the main bundle is an iOS App Extension. + */ +@property(class, nonatomic, readonly) BOOL isAppExtension; + +/** + * Returns YES if the UIApplication is available. UIApplication is not available for App Extensions. + */ +@property(class, nonatomic, readonly) BOOL isAvailable; + +/** + * Returns the `UIApplication.sharedApplication` is available. Otherwise returns nil. + */ +@property(class, nonatomic, readonly) UIApplication* application; + +@end + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERSHAREDAPPLICATION_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm new file mode 100644 index 0000000000..4c941b3607 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.mm @@ -0,0 +1,37 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" + +#include "flutter/fml/logging.h" +#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" + +FLUTTER_ASSERT_ARC + +@implementation FlutterSharedApplication + ++ (BOOL)isAppExtension { + NSDictionary* nsExtension = [NSBundle.mainBundle objectForInfoDictionaryKey:@"NSExtension"]; + return [nsExtension isKindOfClass:[NSDictionary class]]; +} + ++ (BOOL)isAvailable { + // If the bundle is an App Extension, the application is not available. + // Therefore access to `UIApplication.sharedApplication` is not allowed. + return !FlutterSharedApplication.isAppExtension; +} + ++ (UIApplication*)application { + if (FlutterSharedApplication.isAvailable) { + return FlutterSharedApplication.sharedApplication; + } + return nil; +} + ++ (UIApplication*) + sharedApplication NS_EXTENSION_UNAVAILABLE_IOS("Accesses unavailable sharedApplication.") { + return UIApplication.sharedApplication; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm new file mode 100644 index 0000000000..2f4553b5c9 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplicationTest.mm @@ -0,0 +1,66 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import + +#include "flutter/common/constants.h" +#include "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" + +FLUTTER_ASSERT_ARC + +@interface FlutterSharedApplicationTest : XCTestCase +@end + +@implementation FlutterSharedApplicationTest + +- (void)testWhenNSExtensionInBundle { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + XCTAssertTrue(FlutterSharedApplication.isAppExtension); + XCTAssertFalse(FlutterSharedApplication.isAvailable); + [mockBundle stopMocking]; +} + +- (void)testWhenNSExtensionEmptyInBundle { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]) + .andReturn([[NSDictionary alloc] init]); + XCTAssertTrue(FlutterSharedApplication.isAppExtension); + XCTAssertFalse(FlutterSharedApplication.isAvailable); + [mockBundle stopMocking]; +} + +- (void)testWhenNSExtensionNotInBundle { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + XCTAssertFalse(FlutterSharedApplication.isAppExtension); + XCTAssertTrue(FlutterSharedApplication.isAvailable); + [mockBundle stopMocking]; +} + +- (void)testSharedApplicationNotCalledIfIsAvailableFalse { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + id mockApplication = OCMClassMock([UIApplication class]); + XCTAssertFalse(FlutterSharedApplication.isAvailable); + OCMReject([mockApplication sharedApplication]); + XCTAssertNil(FlutterSharedApplication.application); + [mockBundle stopMocking]; +} + +- (void)testSharedApplicationCalledIfIsAvailableTrue { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + id mockApplication = OCMClassMock([UIApplication class]); + XCTAssertTrue(FlutterSharedApplication.isAvailable); + XCTAssertNotNil(FlutterSharedApplication.application); + OCMVerify([mockApplication sharedApplication]); + [mockBundle stopMocking]; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 2b53e96b59..827cd377c1 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -12,6 +12,7 @@ #include "flutter/fml/logging.h" #include "flutter/fml/platform/darwin/string_range_sanitization.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" FLUTTER_ASSERT_ARC @@ -2657,7 +2658,12 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, - (void)hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate { [UIView setAnimationsEnabled:NO]; - _cachedFirstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder; + UIApplication* flutterApplication = FlutterSharedApplication.application; + _cachedFirstResponder = + flutterApplication + ? flutterApplication.keyWindow.flutterFirstResponder + : self.viewController.flutterWindowSceneIfViewLoaded.keyWindow.flutterFirstResponder; + _activeView.preventCursorDismissWhenResignFirstResponder = YES; [_cachedFirstResponder resignFirstResponder]; _activeView.preventCursorDismissWhenResignFirstResponder = NO; @@ -2674,8 +2680,11 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, _keyboardView = keyboardSnap; [_keyboardViewContainer addSubview:_keyboardView]; if (_keyboardViewContainer.superview == nil) { - [UIApplication.sharedApplication.delegate.window.rootViewController.view - addSubview:_keyboardViewContainer]; + UIApplication* flutterApplication = FlutterSharedApplication.application; + UIView* rootView = flutterApplication + ? flutterApplication.delegate.window.rootViewController.view + : self.viewController.viewIfLoaded.window.rootViewController.view; + [rootView addSubview:_keyboardViewContainer]; } _keyboardViewContainer.layer.zPosition = NSIntegerMax; _keyboardViewContainer.frame = _keyboardRect; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm index dd37e8a553..d1f813b13a 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterView.mm @@ -5,6 +5,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h" #include "flutter/fml/platform/darwin/cf_utils.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h" FLUTTER_ASSERT_ARC @@ -53,6 +54,12 @@ FLUTTER_ASSERT_ARC - (BOOL)isWideGamutSupported { FML_DCHECK(self.screen); + // Wide Gamut is not supported for iOS Extensions due to memory limitations + // (see https://github.com/flutter/flutter/issues/165086). + if (FlutterSharedApplication.isAppExtension) { + return NO; + } + // This predicates the decision on the capabilities of the iOS device's // display. This means external displays will not support wide gamut if the // device's display doesn't support it. It practice that should be never. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index a38b268a65..2556a5b063 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -23,6 +23,7 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardManager.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterSharedApplication.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h" @@ -319,15 +320,15 @@ typedef struct MouseState { name:@(flutter::kOverlayStyleUpdateNotificationName) object:nil]; -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 13.0, *)) { - [self setUpSceneLifecycleNotifications:center]; - } else { + if (FlutterSharedApplication.isAvailable) { [self setUpApplicationLifecycleNotifications:center]; + } else { + if (@available(iOS 13.0, *)) { + [self setUpSceneLifecycleNotifications:center]; + } else { + [self setUpApplicationLifecycleNotifications:center]; + } } -#else - [self setUpApplicationLifecycleNotifications:center]; -#endif [center addObserver:self selector:@selector(keyboardWillChangeFrame:) @@ -722,6 +723,58 @@ static void SendFakeTouchEvent(UIScreen* screen, _flutterViewRenderedCallback = callback; } +- (UISceneActivationState)activationState { + return self.flutterWindowSceneIfViewLoaded.activationState; +} + +- (BOOL)stateIsActive { + BOOL isActive = YES; + + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + isActive = [self isApplicationStateMatching:UIApplicationStateActive + withApplication:flutterApplication]; + } else if (@available(iOS 13.0, *)) { + isActive = [self isSceneStateMatching:UISceneActivationStateForegroundActive]; + } + return isActive; +} + +- (BOOL)stateIsBackground { + // [UIApplication sharedApplication API is not available for app extension. + // Assume the app is not in the background if we're unable to get the state. + BOOL isBackground = NO; + + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + isBackground = [self isApplicationStateMatching:UIApplicationStateBackground + withApplication:flutterApplication]; + } else if (@available(iOS 13.0, *)) { + isBackground = [self isSceneStateMatching:UISceneActivationStateBackground]; + } + return isBackground; +} + +- (BOOL)isApplicationStateMatching:(UIApplicationState)match + withApplication:(UIApplication*)application { + switch (application.applicationState) { + case UIApplicationStateActive: + case UIApplicationStateInactive: + case UIApplicationStateBackground: + return application.applicationState == match; + } +} + +- (BOOL)isSceneStateMatching:(UISceneActivationState)match API_AVAILABLE(ios(13.0)) { + switch (self.activationState) { + case UISceneActivationStateForegroundActive: + case UISceneActivationStateUnattached: + case UISceneActivationStateForegroundInactive: + case UISceneActivationStateBackground: + return self.activationState == match; + } +} + #pragma mark - Surface creation and teardown updates - (void)surfaceUpdated:(BOOL)appeared { @@ -848,16 +901,8 @@ static void SendFakeTouchEvent(UIScreen* screen, if (self.engine.viewController == self) { [self onUserSettingsChanged:nil]; [self onAccessibilityStatusChanged:nil]; - BOOL stateIsActive = YES; -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 13.0, *)) { - stateIsActive = self.flutterWindowSceneIfViewLoaded.activationState == - UISceneActivationStateForegroundActive; - } -#else - stateIsActive = UIApplication.sharedApplication.applicationState == UIApplicationStateActive; -#endif - if (stateIsActive) { + + if (self.stateIsActive) { [self.engine.lifecycleChannel sendMessage:@"AppLifecycleState.resumed"]; } } @@ -1385,20 +1430,9 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) // There is no guarantee that UIKit will layout subviews when the application/scene is active. // Creating the surface when inactive will cause GPU accesses from the background. Only wait for // the first frame to render when the application/scene is actually active. - bool applicationOrSceneIsActive = YES; -#if APPLICATION_EXTENSION_API_ONLY - if (@available(iOS 13.0, *)) { - applicationOrSceneIsActive = self.flutterWindowSceneIfViewLoaded.activationState == - UISceneActivationStateForegroundActive; - } -#else - applicationOrSceneIsActive = - [UIApplication sharedApplication].applicationState == UIApplicationStateActive; -#endif - // This must run after updateViewportMetrics so that the surface creation tasks are queued after // the viewport metrics update tasks. - if (firstViewBoundsUpdate && applicationOrSceneIsActive && self.engine) { + if (firstViewBoundsUpdate && self.stateIsActive && self.engine) { [self surfaceUpdated:YES]; #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG NSTimeInterval timeout = 0.2; @@ -2003,18 +2037,17 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) self.orientationPreferences = new_preferences; if (@available(iOS 16.0, *)) { - NSSet* scenes = -#if APPLICATION_EXTENSION_API_ONLY - self.flutterWindowSceneIfViewLoaded - ? [NSSet setWithObject:self.flutterWindowSceneIfViewLoaded] - : [NSSet set]; -#else - [UIApplication.sharedApplication.connectedScenes - filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( - id scene, NSDictionary* bindings) { - return [scene isKindOfClass:[UIWindowScene class]]; - }]]; -#endif + UIApplication* flutterApplication = FlutterSharedApplication.application; + NSSet* scenes = [NSSet set]; + if (flutterApplication) { + scenes = [flutterApplication.connectedScenes + filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL( + id scene, NSDictionary* bindings) { + return [scene isKindOfClass:[UIWindowScene class]]; + }]]; + } else if (self.flutterWindowSceneIfViewLoaded) { + scenes = [NSSet setWithObject:self.flutterWindowSceneIfViewLoaded]; + } [self requestGeometryUpdateForWindowScenes:scenes]; } else { UIInterfaceOrientationMask currentInterfaceOrientation = 0; @@ -2027,13 +2060,14 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } currentInterfaceOrientation = 1 << windowScene.interfaceOrientation; } else { -#if APPLICATION_EXTENSION_API_ONLY - FML_LOG(ERROR) << "Application based status bar orentiation update is not supported in " - "app extension. Orientation: " - << currentInterfaceOrientation; -#else - currentInterfaceOrientation = 1 << [[UIApplication sharedApplication] statusBarOrientation]; -#endif + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication) { + currentInterfaceOrientation = 1 << [flutterApplication statusBarOrientation]; + } else { + FML_LOG(ERROR) << "Application based status bar orentiation update is not supported in " + "app extension. Orientation: " + << currentInterfaceOrientation; + } } if (!(self.orientationPreferences & currentInterfaceOrientation)) { [UIViewController attemptRotationToDeviceOrientation]; @@ -2167,11 +2201,13 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } - (CGFloat)textScaleFactor { -#if APPLICATION_EXTENSION_API_ONLY - FML_LOG(WARNING) << "Dynamic content size update is not supported in app extension."; - return 1.0; -#else - UIContentSizeCategory category = [UIApplication sharedApplication].preferredContentSizeCategory; + UIApplication* flutterApplication = FlutterSharedApplication.application; + if (flutterApplication == nil) { + FML_LOG(WARNING) << "Dynamic content size update is not supported in app extension."; + return 1.0; + } + + UIContentSizeCategory category = flutterApplication.preferredContentSizeCategory; // The delta is computed by approximating Apple's typography guidelines: // https://developer.apple.com/ios/human-interface-guidelines/visual-design/typography/ // @@ -2221,7 +2257,6 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } else { return 1.0; } -#endif } - (BOOL)supportsShowingSystemContextMenu { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 2803040861..654716535f 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -1650,12 +1650,12 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; - [[NSNotificationCenter defaultCenter] addObserverForName:FlutterViewControllerWillDealloc - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification* _Nonnull note) { - [expectation fulfill]; - }]; + [NSNotificationCenter.defaultCenter addObserverForName:FlutterViewControllerWillDealloc + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* _Nonnull note) { + [expectation fulfill]; + }]; XCTAssertNotNil(realVC); realVC = nil; } @@ -1696,8 +1696,8 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; nibName:nil bundle:nil]; XCTAssertFalse(realVC.prefersHomeIndicatorAutoHidden, @""); - [[NSNotificationCenter defaultCenter] postNotificationName:FlutterViewControllerHideHomeIndicator - object:nil]; + [NSNotificationCenter.defaultCenter postNotificationName:FlutterViewControllerHideHomeIndicator + object:nil]; XCTAssertTrue(realVC.prefersHomeIndicatorAutoHidden, @""); engine.viewController = nil; } @@ -1906,7 +1906,7 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; [flutterViewController setSplashScreenView:nil]; } -- (void)testLifeCycleNotificationBecameActive { +- (void)testLifeCycleNotificationApplicationBecameActive { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; FlutterViewController* flutterViewController = @@ -1922,15 +1922,10 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; object:nil userInfo:nil]; id mockVC = OCMPartialMock(flutterViewController); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockVC sceneBecameActive:[OCMArg any]]); - OCMReject([mockVC applicationBecameActive:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMReject([mockVC sceneBecameActive:[OCMArg any]]); OCMVerify([mockVC applicationBecameActive:[OCMArg any]]); -#endif XCTAssertFalse(flutterViewController.isKeyboardInOrTransitioningFromBackground); OCMVerify([mockVC surfaceUpdated:YES]); XCTestExpectation* timeoutApplicationLifeCycle = @@ -1944,7 +1939,45 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; [self waitForExpectationsWithTimeout:5.0 handler:nil]; } -- (void)testLifeCycleNotificationWillResignActive { +- (void)testLifeCycleNotificationSceneBecameActive { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + UIWindow* window = [[UIWindow alloc] init]; + [window addSubview:flutterViewController.view]; + flutterViewController.view.bounds = CGRectMake(0, 0, 100, 100); + [flutterViewController viewDidLayoutSubviews]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneDidActivateNotification object:nil userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationDidBecomeActiveNotification + object:nil + userInfo:nil]; + id mockVC = OCMPartialMock(flutterViewController); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify([mockVC sceneBecameActive:[OCMArg any]]); + OCMReject([mockVC applicationBecameActive:[OCMArg any]]); + XCTAssertFalse(flutterViewController.isKeyboardInOrTransitioningFromBackground); + OCMVerify([mockVC surfaceUpdated:YES]); + XCTestExpectation* timeoutApplicationLifeCycle = + [self expectationWithDescription:@"timeoutApplicationLifeCycle"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [timeoutApplicationLifeCycle fulfill]; + OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.resumed"]); + [flutterViewController deregisterNotifications]; + }); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; + [mockBundle stopMocking]; +} + +- (void)testLifeCycleNotificationApplicationWillResignActive { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; FlutterViewController* flutterViewController = @@ -1958,20 +1991,42 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; object:nil userInfo:nil]; id mockVC = OCMPartialMock(flutterViewController); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockVC sceneWillResignActive:[OCMArg any]]); - OCMReject([mockVC applicationWillResignActive:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMReject([mockVC sceneWillResignActive:[OCMArg any]]); OCMVerify([mockVC applicationWillResignActive:[OCMArg any]]); -#endif OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.inactive"]); [flutterViewController deregisterNotifications]; } -- (void)testLifeCycleNotificationWillTerminate { +- (void)testLifeCycleNotificationSceneWillResignActive { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneWillDeactivateNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationWillResignActiveNotification + object:nil + userInfo:nil]; + id mockVC = OCMPartialMock(flutterViewController); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify([mockVC sceneWillResignActive:[OCMArg any]]); + OCMReject([mockVC applicationWillResignActive:[OCMArg any]]); + OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.inactive"]); + [flutterViewController deregisterNotifications]; + [mockBundle stopMocking]; +} + +- (void)testLifeCycleNotificationApplicationWillTerminate { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; FlutterViewController* flutterViewController = @@ -1987,21 +2042,46 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; id mockVC = OCMPartialMock(flutterViewController); id mockEngine = OCMPartialMock(engine); OCMStub([mockVC engine]).andReturn(mockEngine); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockVC sceneWillDisconnect:[OCMArg any]]); - OCMReject([mockVC applicationWillTerminate:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMReject([mockVC sceneWillDisconnect:[OCMArg any]]); OCMVerify([mockVC applicationWillTerminate:[OCMArg any]]); -#endif OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.detached"]); OCMVerify([mockEngine destroyContext]); [flutterViewController deregisterNotifications]; } -- (void)testLifeCycleNotificationDidEnterBackground { +- (void)testLifeCycleNotificationSceneWillTerminate { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneDidDisconnectNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationWillTerminateNotification + object:nil + userInfo:nil]; + id mockVC = OCMPartialMock(flutterViewController); + id mockEngine = OCMPartialMock(engine); + OCMStub([mockVC engine]).andReturn(mockEngine); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify([mockVC sceneWillDisconnect:[OCMArg any]]); + OCMReject([mockVC applicationWillTerminate:[OCMArg any]]); + OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.detached"]); + OCMVerify([mockEngine destroyContext]); + [flutterViewController deregisterNotifications]; + [mockBundle stopMocking]; +} + +- (void)testLifeCycleNotificationApplicationDidEnterBackground { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; FlutterViewController* flutterViewController = @@ -2015,22 +2095,46 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; object:nil userInfo:nil]; id mockVC = OCMPartialMock(flutterViewController); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockVC sceneDidEnterBackground:[OCMArg any]]); - OCMReject([mockVC applicationDidEnterBackground:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMReject([mockVC sceneDidEnterBackground:[OCMArg any]]); OCMVerify([mockVC applicationDidEnterBackground:[OCMArg any]]); -#endif XCTAssertTrue(flutterViewController.isKeyboardInOrTransitioningFromBackground); OCMVerify([mockVC surfaceUpdated:NO]); OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.paused"]); [flutterViewController deregisterNotifications]; } -- (void)testLifeCycleNotificationWillEnterForeground { +- (void)testLifeCycleNotificationSceneDidEnterBackground { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneDidEnterBackgroundNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationDidEnterBackgroundNotification + object:nil + userInfo:nil]; + id mockVC = OCMPartialMock(flutterViewController); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify([mockVC sceneDidEnterBackground:[OCMArg any]]); + OCMReject([mockVC applicationDidEnterBackground:[OCMArg any]]); + XCTAssertTrue(flutterViewController.isKeyboardInOrTransitioningFromBackground); + OCMVerify([mockVC surfaceUpdated:NO]); + OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.paused"]); + [flutterViewController deregisterNotifications]; + [mockBundle stopMocking]; +} + +- (void)testLifeCycleNotificationApplicationWillEnterForeground { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; FlutterViewController* flutterViewController = @@ -2044,19 +2148,41 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; object:nil userInfo:nil]; id mockVC = OCMPartialMock(flutterViewController); - [[NSNotificationCenter defaultCenter] postNotification:sceneNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationNotification]; -#if APPLICATION_EXTENSION_API_ONLY - OCMVerify([mockVC sceneWillEnterForeground:[OCMArg any]]); - OCMReject([mockVC applicationWillEnterForeground:[OCMArg any]]); -#else + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; OCMReject([mockVC sceneWillEnterForeground:[OCMArg any]]); OCMVerify([mockVC applicationWillEnterForeground:[OCMArg any]]); -#endif OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.inactive"]); [flutterViewController deregisterNotifications]; } +- (void)testLifeCycleNotificationSceneWillEnterForeground { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* flutterViewController = + [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + NSNotification* sceneNotification = + [NSNotification notificationWithName:UISceneWillEnterForegroundNotification + object:nil + userInfo:nil]; + NSNotification* applicationNotification = + [NSNotification notificationWithName:UIApplicationWillEnterForegroundNotification + object:nil + userInfo:nil]; + id mockVC = OCMPartialMock(flutterViewController); + [NSNotificationCenter.defaultCenter postNotification:sceneNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationNotification]; + OCMVerify([mockVC sceneWillEnterForeground:[OCMArg any]]); + OCMReject([mockVC applicationWillEnterForeground:[OCMArg any]]); + OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.inactive"]); + [flutterViewController deregisterNotifications]; + [mockBundle stopMocking]; +} + - (void)testLifeCycleNotificationCancelledInvalidResumed { FlutterEngine* engine = [[FlutterEngine alloc] init]; [engine runWithEntrypoint:nil]; @@ -2071,12 +2197,9 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; object:nil userInfo:nil]; id mockVC = OCMPartialMock(flutterViewController); - [[NSNotificationCenter defaultCenter] postNotification:applicationDidBecomeActiveNotification]; - [[NSNotificationCenter defaultCenter] postNotification:applicationWillResignActiveNotification]; -#if APPLICATION_EXTENSION_API_ONLY -#else + [NSNotificationCenter.defaultCenter postNotification:applicationDidBecomeActiveNotification]; + [NSNotificationCenter.defaultCenter postNotification:applicationWillResignActiveNotification]; OCMVerify([mockVC goToApplicationLifecycle:@"AppLifecycleState.inactive"]); -#endif XCTestExpectation* timeoutApplicationLifeCycle = [self expectationWithDescription:@"timeoutApplicationLifeCycle"]; @@ -2259,4 +2382,100 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; } } +- (void)testStateIsActiveAndBackgroundWhenApplicationStateIsActive { + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateActive); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + XCTAssertTrue(viewController.stateIsActive); + XCTAssertFalse(viewController.stateIsBackground); +} + +- (void)testStateIsActiveAndBackgroundWhenApplicationStateIsBackground { + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateBackground); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + XCTAssertFalse(viewController.stateIsActive); + XCTAssertTrue(viewController.stateIsBackground); +} + +- (void)testStateIsActiveAndBackgroundWhenApplicationStateIsInactive { + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateInactive); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + XCTAssertFalse(viewController.stateIsActive); + XCTAssertFalse(viewController.stateIsBackground); +} + +- (void)testStateIsActiveAndBackgroundWhenSceneStateIsActive { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockVC = OCMPartialMock(viewController); + OCMStub([mockVC activationState]).andReturn(UISceneActivationStateForegroundActive); + XCTAssertTrue(viewController.stateIsActive); + XCTAssertFalse(viewController.stateIsBackground); + + [mockBundle stopMocking]; + [mockVC stopMocking]; +} + +- (void)testStateIsActiveAndBackgroundWhenSceneStateIsBackground { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockVC = OCMPartialMock(viewController); + OCMStub([mockVC activationState]).andReturn(UISceneActivationStateBackground); + XCTAssertFalse(viewController.stateIsActive); + XCTAssertTrue(viewController.stateIsBackground); + + [mockBundle stopMocking]; + [mockVC stopMocking]; +} + +- (void)testStateIsActiveAndBackgroundWhenSceneStateIsInactive { + id mockBundle = OCMPartialMock([NSBundle mainBundle]); + OCMStub([mockBundle objectForInfoDictionaryKey:@"NSExtension"]).andReturn(@{ + @"NSExtensionPointIdentifier" : @"com.apple.share-services" + }); + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockVC = OCMPartialMock(viewController); + OCMStub([mockVC activationState]).andReturn(UISceneActivationStateForegroundInactive); + XCTAssertFalse(viewController.stateIsActive); + XCTAssertFalse(viewController.stateIsBackground); + + [mockBundle stopMocking]; + [mockVC stopMocking]; +} + @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h index 3645d2c376..f62eec2bb2 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h @@ -71,6 +71,8 @@ typedef void (^FlutterKeyboardAnimationCallback)(fml::TimePoint); - (int32_t)accessibilityFlags; - (BOOL)supportsShowingSystemContextMenu; +- (BOOL)stateIsActive; +- (BOOL)stateIsBackground; @end #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERVIEWCONTROLLER_INTERNAL_H_ diff --git a/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj b/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj index f0211a07c6..c75a1e3309 100644 --- a/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj +++ b/engine/src/flutter/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ 3DD7D38C27D2B81000DA365C /* FlutterUndoManagerPluginTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterUndoManagerPluginTest.mm; sourceTree = ""; }; 689EC1E2281B30D3008FEB58 /* FlutterSpellCheckPluginTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterSpellCheckPluginTest.mm; sourceTree = ""; }; 68B6091227F62F990036AC78 /* VsyncWaiterIosTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = VsyncWaiterIosTest.mm; sourceTree = ""; }; + 78E4ED342D88A77C00FD954E /* FlutterSharedApplicationTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterSharedApplicationTest.mm; sourceTree = ""; }; D2D361A52B234EAC0018964E /* FlutterMetalLayerTest.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterMetalLayerTest.mm; sourceTree = ""; }; F7521D7226BB671E005F15C5 /* libios_test_flutter.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libios_test_flutter.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libios_test_flutter.dylib"; sourceTree = ""; }; F7521D7526BB673E005F15C5 /* libocmock_shared.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libocmock_shared.dylib; path = "../../../../out/$(FLUTTER_ENGINE)/libocmock_shared.dylib"; sourceTree = ""; }; @@ -100,6 +101,7 @@ 0AC232E924BA71D300A85907 /* Source */ = { isa = PBXGroup; children = ( + 78E4ED342D88A77C00FD954E /* FlutterSharedApplicationTest.mm */, F76A3A892BE48F2F00A654F1 /* FlutterPlatformViewsTest.mm */, 689EC1E2281B30D3008FEB58 /* FlutterSpellCheckPluginTest.mm */, 68B6091227F62F990036AC78 /* VsyncWaiterIosTest.mm */,