diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index e4fc4e4a352f9..7b7995e713ef8 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -7,6 +7,8 @@ #import +#include "unicode/uchar.h" + #import "flutter/fml/memory/weak_ptr.h" #import "flutter/shell/platform/common/text_editing_delta.h" #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h" diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 0e2c656a32fe5..42537f3b34e73 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -71,6 +71,19 @@ #pragma mark - Static Functions +// Determine if the character at `range` of `text` is an emoji. +static BOOL IsEmoji(NSString* text, NSRange charRange) { + UChar32 codePoint; + BOOL gotCodePoint = [text getBytes:&codePoint + maxLength:sizeof(codePoint) + usedLength:NULL + encoding:NSUTF32StringEncoding + options:kNilOptions + range:charRange + remainingRange:NULL]; + return gotCodePoint && u_hasBinaryProperty(codePoint, UCHAR_EMOJI); +} + // "TextInputType.none" is a made-up input type that's typically // used when there's an in-app virtual keyboard. If // "TextInputType.none" is specified, disable the system @@ -713,6 +726,10 @@ @interface FlutterTextInputView () @property(nonatomic, assign) CGRect markedRect; @property(nonatomic) BOOL isVisibleToAutofill; @property(nonatomic, assign) BOOL accessibilityEnabled; +// The composed character that is temporarily removed by the keyboard API. +// This is cleared at the start of each keyboard interaction. (Enter a character, delete a character +// etc) +@property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; - (void)setEditableTransform:(NSArray*)matrix; @end @@ -899,6 +916,8 @@ - (void)dealloc { [_markedTextStyle release]; [_textContentType release]; [_textInteraction release]; + [_temporarilyDeletedComposedCharacter release]; + _temporarilyDeletedComposedCharacter = nil; [super dealloc]; } @@ -1243,6 +1262,10 @@ - (void)replaceRange:(UITextRange*)range withText:(NSString*)text { } - (BOOL)shouldChangeTextInRange:(UITextRange*)range replacementText:(NSString*)text { + // `temporarilyDeletedComposedCharacter` should only be used during a single text change session. + // So it needs to be cleared at the start of each text editting session. + self.temporarilyDeletedComposedCharacter = nil; + if (self.returnKeyType == UIReturnKeyDefault && [text isEqualToString:@"\n"]) { [self.textInputDelegate flutterTextInputView:self performAction:FlutterTextInputActionNewline @@ -1830,6 +1853,15 @@ - (BOOL)hasText { } - (void)insertText:(NSString*)text { + if (self.temporarilyDeletedComposedCharacter.length > 0 && text.length == 1 && !text.UTF8String && + [text characterAtIndex:0] == [self.temporarilyDeletedComposedCharacter characterAtIndex:0]) { + // Workaround for https://github.com/flutter/flutter/issues/111494 + // TODO(cyanglaz): revert this workaround if when flutter supports a minimum iOS version which + // this bug is fixed by Apple. + text = self.temporarilyDeletedComposedCharacter; + self.temporarilyDeletedComposedCharacter = nil; + } + NSMutableArray* copiedRects = [[NSMutableArray alloc] initWithCapacity:[_selectionRects count]]; NSAssert([_selectedTextRange.start isKindOfClass:[FlutterTextPosition class]], @@ -1896,12 +1928,22 @@ - (void)deleteBackward { NSRange oldRange = ((FlutterTextRange*)oldSelectedRange).range; if (oldRange.location > 0) { NSRange newRange = NSMakeRange(oldRange.location - 1, 1); + _selectedTextRange = [[FlutterTextRange rangeWithNSRange:newRange] copy]; [oldSelectedRange release]; } } if (!_selectedTextRange.isEmpty) { + // Cache the last deleted emoji to use for an iOS bug where the next + // insertion corrupts the emoji characters. + // See: https://github.com/flutter/flutter/issues/111494#issuecomment-1248441346 + if (IsEmoji(self.text, _selectedTextRange.range)) { + NSString* deletedText = [self.text substringWithRange:_selectedTextRange.range]; + NSRange deleteFirstCharacterRange = fml::RangeForCharacterAtIndex(deletedText, 0); + self.temporarilyDeletedComposedCharacter = + [deletedText substringWithRange:deleteFirstCharacterRange]; + } [self replaceRange:_selectedTextRange withText:@""]; } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index bd656cb6cc3aa..50ac6d2b3c34b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -406,6 +406,65 @@ - (void)testStandardEditActions { XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa"); } +// This tests the workaround to fix an iOS 16 bug +// See: https://github.com/flutter/flutter/issues/111494 +- (void)testSystemOnlyAddingPartialComposedCharacter { + NSDictionary* config = self.mutableTemplateCopy; + [self setClientId:123 configuration:config]; + NSArray* inputFields = self.installedInputViews; + FlutterTextInputView* inputView = inputFields[0]; + + [inputView insertText:@"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"]; + [inputView deleteBackward]; + + // Insert the first unichar in the emoji. + [inputView insertText:[@"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" substringWithRange:NSMakeRange(0, 1)]]; + [inputView insertText:@"์•„"]; + + XCTAssertEqualObjects(inputView.text, @"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ์•„"); + + // Deleting ์•„. + [inputView deleteBackward]; + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ should be the current string. + + [inputView insertText:@"๐Ÿ˜€"]; + [inputView deleteBackward]; + // Insert the first unichar in the emoji. + [inputView insertText:[@"๐Ÿ˜€" substringWithRange:NSMakeRange(0, 1)]]; + [inputView insertText:@"์•„"]; + XCTAssertEqualObjects(inputView.text, @"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ˜€์•„"); + + // Deleting ์•„. + [inputView deleteBackward]; + // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ˜€ should be the current string. + + [inputView deleteBackward]; + // Insert the first unichar in the emoji. + [inputView insertText:[@"๐Ÿ˜€" substringWithRange:NSMakeRange(0, 1)]]; + [inputView insertText:@"์•„"]; + + XCTAssertEqualObjects(inputView.text, @"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ๐Ÿ˜€์•„"); +} + +- (void)testCachedComposedCharacterClearedAtKeyboardInteraction { + NSDictionary* config = self.mutableTemplateCopy; + [self setClientId:123 configuration:config]; + NSArray* inputFields = self.installedInputViews; + FlutterTextInputView* inputView = inputFields[0]; + + [inputView insertText:@"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"]; + [inputView deleteBackward]; + [inputView shouldChangeTextInRange:OCMClassMock([UITextRange class]) replacementText:@""]; + + // Insert the first unichar in the emoji. + NSString* brokenEmoji = [@"๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" substringWithRange:NSMakeRange(0, 1)]; + [inputView insertText:brokenEmoji]; + [inputView insertText:@"์•„"]; + + NSString* finalText = [NSString stringWithFormat:@"%@์•„", brokenEmoji]; + XCTAssertEqualObjects(inputView.text, finalText); +} + - (void)testPastingNonTextDisallowed { NSDictionary* config = self.mutableTemplateCopy; [self setClientId:123 configuration:config]; diff --git a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme index 48aa2903c3a41..b1341fc8d5c2a 100644 --- a/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme +++ b/testing/ios/IosUnitTests/IosUnitTests.xcodeproj/xcshareddata/xcschemes/IosUnitTests.xcscheme @@ -78,6 +78,12 @@ ReferencedContainer = "container:IosUnitTests.xcodeproj"> + + + +