unhide uitextinput when focused (#23776)

diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
index bb915dd..ced4268 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
@@ -71,6 +71,7 @@
 @property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0));
 
 @property(nonatomic, assign) id<FlutterTextInputDelegate> textInputDelegate;
+@property(nonatomic, assign) UIAccessibilityElement* backingTextInputAccessibilityObject;
 
 @end
 #endif  // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
index 507b9ca..52b3103 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
@@ -7,10 +7,14 @@
 #import <Foundation/Foundation.h>
 #import <UIKit/UIKit.h>
 
+#include "flutter/fml/logging.h"
 #include "flutter/fml/platform/darwin/string_range_sanitization.h"
 
 static const char _kTextAffinityDownstream[] = "TextAffinity.downstream";
 static const char _kTextAffinityUpstream[] = "TextAffinity.upstream";
+// A delay before enabling the accessibility of FlutterTextInputView after
+// it is activated.
+static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5;
 
 // The "canonical" invalid CGRect, similar to CGRectNull, used to
 // indicate a CGRect involved in firstRectForRange calculation is
@@ -424,6 +428,7 @@
 @property(nonatomic, readonly) CATransform3D editableTransform;
 @property(nonatomic, assign) CGRect markedRect;
 @property(nonatomic) BOOL isVisibleToAutofill;
+@property(nonatomic, assign) BOOL accessibilityEnabled;
 
 - (void)setEditableTransform:(NSArray*)matrix;
 @end
@@ -462,6 +467,7 @@
     _keyboardType = UIKeyboardTypeDefault;
     _returnKeyType = UIReturnKeyDone;
     _secureTextEntry = NO;
+    _accessibilityEnabled = NO;
     if (@available(iOS 11.0, *)) {
       _smartQuotesType = UITextSmartQuotesTypeYes;
       _smartDashesType = UITextSmartDashesTypeYes;
@@ -1106,16 +1112,52 @@
     [self replaceRange:_selectedTextRange withText:@""];
 }
 
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
+  UIAccessibilityPostNotification(notification, target);
+}
+
+- (void)accessibilityElementDidBecomeFocused {
+  if ([self accessibilityElementIsFocused]) {
+    // For most of the cases, this flutter text input view should never
+    // receive the focus. If we do receive the focus, we make the best effort
+    // to send the focus back to the real text field.
+    FML_DCHECK(_backingTextInputAccessibilityObject);
+    [self postAccessibilityNotification:UIAccessibilityScreenChangedNotification
+                                 target:_backingTextInputAccessibilityObject];
+  }
+}
+
 - (BOOL)accessibilityElementsHidden {
-  // We are hiding this accessibility element.
-  // There are 2 accessible elements involved in text entry in 2 different parts of the view
-  // hierarchy. This `FlutterTextInputView` is injected at the top of key window. We use this as a
-  // `UITextInput` protocol to bridge text edit events between Flutter and iOS.
-  //
-  // We also create ur own custom `UIAccessibilityElements` tree with our `SemanticsObject` to
-  // mimic the semantics tree from Flutter. We want the text field to be represented as a
-  // `TextInputSemanticsObject` in that `SemanticsObject` tree rather than in this
-  // `FlutterTextInputView` bridge which doesn't appear above a text field from the Flutter side.
+  return !_accessibilityEnabled;
+}
+
+@end
+
+/**
+ * Hides `FlutterTextInputView` from iOS accessibility system so it
+ * does not show up twice, once where it is in the `UIView` hierarchy,
+ * and a second time as part of the `SemanticsObject` hierarchy.
+ *
+ * This prevents the `FlutterTextInputView` from receiving the focus
+ * due to swipping gesture.
+ *
+ * There are other cases the `FlutterTextInputView` may receive
+ * focus. One example is during screen changes, the accessibility
+ * tree will undergo a dramatic structural update. The Voiceover may
+ * decide to focus the `FlutterTextInputView` that is not involved
+ * in the structural update instead. If that happens, the
+ * `FlutterTextInputView` will make a best effort to direct the
+ * focus back to the `SemanticsObject`.
+ */
+@interface FlutterTextInputViewAccessibilityHider : UIView {
+}
+
+@end
+
+@implementation FlutterTextInputViewAccessibilityHider {
+}
+
+- (BOOL)accessibilityElementsHidden {
   return YES;
 }
 
@@ -1128,9 +1170,12 @@
 @property(nonatomic, readonly)
     NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
 @property(nonatomic, assign) FlutterTextInputView* activeView;
+@property(nonatomic, strong) FlutterTextInputViewAccessibilityHider* inputHider;
 @end
 
-@implementation FlutterTextInputPlugin
+@implementation FlutterTextInputPlugin {
+  NSTimer* _enableFlutterTextInputViewAccessibilityTimer;
+}
 
 @synthesize textInputDelegate = _textInputDelegate;
 
