diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 74f604d69fa1b..4504ea7f93d54 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -32,6 +32,20 @@ @interface FlutterViewController () @property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI; @end +// The following conditional compilation defines an API 13 concept on earlier API targets so that +// a compiler compiling against API 12 or below does not blow up due to non-existent members. +#if __IPHONE_OS_VERSION_MAX_ALLOWED < 130000 +typedef enum UIAccessibilityContrast : NSInteger { + UIAccessibilityContrastUnspecified = 0, + UIAccessibilityContrastNormal = 1, + UIAccessibilityContrastHigh = 2 +} UIAccessibilityContrast; + +@interface UITraitCollection (MethodsFromNewerSDK) +- (UIAccessibilityContrast)accessibilityContrast; +@end +#endif + @implementation FlutterViewController { std::unique_ptr> _weakFactory; fml::scoped_nsobject _engine; @@ -434,6 +448,9 @@ - (void)viewWillAppear:(BOOL)animated { _engineNeedsLaunch = NO; } + // Send platform settings to Flutter, e.g., platform brightness. + [self onUserSettingsChanged:nil]; + // Only recreate surface on subsequent appearances when viewport metrics are known. // First time surface creation is done on viewDidLayoutSubviews. if (_viewportMetrics.physical_width) { @@ -885,10 +902,17 @@ - (void)onLocaleUpdated:(NSNotification*)notification { #pragma mark - Set user settings +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + [self onUserSettingsChanged:nil]; +} + - (void)onUserSettingsChanged:(NSNotification*)notification { [[_engine.get() settingsChannel] sendMessage:@{ @"textScaleFactor" : @([self textScaleFactor]), @"alwaysUse24HourFormat" : @([self isAlwaysUse24HourFormat]), + @"platformBrightness" : [self brightnessMode], + @"platformContrast" : [self contrastMode] }]; } @@ -962,6 +986,40 @@ - (BOOL)isAlwaysUse24HourFormat { return [dateFormat rangeOfString:@"a"].location == NSNotFound; } +// The brightness mode of the platform, e.g., light or dark, expressed as a string that +// is understood by the Flutter framework. See the settings system channel for more +// information. +- (NSString*)brightnessMode { + if (@available(iOS 13, *)) { + UIUserInterfaceStyle style = self.traitCollection.userInterfaceStyle; + + if (style == UIUserInterfaceStyleDark) { + return @"dark"; + } else { + return @"light"; + } + } else { + return @"light"; + } +} + +// The contrast mode of the platform, e.g., normal or high, expressed as a string that is +// understood by the Flutter framework. See the settings system channel for more +// information. +- (NSString*)contrastMode { + if (@available(iOS 13, *)) { + UIAccessibilityContrast contrast = self.traitCollection.accessibilityContrast; + + if (contrast == UIAccessibilityContrastHigh) { + return @"high"; + } else { + return @"normal"; + } + } else { + return @"normal"; + } +} + #pragma mark - Status Bar touch event handling // Standard iOS status bar height in pixels. diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.m b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.m index 928a551059c74..ca431da779094 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.m @@ -6,9 +6,29 @@ #import #import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h" +#include "FlutterBinaryMessenger.h" + +#if !__has_feature(objc_arc) +#error ARC must be enabled! +#endif + @interface FlutterViewControllerTest : XCTestCase @end +// The following conditional compilation defines an API 13 concept on earlier API targets so that +// a compiler compiling against API 12 or below does not blow up due to non-existent members. +#if __IPHONE_OS_VERSION_MAX_ALLOWED < 130000 +typedef enum UIAccessibilityContrast : NSInteger { + UIAccessibilityContrastUnspecified = 0, + UIAccessibilityContrastNormal = 1, + UIAccessibilityContrastHigh = 2 +} UIAccessibilityContrast; + +@interface UITraitCollection (MethodsFromNewerSDK) +- (UIAccessibilityContrast)accessibilityContrast; +@end +#endif + @implementation FlutterViewControllerTest - (void)testBinaryMessenger { @@ -23,4 +43,205 @@ - (void)testBinaryMessenger { OCMVerify([engine binaryMessenger]); } +#pragma mark - Platform Brightness + +- (void)testItReportsLightPlatformBrightnessByDefault { + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + // Exercise behavior under test. + [vc traitCollectionDidChange:nil]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformBrightness"] isEqualToString:@"light"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [settingsChannel stopMocking]; +} + +- (void)testItReportsPlatformBrightnessWhenViewWillAppear { + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + // Exercise behavior under test. + [vc viewWillAppear:false]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformBrightness"] isEqualToString:@"light"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [settingsChannel stopMocking]; +} + +- (void)testItReportsDarkPlatformBrightnessWhenTraitCollectionRequestsIt { + if (!@available(iOS 13, *)) { + return; + } + + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockTraitCollection = + [self fakeTraitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleDark]; + + // We partially mock the real FlutterViewController to act as the OS and report + // the UITraitCollection of our choice. Mocking the object under test is not + // desirable, but given that the OS does not offer a DI approach to providing + // our own UITraitCollection, this seems to be the least bad option. + id partialMockVC = OCMPartialMock(realVC); + OCMStub([partialMockVC traitCollection]).andReturn(mockTraitCollection); + + // Exercise behavior under test. + [partialMockVC traitCollectionDidChange:nil]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformBrightness"] isEqualToString:@"dark"]; + }]]); + + // Clean up mocks + [partialMockVC stopMocking]; + [engine stopMocking]; + [settingsChannel stopMocking]; + [mockTraitCollection stopMocking]; +} + +// Creates a mocked UITraitCollection with nil values for everything except userInterfaceStyle, +// which is set to the given "style". +- (UITraitCollection*)fakeTraitCollectionWithUserInterfaceStyle:(UIUserInterfaceStyle)style { + id mockTraitCollection = OCMClassMock([UITraitCollection class]); + OCMStub([mockTraitCollection userInterfaceStyle]).andReturn(style); + return mockTraitCollection; +} + +#pragma mark - Platform Contrast + +- (void)testItReportsNormalPlatformContrastByDefault { + if (!@available(iOS 13, *)) { + return; + } + + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + // Exercise behavior under test. + [vc traitCollectionDidChange:nil]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformContrast"] isEqualToString:@"normal"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [settingsChannel stopMocking]; +} + +- (void)testItReportsPlatformContrastWhenViewWillAppear { + if (!@available(iOS 13, *)) { + return; + } + + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* vc = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + + // Exercise behavior under test. + [vc viewWillAppear:false]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformContrast"] isEqualToString:@"normal"]; + }]]); + + // Clean up mocks + [engine stopMocking]; + [settingsChannel stopMocking]; +} + +- (void)testItReportsHighContrastWhenTraitCollectionRequestsIt { + if (!@available(iOS 13, *)) { + return; + } + + // Setup test. + id engine = OCMClassMock([FlutterEngine class]); + + id settingsChannel = OCMClassMock([FlutterBasicMessageChannel class]); + OCMStub([engine settingsChannel]).andReturn(settingsChannel); + + FlutterViewController* realVC = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + id mockTraitCollection = [self fakeTraitCollectionWithContrast:UIAccessibilityContrastHigh]; + + // We partially mock the real FlutterViewController to act as the OS and report + // the UITraitCollection of our choice. Mocking the object under test is not + // desirable, but given that the OS does not offer a DI approach to providing + // our own UITraitCollection, this seems to be the least bad option. + id partialMockVC = OCMPartialMock(realVC); + OCMStub([partialMockVC traitCollection]).andReturn(mockTraitCollection); + + // Exercise behavior under test. + [partialMockVC traitCollectionDidChange:mockTraitCollection]; + + // Verify behavior. + OCMVerify([settingsChannel sendMessage:[OCMArg checkWithBlock:^BOOL(id message) { + return [message[@"platformContrast"] isEqualToString:@"high"]; + }]]); + + // Clean up mocks + [partialMockVC stopMocking]; + [engine stopMocking]; + [settingsChannel stopMocking]; + [mockTraitCollection stopMocking]; +} + +// Creates a mocked UITraitCollection with nil values for everything except accessibilityContrast, +// which is set to the given "contrast". +- (UITraitCollection*)fakeTraitCollectionWithContrast:(UIAccessibilityContrast)contrast { + id mockTraitCollection = OCMClassMock([UITraitCollection class]); + OCMStub([mockTraitCollection accessibilityContrast]).andReturn(contrast); + return mockTraitCollection; +} + @end diff --git a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj index ab7ad7ca36504..6a3885c494830 100644 --- a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj +++ b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0D17A5C022D78FCD0057279F /* FlutterViewControllerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D17A5BF22D78FCD0057279F /* FlutterViewControllerTest.m */; }; + 0D17A5C022D78FCD0057279F /* FlutterViewControllerTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D17A5BF22D78FCD0057279F /* FlutterViewControllerTest.m */; settings = {COMPILER_FLAGS = "-fobjc-arc"; }; }; 0D4C3FB022DF9F5300A67C70 /* FlutterPluginAppLifeCycleDelegateTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D4C3FAF22DF9F5300A67C70 /* FlutterPluginAppLifeCycleDelegateTest.m */; settings = {COMPILER_FLAGS = "-fobjc-arc"; }; }; 0D52D3BD22C566D50011DEBD /* FlutterBinaryMessengerRelayTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0D52D3B622C566D50011DEBD /* FlutterBinaryMessengerRelayTest.mm */; settings = {COMPILER_FLAGS = "-fobjc-arc"; }; }; 0D6AB6B622BB05E100EEE540 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D6AB6B522BB05E100EEE540 /* AppDelegate.m */; };