diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 0e2c656a32fe5..3b1aef1fc6bc1 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -7,6 +7,8 @@ #import #import +#include "unicode/uchar.h" + #include "flutter/fml/logging.h" #include "flutter/fml/platform/darwin/string_range_sanitization.h" @@ -71,6 +73,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 +728,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 +918,8 @@ - (void)dealloc { [_markedTextStyle release]; [_textContentType release]; [_textInteraction release]; + [_temporarilyDeletedComposedCharacter release]; + _temporarilyDeletedComposedCharacter = nil; [super dealloc]; } @@ -1243,6 +1264,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 +1855,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 +1930,29 @@ - (void)deleteBackward { NSRange oldRange = ((FlutterTextRange*)oldSelectedRange).range; if (oldRange.location > 0) { NSRange newRange = NSMakeRange(oldRange.location - 1, 1); + + // We should check if the last character is a part of emoji. + // If so, we must delete the entire emoji to prevent the text from being malformed. + NSRange charRange = fml::RangeForCharacterAtIndex(self.text, oldRange.location - 1); + if (IsEmoji(self.text, charRange)) { + newRange = NSMakeRange(charRange.location, oldRange.location - charRange.location); + } + _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..2a2dd15553c85 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -406,6 +406,100 @@ - (void)testStandardEditActions { XCTAssertEqualObjects(substring, @"bbbbaaaabbbbaaaa"); } +- (void)testDeletingBackward { + NSDictionary* config = self.mutableTemplateCopy; + [self setClientId:123 configuration:config]; + NSArray* inputFields = self.installedInputViews; + FlutterTextInputView* inputView = inputFields[0]; + + [inputView insertText:@"αžΉπŸ˜€ text πŸ₯°πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸ‡ΊπŸ‡³ΰΈ”ΰΈ΅ "]; + [inputView deleteBackward]; + [inputView deleteBackward]; + + // Thai vowel is removed. + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€ text πŸ₯°πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸ‡ΊπŸ‡³ΰΈ”"); + [inputView deleteBackward]; + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€ text πŸ₯°πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦πŸ‡ΊπŸ‡³"); + [inputView deleteBackward]; + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€ text πŸ₯°πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"); + [inputView deleteBackward]; + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€ text πŸ₯°"); + [inputView deleteBackward]; + + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€ text "); + [inputView deleteBackward]; + [inputView deleteBackward]; + [inputView deleteBackward]; + [inputView deleteBackward]; + [inputView deleteBackward]; + [inputView deleteBackward]; + + XCTAssertEqualObjects(inputView.text, @"αžΉπŸ˜€"); + [inputView deleteBackward]; + XCTAssertEqualObjects(inputView.text, @"ឹ"); + [inputView deleteBackward]; + XCTAssertEqualObjects(inputView.text, @""); +} + +// 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"> + + + +