@@ -1142,6 +1187,7 @@
     _reusableInputView.secureTextEntry = NO;
     _autofillContext = [[NSMutableDictionary alloc] init];
     _activeView = _reusableInputView;
+    _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
   }
 
   return self;
@@ -1150,11 +1196,19 @@
 - (void)dealloc {
   [self hideTextInput];
   [_reusableInputView release];
+  [_inputHider release];
   [_autofillContext release];
-
   [super dealloc];
 }
 
+- (void)removeEnableFlutterTextInputViewAccessibilityTimer {
+  if (_enableFlutterTextInputViewAccessibilityTimer) {
+    [_enableFlutterTextInputViewAccessibilityTimer invalidate];
+    [_enableFlutterTextInputViewAccessibilityTimer release];
+    _enableFlutterTextInputViewAccessibilityTimer = nil;
+  }
+}
+
 - (UIView<UITextInput>*)textInputView {
   return _activeView;
 }
@@ -1207,11 +1261,38 @@
 - (void)showTextInput {
   _activeView.textInputDelegate = _textInputDelegate;
   [self addToInputParentViewIfNeeded:_activeView];
+  // Adds a delay to prevent the text view from receiving accessibility
+  // focus in case it is activated during semantics updates.
+  //
+  // One common case is when the app navigates to a page with an auto
+  // focused text field. The text field will activate the FlutterTextInputView
+  // with a semantics update sent to the engine. The voiceover will focus
+  // the newly attached active view while performing accessibility update.
+  // This results in accessibility focus stuck at the FlutterTextInputView.
+  if (!_enableFlutterTextInputViewAccessibilityTimer) {
+    _enableFlutterTextInputViewAccessibilityTimer =
+        [[NSTimer scheduledTimerWithTimeInterval:kUITextInputAccessibilityEnablingDelaySeconds
+                                          target:self
+                                        selector:@selector(enableActiveViewAccessibility:)
+                                        userInfo:nil
+                                         repeats:NO] retain];
+  }
   [_activeView becomeFirstResponder];
 }
 
+- (void)enableActiveViewAccessibility:(NSTimer*)time {
+  if (_activeView.isFirstResponder) {
+    _activeView.accessibilityEnabled = YES;
+  }
+  [self removeEnableFlutterTextInputViewAccessibilityTimer];
+}
+
 - (void)hideTextInput {
+  [self removeEnableFlutterTextInputViewAccessibilityTimer];
+  _activeView.accessibilityEnabled = NO;
   [_activeView resignFirstResponder];
+  [_activeView removeFromSuperview];
+  [_inputHider removeFromSuperview];
 }
 
 - (void)triggerAutofillSave:(BOOL)saveEntries {
@@ -1356,7 +1437,7 @@
 }
 
 // The UIView to add FlutterTextInputViews to.
