From c0f0e36464542dee7e2f551be1adaac288ebcebf Mon Sep 17 00:00:00 2001 From: Chris Yang Date: Tue, 4 Oct 2022 15:55:50 -0700 Subject: [PATCH] Workaround iOS text input crash for emoji+Korean text (#36295) --- .../Source/FlutterTextInputPlugin.mm | 51 +++++++++++++--- .../Source/FlutterTextInputPluginTest.mm | 59 +++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 2d33fb9d6d22d..3b1aef1fc6bc1 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -73,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 @@ -715,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 @@ -901,6 +918,8 @@ - (void)dealloc { [_markedTextStyle release]; [_textContentType release]; [_textInteraction release]; + [_temporarilyDeletedComposedCharacter release]; + _temporarilyDeletedComposedCharacter = nil; [super dealloc]; } @@ -1245,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 @@ -1832,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]], @@ -1902,15 +1934,7 @@ - (void)deleteBackward { // 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); - UChar32 codePoint; - BOOL gotCodePoint = [self.text getBytes:&codePoint - maxLength:sizeof(codePoint) - usedLength:NULL - encoding:NSUTF32StringEncoding - options:kNilOptions - range:charRange - remainingRange:NULL]; - if (gotCodePoint && u_hasBinaryProperty(codePoint, UCHAR_EMOJI)) { + if (IsEmoji(self.text, charRange)) { newRange = NSMakeRange(charRange.location, oldRange.location - charRange.location); } @@ -1920,6 +1944,15 @@ - (void)deleteBackward { } 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 bec7028df6354..2a2dd15553c85 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -441,6 +441,65 @@ - (void)testDeletingBackward { 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];