-- (UIView*)textInputParentView {
+- (UIView*)keyWindow {
   UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
   NSAssert(keyWindow != nullptr,
            @"The application must have a key window since the keyboard client "
@@ -1364,12 +1445,17 @@
   return keyWindow;
 }
 
+// The UIView to add FlutterTextInputViews to.
+- (NSArray<UIView*>*)textInputViews {
+  return _inputHider.subviews;
+}
+
 // Removes every installed input field, unless it's in the current autofill
 // context. May remove the active view too if includeActiveView is YES.
 // When clearText is YES, the text on the input fields will be set to empty before
 // they are removed from the view hierarchy, to avoid triggering autofill save.
 - (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
-  for (UIView* view in self.textInputParentView.subviews) {
+  for (UIView* view in self.textInputViews) {
     if ([view isKindOfClass:[FlutterTextInputView class]] &&
         (includeActiveView || view != _activeView)) {
       FlutterTextInputView* inputView = (FlutterTextInputView*)view;
@@ -1390,7 +1476,7 @@
 // Changes the visibility of every FlutterTextInputView currently in the
 // view hierarchy.
 - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
-  for (UIView* view in self.textInputParentView.subviews) {
+  for (UIView* view in self.textInputViews) {
     if ([view isKindOfClass:[FlutterTextInputView class]]) {
       FlutterTextInputView* inputView = (FlutterTextInputView*)view;
       inputView.isVisibleToAutofill = newVisibility;
@@ -1401,7 +1487,7 @@
 // Resets the client id of every FlutterTextInputView in the view hierarchy
 // to 0. Called when a new text input connection will be established.
 - (void)resetAllClientIds {
-  for (UIView* view in self.textInputParentView.subviews) {
+  for (UIView* view in self.textInputViews) {
     if ([view isKindOfClass:[FlutterTextInputView class]]) {
       FlutterTextInputView* inputView = (FlutterTextInputView*)view;
       [inputView setTextInputClient:0];
@@ -1410,9 +1496,12 @@
 }
 
 - (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView {
-  UIView* parentView = self.textInputParentView;
-  if (inputView.superview != parentView) {
-    [parentView addSubview:inputView];
+  if (![inputView isDescendantOfView:_inputHider]) {
+    [_inputHider addSubview:inputView];
+  }
+  UIView* parentView = self.keyWindow;
+  if (_inputHider.superview != parentView) {
+    [parentView addSubview:_inputHider];
   }
 }
 
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
index 88683ab..618e55a 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
@@ -20,6 +20,30 @@
 - (void)setMarkedRect:(CGRect)markedRect;
 - (void)updateEditingState;
 - (BOOL)isVisibleToAutofill;
+
+@end
+
+@interface FlutterTextInputViewSpy : FlutterTextInputView
+@property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
+@property(nonatomic, assign) id receivedNotificationTarget;
+@property(nonatomic, assign) BOOL isAccessibilityFocused;
+
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
+
+@end
+
+@implementation FlutterTextInputViewSpy {
+}
+
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
+  self.receivedNotification = notification;
+  self.receivedNotificationTarget = target;
+}
+
+- (BOOL)accessibilityElementIsFocused {
+  return _isAccessibilityFocused;
+}
+
 @end
 
 @interface FlutterSecureTextInputView : FlutterTextInputView
@@ -33,7 +57,7 @@
     NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
 
 - (void)collectGarbageInputViews;
-- (UIView*)textInputParentView;
+- (NSArray<UIView*>*)textInputViews;
 @end
 
 @interface FlutterTextInputPluginTest : XCTestCase
@@ -71,6 +95,22 @@
                              }];
 }
 
+- (void)setTextInputShow {
+  FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
+                                                                       arguments:@[]];
+  [textInputPlugin handleMethodCall:setClientCall
+                             result:^(id _Nullable result){
+                             }];
+}
+
+- (void)setTextInputHide {
+  FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
+                                                                       arguments:@[]];
+  [textInputPlugin handleMethodCall:setClientCall
+                             result:^(id _Nullable result){
+                             }];
+}
+
 - (NSMutableDictionary*)mutableTemplateCopy {
   if (!_template) {
     _template = @{
@@ -88,7 +128,7 @@
 }
 
 - (NSArray<FlutterTextInputView*>*)installedInputViews {
-  return [textInputPlugin.textInputParentView.subviews
+  return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
       filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
                                                                    [FlutterTextInputView class]]];
 }
@@ -743,4 +783,39 @@
   [self commitAutofillContextAndVerify];
 }
 
+#pragma mark - Accessibility - Tests
+
+- (void)testUITextInputAccessibilityNotHiddenWhenShowed {
+  // Send show text input method call.
+  [self setTextInputShow];
+  // Find all the FlutterTextInputViews we created.
+  NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
+
+  // The input view should not be hidden.
+  XCTAssertEqual([inputFields count], 1u);
+
+  // Send hide text input method call.
+  [self setTextInputHide];
+
+  inputFields = self.installedInputViews;
+
+  // The input view should be hidden.
+  XCTAssertEqual([inputFields count], 0u);
+}
+
+- (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
+  FlutterTextInputViewSpy* inputView = [[FlutterTextInputViewSpy alloc] init];
+  inputView.textInputDelegate = engine;
+  UIView* container = [[UIView alloc] init];
+  UIAccessibilityElement* backing =
+      [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
+  inputView.backingTextInputAccessibilityObject = backing;
+  // Simulate accessibility focus.
+  inputView.isAccessibilityFocused = YES;
+  [inputView accessibilityElementDidBecomeFocused];
+
+  XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
+  XCTAssertEqual(inputView.receivedNotificationTarget, backing);
+}
+
 @end
diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
index d9bf1cb..2b6b522 100644
--- a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
+++ b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
@@ -198,10 +198,14 @@
 - (void)setSemanticsNode:(const flutter::SemanticsNode*)node {
   [super setSemanticsNode:node];
   _inactive_text_input.text = @(node->value.data());
+  FlutterTextInputView* textInput = (FlutterTextInputView*)[self bridge]->textInputView();
   if ([self node].HasFlag(flutter::SemanticsFlags::kIsFocused)) {
+    textInput.backingTextInputAccessibilityObject = self;
     // The text input view must have a non-trivial size for the accessibility
     // system to send text editing events.
-    [self bridge]->textInputView().frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
+    textInput.frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
+  } else if (textInput.backingTextInputAccessibilityObject == self) {
+    textInput.backingTextInputAccessibilityObject = nil;
   }
 }