From 004b51c63be1e9918d78eaf846313fbbfeafeefb Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Tue, 25 Aug 2026 17:21:30 +1000 Subject: [PATCH 1/9] fix: route ios keyboard focus requests through the window root and retry on attach UIKit honors setNeedsFocusUpdate only when the environment contains the currently focused item, so requests on nearest-ancestor controllers were silently dropped (nested controllers, react-native-screens). All focus requests now go through RNCEKVKeyboardFocusService, preferring the key window root. Imperative focus() on a detached view records a pending request replayed on window attach. Also collapses the duplicated autofocus branches in didMoveToWindow, revalidates the deferred autofocus block against a generation counter after recycling, and enables the directional order guides via a descendant check instead of a first-subview pointer comparison. The text-input wrapper additionally gains the hasOnFocusChanged emission gate ahead of the base-class gate removal in the focus-event commit; the interim tree is double-gated with unchanged behavior. --- ios/Services/RNCEKVKeyboardFocusService.h | 7 ++++ ios/Services/RNCEKVKeyboardFocusService.mm | 9 +++-- .../RNCEKVViewOrderGroupBase.mm | 7 ++-- .../RNCEKVViewFocusRequestBase.mm | 34 ++++++++++++------- .../RNCEKVTextInputFocusWrapper.mm | 26 +++++++++++--- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/ios/Services/RNCEKVKeyboardFocusService.h b/ios/Services/RNCEKVKeyboardFocusService.h index e1a548f..87c977f 100644 --- a/ios/Services/RNCEKVKeyboardFocusService.h +++ b/ios/Services/RNCEKVKeyboardFocusService.h @@ -23,6 +23,13 @@ /// Moves keyboard focus to the given view on the next focus update. + (void)focus:(UIView *)view; +/// Like `focus:`, but falls back to the given controller when no key-window root +/// controller exists. The root is preferred because UIKit honors a focus update +/// only when the environment it is requested on contains the currently focused +/// item — a nearest-ancestor controller often does not (nested controllers, +/// react-native-screens), and the request is then silently discarded. ++ (void)focus:(UIView *)view withFallback:(UIViewController *)controller; + @end #endif /* RNCEKVKeyboardFocusService_h */ diff --git a/ios/Services/RNCEKVKeyboardFocusService.mm b/ios/Services/RNCEKVKeyboardFocusService.mm index efd038f..f9d5df9 100644 --- a/ios/Services/RNCEKVKeyboardFocusService.mm +++ b/ios/Services/RNCEKVKeyboardFocusService.mm @@ -41,14 +41,17 @@ + (void)updatePreferredFocusEnvironment:(UIView *)view { } + (void)focus:(UIView *)view { + [self focus:view withFallback:nil]; +} + ++ (void)focus:(UIView *)view withFallback:(UIViewController *)controller { if (!view) { return; } UIWindow *window = RCTKeyWindow(); - if (window && window.rootViewController) { - [window.rootViewController rncekvFocusView:view]; - } + UIViewController *targetController = window.rootViewController ?: controller; + [targetController rncekvFocusView:view]; } @end diff --git a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm index 64d3be3..fbe9e03 100644 --- a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm +++ b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm @@ -8,7 +8,7 @@ #import #import "RNCEKVViewOrderGroupBase.h" #import "RNCEKVOrderLinking.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVKeyboardFocusService.h" #import "UIView+React.h" #import "RNCEKVPropHelper.h" @@ -34,7 +34,8 @@ - (instancetype)initWithFrame:(CGRect)frame } - (BOOL)getIsViewFocused:(UIFocusUpdateContext *)context { - return context.nextFocusedView == [self getStoredView]; + UIView *next = context.nextFocusedView; + return next != nil && [next isDescendantOfView:self]; } - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context @@ -48,7 +49,7 @@ - (void)focus { BOOL isAttached = self.superview != nil && controller != nil; if (isAttached) { - [controller rncekvFocusView:[self getStoredView]]; + [RNCEKVKeyboardFocusService focus:[self getStoredView] withFallback:controller]; } } diff --git a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm index f3dcb71..b60846c 100644 --- a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm +++ b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm @@ -6,10 +6,10 @@ // #import -#import "UIViewController+RNCEKVExternalKeyboard.h" #import "UIView+React.h" #import "RNCEKVViewFocusRequestBase.h" +#import "RNCEKVKeyboardFocusService.h" #ifdef RCT_NEW_ARCH_ENABLED #import "RNCEKVNativeProps.h" @@ -17,19 +17,20 @@ #endif @implementation RNCEKVViewFocusRequestBase { - BOOL _isAttachedToWindow; BOOL _autoFocusRequested; + BOOL _pendingFocusRequest; + NSUInteger _autoFocusGeneration; } - (void)cleanReferences { [super cleanReferences]; - _isAttachedToWindow = NO; _autoFocusRequested = NO; + _pendingFocusRequest = NO; + _autoFocusGeneration++; } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { - _isAttachedToWindow = NO; _autoFocusRequested = NO; } @@ -38,9 +39,11 @@ - (instancetype)initWithFrame:(CGRect)frame { - (void)focus { UIViewController *controller = self.reactViewController; - if (controller != nil) { - [controller rncekvFocusView: self]; + if (controller == nil) { + _pendingFocusRequest = YES; + return; } + [RNCEKVKeyboardFocusService focus:self withFallback:controller]; } - (void)screenReaderFocus { @@ -72,9 +75,17 @@ - (void)focusOnMount { if (self.autoFocus) { if(!_autoFocusRequested) { _autoFocusRequested = YES; + NSUInteger generation = _autoFocusGeneration; + __weak __typeof(self) weakSelf = self; dispatch_async(dispatch_get_main_queue(), ^{ dispatch_async(dispatch_get_main_queue(), ^{ - [self focus]; + __typeof(self) strongSelf = weakSelf; + if (strongSelf == nil || strongSelf->_autoFocusGeneration != generation) { + return; + } + if (strongSelf.window && strongSelf.autoFocus) { + [strongSelf focus]; + } }); }); } @@ -86,14 +97,11 @@ - (void)didMoveToWindow { [super didMoveToWindow]; if (self.window) { - [self onAttached]; - } - - if (self.window && !_isAttachedToWindow) { - if (self.autoFocus) { + if (_pendingFocusRequest) { + _pendingFocusRequest = NO; [self focus]; } - _isAttachedToWindow = YES; + [self onAttached]; } } diff --git a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm index 491aecc..909d5dd 100644 --- a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm +++ b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm @@ -6,7 +6,7 @@ #import "RNCEKVFocusEffectUtility.h" #import "RCTBaseTextInputView.h" #import "RNCEKVOrderLinking.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVKeyboardFocusService.h" #ifdef RCT_NEW_ARCH_ENABLED #import "RCTTextInputComponentView+RNCEKVExternalKeyboard.h" @@ -41,7 +41,9 @@ @interface RNCEKVTextInputFocusWrapper () static const NSInteger AUTO_FOCUS = 2; static const NSInteger AUTO_BLUR = 2; -@implementation RNCEKVTextInputFocusWrapper +@implementation RNCEKVTextInputFocusWrapper { + BOOL _pendingFocusRequest; +} - (instancetype)initWithFrame:(CGRect)frame { @@ -122,6 +124,9 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & #ifdef RCT_NEW_ARCH_ENABLED - (void)onFocusChangeHandler:(BOOL) isFocused { + if (!self.hasOnFocusChanged) { + return; + } if (_eventEmitter) { auto viewEventEmitter = std::static_pointer_cast(_eventEmitter); facebook::react::TextInputFocusWrapperEventEmitter::OnFocusChange data = { @@ -146,7 +151,7 @@ - (void)onMultiplyTextSubmitHandler: (RCTUITextView*) textView { - (void)onFocusChangeHandler:(BOOL) isFocused { - if(self.onFocusChange) { + if(self.hasOnFocusChanged && self.onFocusChange) { self.onFocusChange(@{ @"isFocused": @(isFocused) }); } } @@ -162,13 +167,25 @@ - (void)onMultiplyTextSubmitHandler: (RCTUITextView*) textView { - (void)focus { UIViewController *viewController = self.reactViewController; + if (viewController == nil || self.superview == nil) { + _pendingFocusRequest = YES; + return; + } [self updateFocus:viewController]; } - (void)updateFocus:(UIViewController *)controller { UIView *focusingView = self.subviews.count ? self.subviews[0] : nil; if (self.superview != nil && controller != nil) { - [controller rncekvFocusView:focusingView]; + [RNCEKVKeyboardFocusService focus:focusingView withFallback:controller]; + } +} + +- (void)didMoveToWindow { + [super didMoveToWindow]; + if (self.window && _pendingFocusRequest) { + _pendingFocusRequest = NO; + [self focus]; } } @@ -246,6 +263,7 @@ - (void)cleanReferences{ [super cleanReferences]; _textField = nil; _textView = nil; + _pendingFocusRequest = NO; } - (BOOL)getIsTextInputView: (UIView*)view { From 304f3fbe12f24801d2df5acc294f2438ff0ac970 Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Tue, 25 Aug 2026 17:21:48 +1000 Subject: [PATCH 2/9] fix: break ios focus retain cycles and stop retaining stale focus views All focus delegates held plain-strong back-pointers to their host views while the views strongly own the delegates, making every keyboard view immortal; the back-pointers are now zeroing-weak. The view controller's custom focus view association is stored through a weak holder, skipped and self-cleared when off-window, so controllers no longer retain unmounted subtrees or steer later focus updates to stale views. Group entry/exit boundary views latched in the order-linking singleton are now weak. --- .../RNCEKVFocusLinkDelegate.mm | 2 +- .../RNCEKVGroupIdentifierDelegate.mm | 2 +- .../UIViewController+RNCEKVExternalKeyboard.h | 2 +- ...UIViewController+RNCEKVExternalKeyboard.mm | 31 +++++++++++++++---- .../RNCEKVOrderRelationship.h | 4 +-- .../Halo/delegate/RNCEKVHaloDelegate.mm | 2 +- 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/ios/Delegates/RNCEKVFocusLinkDelegate/RNCEKVFocusLinkDelegate.mm b/ios/Delegates/RNCEKVFocusLinkDelegate/RNCEKVFocusLinkDelegate.mm index 0d17806..a9aede7 100644 --- a/ios/Delegates/RNCEKVFocusLinkDelegate/RNCEKVFocusLinkDelegate.mm +++ b/ios/Delegates/RNCEKVFocusLinkDelegate/RNCEKVFocusLinkDelegate.mm @@ -17,7 +17,7 @@ @implementation RNCEKVFocusLinkDelegate { BOOL _isFocused; - UIView *_delegate; + __weak UIView *_delegate; NSMutableDictionary *_sides; NSMutableDictionary *_subscribers; } diff --git a/ios/Delegates/RNCEKVGroupIdentifierDelegate/RNCEKVGroupIdentifierDelegate.mm b/ios/Delegates/RNCEKVGroupIdentifierDelegate/RNCEKVGroupIdentifierDelegate.mm index 1aecd5b..ad19602 100644 --- a/ios/Delegates/RNCEKVGroupIdentifierDelegate/RNCEKVGroupIdentifierDelegate.mm +++ b/ios/Delegates/RNCEKVGroupIdentifierDelegate/RNCEKVGroupIdentifierDelegate.mm @@ -15,7 +15,7 @@ @implementation RNCEKVGroupIdentifierDelegate { - UIView* _delegate; + __weak UIView* _delegate; NSString* _tagId; } diff --git a/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.h b/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.h index 2a83ee8..0e20579 100644 --- a/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.h +++ b/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.h @@ -11,7 +11,7 @@ #import @interface UIViewController (RNCEKVExternalKeyboard) -@property (nonatomic, strong) UIView *rncekvCustomFocusView; +@property (nonatomic, weak) UIView *rncekvCustomFocusView; - (void)rncekvFocusView:(UIView *)view; @end diff --git a/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm b/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm index d37b6cc..c0f852b 100644 --- a/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm +++ b/ios/Extensions/UIViewController+RNCEKVExternalKeyboard.mm @@ -21,16 +21,29 @@ static void RNCEKVUIViewControllerSwizzle(void) { RNCEKVSwizzleInstanceMethod([UIViewController class], @selector(preferredFocusEnvironments), @selector(keyboardedPreferredFocusEnvironments)); } +@interface RNCEKVWeakFocusViewHolder : NSObject +@property (nonatomic, weak) UIView *view; +@end + +@implementation RNCEKVWeakFocusViewHolder +@end + @implementation UIViewController (RNCEKVExternalKeyboard) RNCEKV_INSTALL_SWIZZLES(RNCEKVUIViewControllerSwizzle) - (UIView *)rncekvCustomFocusView { - return objc_getAssociatedObject(self, &kCustomFocusViewKey); + RNCEKVWeakFocusViewHolder *holder = objc_getAssociatedObject(self, &kCustomFocusViewKey); + return holder.view; } - (void)setRncekvCustomFocusView:(UIView *)customFocusView { - objc_setAssociatedObject(self, &kCustomFocusViewKey, customFocusView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + RNCEKVWeakFocusViewHolder *holder = nil; + if (customFocusView != nil) { + holder = [RNCEKVWeakFocusViewHolder new]; + holder.view = customFocusView; + } + objc_setAssociatedObject(self, &kCustomFocusViewKey, holder, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (void)keyboardedViewDidAppear:(BOOL)animated { @@ -49,13 +62,19 @@ - (void)rncekvFocusView:(UIView *)view { - (NSArray> *)keyboardedPreferredFocusEnvironments { NSArray> *originalEnvironments = [self keyboardedPreferredFocusEnvironments]; - NSMutableArray *focusEnvironments = [originalEnvironments mutableCopy]; + RNCEKVWeakFocusViewHolder *holder = objc_getAssociatedObject(self, &kCustomFocusViewKey); + if (holder == nil) { + return originalEnvironments; + } - UIView *customFocusView = self.rncekvCustomFocusView; - if (customFocusView) { - [focusEnvironments insertObject:customFocusView atIndex:0]; + UIView *customFocusView = holder.view; + if (customFocusView == nil || customFocusView.window == nil) { + self.rncekvCustomFocusView = nil; + return originalEnvironments; } + NSMutableArray *focusEnvironments = [originalEnvironments mutableCopy]; + [focusEnvironments insertObject:customFocusView atIndex:0]; return focusEnvironments; } diff --git a/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.h b/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.h index b6443b0..2d427cf 100644 --- a/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.h +++ b/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.h @@ -10,8 +10,8 @@ @interface RNCEKVOrderRelationship : NSObject -@property UIView* entry; -@property UIView* exit; +@property (weak) UIView* entry; +@property (weak) UIView* exit; - (void)add:(NSNumber*)position withObject:(NSObject*)obj; - (void)remove:(NSNumber*)position; diff --git a/ios/features/Halo/delegate/RNCEKVHaloDelegate.mm b/ios/features/Halo/delegate/RNCEKVHaloDelegate.mm index 97c74f3..bc5765f 100644 --- a/ios/features/Halo/delegate/RNCEKVHaloDelegate.mm +++ b/ios/features/Halo/delegate/RNCEKVHaloDelegate.mm @@ -14,7 +14,7 @@ // — `haloCornerRadius`, `haloExpendX`, `haloExpendY`. The radius is an input, not // observed off the layer, so there is no stable-radius tracking and no re-arm loop. @implementation RNCEKVHaloDelegate { - UIView *_delegate; + __weak UIView *_delegate; UIFocusEffect *_currentEffect; BOOL _isDirty; CGRect _prevBounds; From 671551276f5a80900980d1a482e75eab88810690 Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Tue, 25 Aug 2026 17:22:06 +1000 Subject: [PATCH 3/9] fix: correct ios focus-lock, order-group entry, and focus-event behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FocusTrap now diffs forceLock/lockDisabled and requests focus only when the lock becomes active, so prop commits and disable transitions no longer steal keyboard or VoiceOver focus. Tabbing into an ordered group suppresses UIKit's default move instead of double-focusing, and latched entry/exit boundaries are revalidated against the window before use. The native focus handler chain (context-menu registration) no longer depends on a JS listener being attached — only event emission is gated. Blur is emitted even after the tracked focused child deallocates, and moves between descendants of one wrapper no longer emit duplicate focus events. --- .../RNCEKVFocusDelegate.mm | 23 ++++++++++------ .../RNCEKVFocusSequenceDelegate.mm | 18 ++++++++----- .../FocusChange/RNCEKVViewFocusChangeBase.mm | 9 +++---- .../RNCEKVExternalKeyboardLockView.mm | 26 +++++++++++++------ .../RNCEKVExternalKeyboardView.mm | 5 +++- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm b/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm index 9befda4..50343c1 100644 --- a/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm +++ b/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm @@ -12,10 +12,13 @@ #import "RNCEKVFocusProtocol.h" @implementation RNCEKVFocusDelegate{ - UIView* _delegate; + __weak UIView* _delegate; // The view UIKit actually focused inside our subtree (set from the focus engine, // not guessed). Weak so a removed/recycled view can't be retained or go stale. __weak UIView* _focusedTarget; + // Survives _focusedTarget zeroing (target deallocated while focused) so the blur + // can still be reported when focus moves on. + BOOL _isTrackingFocus; } - (instancetype _Nonnull )initWithView:(UIView *_Nonnull)delegate{ @@ -28,6 +31,7 @@ - (instancetype _Nonnull )initWithView:(UIView *_Nonnull)de - (void)reset { _focusedTarget = nil; + _isTrackingFocus = NO; } // Whether `view` is the focus target THIS wrapper owns. A non-wrapper owns only @@ -119,18 +123,21 @@ - (NSNumber*)isFocusChanged:(UIFocusUpdateContext *)context { UIView *next = context.nextFocusedView; UIView *prev = context.previouslyFocusedView; - // Focus entered our subtree: remember the *actual* focused view and report focus. + // Focus entered our subtree: remember the *actual* focused view. A move between + // two of our own descendants keeps the wrapper focused — retarget without + // reporting a change, so JS never sees focus=true twice with no blur between. if (next && [self ownsFocusedView:next]) { - if (next == _focusedTarget) { - return nil; // already tracking this view — not a change - } + BOOL alreadyFocused = _isTrackingFocus; _focusedTarget = next; - return @YES; + _isTrackingFocus = YES; + return alreadyFocused ? nil : @YES; } - // Focus left the view we were tracking. - if (prev && prev == _focusedTarget) { + // Focus left the view we were tracking — or the tracked view deallocated + // (_focusedTarget zeroed) and focus moved elsewhere. + if (_isTrackingFocus && (_focusedTarget == nil || prev == _focusedTarget)) { _focusedTarget = nil; + _isTrackingFocus = NO; return @NO; } diff --git a/ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm b/ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm index acc9894..81bbdc1 100644 --- a/ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm +++ b/ios/Delegates/RNCEKVFocusSequenceDelegate/RNCEKVFocusSequenceDelegate.mm @@ -8,7 +8,7 @@ #import "RNCEKVOrderLinking.h" #import "RNCEKVOrderRelationship.h" #import "RNCEKVKeyboardFocusableProtocol.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVKeyboardFocusService.h" #import "UIView+React.h" static NSNumber *const FOCUS_DEFAULT = nil; @@ -16,7 +16,7 @@ @implementation RNCEKVFocusSequenceDelegate { BOOL _isLinked; - UIView *_delegate; + __weak UIView *_delegate; } - (instancetype)initWithView:(UIView *)delegate { @@ -65,10 +65,7 @@ - (void)keyboardedViewFocus:(UIView *)view { } - (void)defaultViewFocus:(UIView *)view { - UIViewController *controller = _delegate.reactViewController; - if (controller != nil) { - [controller rncekvFocusView:view]; - } + [RNCEKVKeyboardFocusService focus:view withFallback:_delegate.reactViewController]; } #pragma mark - Sequential navigation @@ -81,7 +78,7 @@ - (BOOL)handleNextFocus:(UIView *)current if (entry == current) { [self keyboardedViewFocus:[orderRelationship getItem:0]]; - return NO; + return YES; } if (currentIndex == orderRelationship.count - 1 && exit) { @@ -135,6 +132,13 @@ - (NSNumber *)shouldUpdateFocusInContext:(UIFocusUpdateContext *)context { return FOCUS_DEFAULT; } + if (orderRelationship.entry != nil && orderRelationship.entry.window == nil) { + orderRelationship.entry = nil; + } + if (orderRelationship.exit != nil && orderRelationship.exit.window == nil) { + orderRelationship.exit = nil; + } + int currentIndex = [orderRelationship getItemIndex:current]; int nextIndex = [orderRelationship getItemIndex:next]; diff --git a/ios/Views/Base/FocusChange/RNCEKVViewFocusChangeBase.mm b/ios/Views/Base/FocusChange/RNCEKVViewFocusChangeBase.mm index e40290e..f634656 100644 --- a/ios/Views/Base/FocusChange/RNCEKVViewFocusChangeBase.mm +++ b/ios/Views/Base/FocusChange/RNCEKVViewFocusChangeBase.mm @@ -61,12 +61,11 @@ - (NSNumber *)resolveFocusChange:(UIFocusUpdateContext *)context { - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { - _isFocused = [self resolveFocusChange:context]; + NSNumber *focusChange = [self resolveFocusChange:context]; - if ([self hasOnFocusChanged]) { - if (_isFocused != nil) { - [self onFocusChangeHandler:[_isFocused isEqual:@YES]]; - } + if (focusChange != nil) { + _isFocused = focusChange; + [self onFocusChangeHandler:[focusChange isEqual:@YES]]; } [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; diff --git a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm index c05268e..ac3e3ac 100644 --- a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm +++ b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm @@ -6,7 +6,7 @@ // #import -#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVKeyboardFocusService.h" #import #import @@ -78,15 +78,21 @@ - (void)onAccessibilityFocusChanged:(NSNotification *)notification { } - (void)setForceLock:(BOOL)forceLock { + BOOL becameActive = forceLock && !_forceLock && !_lockDisabled; _forceLock = forceLock; - [self requestFocus]; - [self requestScreenReaderFocus]; + if (becameActive) { + [self requestFocus]; + [self requestScreenReaderFocus]; + } } - (void)setLockDisabled:(BOOL)lockDisabled { + BOOL becameActive = _forceLock && !lockDisabled && _lockDisabled; _lockDisabled = lockDisabled; - [self requestFocus]; - [self requestScreenReaderFocus]; + if (becameActive) { + [self requestFocus]; + [self requestScreenReaderFocus]; + } } - (BOOL)shouldUpdateFocusInContext:(UIFocusUpdateContext *)context { @@ -107,7 +113,7 @@ - (void)requestFocus { UIViewController *controller = self.reactViewController; if (controller != nil) { - [controller rncekvFocusView: self]; + [RNCEKVKeyboardFocusService focus:self withFallback:controller]; } } @@ -142,8 +148,12 @@ - (void)updateProps:(Props::Shared const &)props *std::static_pointer_cast(props); [super updateProps:props oldProps:oldProps]; - self.forceLock = newViewProps.forceLock; - self.lockDisabled = newViewProps.lockDisabled; + if (_forceLock != newViewProps.forceLock) { + self.forceLock = newViewProps.forceLock; + } + if (_lockDisabled != newViewProps.lockDisabled) { + self.lockDisabled = newViewProps.lockDisabled; + } } Class ExternalKeyboardLockViewCls(void) diff --git a/ios/Views/RNCEKVExternalKeyboardView/RNCEKVExternalKeyboardView.mm b/ios/Views/RNCEKVExternalKeyboardView/RNCEKVExternalKeyboardView.mm index 27a1eb0..f5cf0b2 100644 --- a/ios/Views/RNCEKVExternalKeyboardView/RNCEKVExternalKeyboardView.mm +++ b/ios/Views/RNCEKVExternalKeyboardView/RNCEKVExternalKeyboardView.mm @@ -118,6 +118,9 @@ - (void)onBubbledContextMenuPressHandler { - (void)onFocusChangeHandler:(BOOL)isFocused { [super onFocusChangeHandler: isFocused]; + if (!self.hasOnFocusChanged) { + return; + } [RNCEKVFabricEventHelper onFocusChangeEventEmmiter:isFocused withEmitter:_eventEmitter]; } @@ -148,7 +151,7 @@ - (void)onBubbledContextMenuPressHandler { // - (void)onFocusChangeHandler:(BOOL)isFocused { [super onFocusChangeHandler: isFocused]; - if (self.onFocusChange) { + if (self.hasOnFocusChanged && self.onFocusChange) { self.onFocusChange(@{@"isFocused" : @(isFocused)}); } } From 6d0b46ac34f5ead66dc9de77b6ad57b816930b8d Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Wed, 26 Aug 2026 11:58:35 +1000 Subject: [PATCH 4/9] test: add unit tests for the ios focus path Hydrates the dormant ExternalKeyboardExampleTests target with unit tests covering focus change events, focus delegates, keyboard focus service, lock view, and retain cycles. Adds a setup script to enable coverage on the test run and updates the Podfile/project to wire the target into the example workspace. --- .../project.pbxproj | 101 +++++- .../ExternalKeyboardExampleTests/Info.plist | 12 + .../RNCEKVFocusChangeEventTests.mm | 201 ++++++++++++ .../RNCEKVFocusDelegateTests.mm | 144 +++++++++ .../RNCEKVFocusRequestBaseTests.mm | 169 ++++++++++ .../RNCEKVFocusSequenceDelegateTests.mm | 285 +++++++++++++++++ .../RNCEKVKeyboardFocusServiceTests.mm | 65 ++++ .../RNCEKVLockViewTests.mm | 288 ++++++++++++++++++ .../RNCEKVOrderGroupBaseTests.mm | 101 ++++++ .../RNCEKVRetainCycleTests.mm | 191 ++++++++++++ .../RNCEKVTestSupport.h | 135 ++++++++ .../RNCEKVTestSupport.mm | 81 +++++ .../RNCEKVTextInputFocusWrapperTests.mm | 121 ++++++++ .../RNCEKVViewControllerExtensionTests.mm | 100 ++++++ example/ios/Podfile | 4 + example/ios/Podfile.lock | 136 ++++----- example/ios/scripts/setup_unit_tests.rb | 28 ++ 17 files changed, 2091 insertions(+), 71 deletions(-) create mode 100644 example/ios/ExternalKeyboardExampleTests/Info.plist create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm create mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm create mode 100644 example/ios/scripts/setup_unit_tests.rb diff --git a/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj b/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj index 9b22e15..c6b1397 100644 --- a/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj +++ b/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj @@ -8,10 +8,22 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 1EEE3A3E0203CF05C09377F4 /* RNCEKVViewControllerExtensionTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */; }; + 1F305FA47A9B6677B57844F1 /* RNCEKVLockViewTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */; }; + 2DE37770475F7057B5599438 /* RNCEKVRetainCycleTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */; }; + 2FBF7B772C58DD5E1A887A77 /* RNCEKVFocusChangeEventTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */; }; 49BB6AB52F57756100D611EC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49BB6AB42F57756100D611EC /* AppDelegate.swift */; }; + 53A9D0E943566D33D18AEC20 /* RNCEKVTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */; }; + 70ADC087D979C21AF5FD4BFD /* RNCEKVFocusRequestBaseTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */; }; + 74CE1FA6E9882F06425F601D /* RNCEKVFocusSequenceDelegateTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */; }; + 7E544FB94E365B379EF4CE81 /* libPods-ExternalKeyboardExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */; }; + 7FA320F6D655DD131985493C /* RNCEKVKeyboardFocusServiceTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 8C8CDA348C04716D7E978977 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 9AB31AFBF154E1F653ABFDCC /* RNCEKVTextInputFocusWrapperTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */; }; + AFA2C580C8C0FA06CD2B0649 /* RNCEKVOrderGroupBaseTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */; }; C7DA79396D52E915C74FA1F4 /* libPods-ExternalKeyboardExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */; }; + FE3AA40BEAA4EDD086C0B018 /* RNCEKVFocusDelegateTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,17 +38,32 @@ /* Begin PBXFileReference section */ 00E356EE1AD99517003FC87E /* ExternalKeyboardExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExternalKeyboardExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusChangeEventTests.mm; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* ExternalKeyboardExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExternalKeyboardExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = ExternalKeyboardExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ExternalKeyboardExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ExternalKeyboardExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 1BA93C23CA0B2C4A9E920F95 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ExternalKeyboardExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 37EFAB3374F22BB8020F5BD3 /* Pods-ExternalKeyboardExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExample.release.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample.release.xcconfig"; sourceTree = ""; }; + 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVViewControllerExtensionTests.mm; sourceTree = ""; }; + 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExampleTests/Pods-ExternalKeyboardExampleTests.debug.xcconfig"; sourceTree = ""; }; + 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVOrderGroupBaseTests.mm; sourceTree = ""; }; 49BB6AB42F57756100D611EC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 49BB6AB62F57756400D611EC /* ExternalKeyboardExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ExternalKeyboardExample-Bridging-Header.h"; sourceTree = ""; }; 4C1BC52B9D066024791DCF89 /* Pods-ExternalKeyboardExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExample.debug.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample.debug.xcconfig"; sourceTree = ""; }; + 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVRetainCycleTests.mm; sourceTree = ""; }; + 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ExternalKeyboardExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusRequestBaseTests.mm; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ExternalKeyboardExample/LaunchScreen.storyboard; sourceTree = ""; }; + 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVKeyboardFocusServiceTests.mm; sourceTree = ""; }; + AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVTextInputFocusWrapperTests.mm; sourceTree = ""; }; + AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVLockViewTests.mm; sourceTree = ""; }; + C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusDelegateTests.mm; sourceTree = ""; }; + D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVTestSupport.mm; sourceTree = ""; }; + E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExampleTests.release.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExampleTests/Pods-ExternalKeyboardExampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusSequenceDelegateTests.mm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -44,6 +71,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7E544FB94E365B379EF4CE81 /* libPods-ExternalKeyboardExampleTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +104,7 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */, + 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -95,6 +124,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, + A9AC4704AAD612D7990297BE /* ExternalKeyboardExampleTests */, ); indentWidth = 2; sourceTree = ""; @@ -110,11 +140,33 @@ name = Products; sourceTree = ""; }; + A9AC4704AAD612D7990297BE /* ExternalKeyboardExampleTests */ = { + isa = PBXGroup; + children = ( + 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */, + C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */, + 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */, + FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */, + 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */, + AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */, + 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */, + 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */, + D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */, + AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */, + 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */, + 1BA93C23CA0B2C4A9E920F95 /* Info.plist */, + ); + name = ExternalKeyboardExampleTests; + path = ExternalKeyboardExampleTests; + sourceTree = ""; + }; BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( 4C1BC52B9D066024791DCF89 /* Pods-ExternalKeyboardExample.debug.xcconfig */, 37EFAB3374F22BB8020F5BD3 /* Pods-ExternalKeyboardExample.release.xcconfig */, + 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */, + E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -126,6 +178,7 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "ExternalKeyboardExampleTests" */; buildPhases = ( + CB5AFCFB03868E4E84DCE553 /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, @@ -232,7 +285,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n\"$WITH_ENVIRONMENT\" \"$REACT_NATIVE_XCODE\"\n"; }; 16BED33323DB9400FA927456 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -273,6 +326,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; + CB5AFCFB03868E4E84DCE553 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-ExternalKeyboardExampleTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; ECDD31C7EEDF518249EFE814 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -297,6 +372,17 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2FBF7B772C58DD5E1A887A77 /* RNCEKVFocusChangeEventTests.mm in Sources */, + FE3AA40BEAA4EDD086C0B018 /* RNCEKVFocusDelegateTests.mm in Sources */, + 70ADC087D979C21AF5FD4BFD /* RNCEKVFocusRequestBaseTests.mm in Sources */, + 74CE1FA6E9882F06425F601D /* RNCEKVFocusSequenceDelegateTests.mm in Sources */, + 7FA320F6D655DD131985493C /* RNCEKVKeyboardFocusServiceTests.mm in Sources */, + 1F305FA47A9B6677B57844F1 /* RNCEKVLockViewTests.mm in Sources */, + AFA2C580C8C0FA06CD2B0649 /* RNCEKVOrderGroupBaseTests.mm in Sources */, + 2DE37770475F7057B5599438 /* RNCEKVRetainCycleTests.mm in Sources */, + 53A9D0E943566D33D18AEC20 /* RNCEKVTestSupport.mm in Sources */, + 9AB31AFBF154E1F653ABFDCC /* RNCEKVTextInputFocusWrapperTests.mm in Sources */, + 1EEE3A3E0203CF05C09377F4 /* RNCEKVViewControllerExtensionTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,11 +407,14 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", + "RCT_NEW_ARCH_ENABLED=1", ); INFOPLIST_FILE = ExternalKeyboardExampleTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -339,7 +428,7 @@ "-lc++", "$(inherited)", ); - PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example; + PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example.tests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ExternalKeyboardExample.app/ExternalKeyboardExample"; }; @@ -347,9 +436,15 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "RCT_NEW_ARCH_ENABLED=1", + ); INFOPLIST_FILE = ExternalKeyboardExampleTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -362,7 +457,7 @@ "-lc++", "$(inherited)", ); - PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example; + PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example.tests; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ExternalKeyboardExample.app/ExternalKeyboardExample"; }; diff --git a/example/ios/ExternalKeyboardExampleTests/Info.plist b/example/ios/ExternalKeyboardExampleTests/Info.plist new file mode 100644 index 0000000..33e4700 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/Info.plist @@ -0,0 +1,12 @@ + + + +CFBundleDevelopmentRegionen +CFBundleExecutable$(EXECUTABLE_NAME) +CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) +CFBundleInfoDictionaryVersion6.0 +CFBundleName$(PRODUCT_NAME) +CFBundlePackageTypeBNDL +CFBundleShortVersionString1.0 +CFBundleVersion1 + diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm new file mode 100644 index 0000000..323d908 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm @@ -0,0 +1,201 @@ +// +// RNCEKVFocusChangeEventTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import + +#import "RNCEKVExternalKeyboardView.h" +#import "RNCEKVTestSupport.h" + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import "RNCEKVFabricEventHelper.h" +#endif + +#pragma mark - RNCEKVFocusChangeEventRecordingView + +// Records every -onFocusChangeHandler: argument before forwarding to super, +// so a test can assert the exact sequence of focus-change events the base +// class fired without a live focus engine or JS-side event wiring. +@interface RNCEKVFocusChangeEventRecordingView : RNCEKVExternalKeyboardView + +@property (nonatomic, strong, readonly) NSArray *recordedFocusChanges; + +@end + +@implementation RNCEKVFocusChangeEventRecordingView { + NSMutableArray *_focusChangeLog; +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + _focusChangeLog = [NSMutableArray array]; + } + return self; +} + +- (NSArray *)recordedFocusChanges { + return [_focusChangeLog copy]; +} + +- (void)onFocusChangeHandler:(BOOL)isFocused { + [_focusChangeLog addObject:@(isFocused)]; + [super onFocusChangeHandler:isFocused]; +} + +@end + +#ifdef RCT_NEW_ARCH_ENABLED + +#pragma mark - RNCEKVFabricEventHelper counting replacement + +// Byte-identical C++ signature to +onFocusChangeEventEmmiter:withEmitter:, +// swapped in via method_exchangeImplementations so the leaf-gate tests can +// count invocations without constructing a real SharedViewEventEmitter. +// imp_implementationWithBlock is avoided here: it is ABI-delicate over a +// by-value std::shared_ptr parameter, while a compiled category preserves +// the C++ calling convention exactly. +@interface RNCEKVFabricEventHelper (RNCEKVFocusChangeEventCounting) + ++ (void)rncekv_test_onFocusChangeEventEmmiter:(BOOL)isFocused + withEmitter:(facebook::react::SharedViewEventEmitter)emitter; + +@end + +static NSUInteger sRNCEKVFocusChangeEmitCallCount; +static BOOL sRNCEKVFocusChangeEmitLastIsFocused; + +@implementation RNCEKVFabricEventHelper (RNCEKVFocusChangeEventCounting) + ++ (void)rncekv_test_onFocusChangeEventEmmiter:(BOOL)isFocused + withEmitter:(facebook::react::SharedViewEventEmitter)emitter { + sRNCEKVFocusChangeEmitCallCount += 1; + sRNCEKVFocusChangeEmitLastIsFocused = isFocused; +} + +@end + +#endif /* RCT_NEW_ARCH_ENABLED */ + +#pragma mark - Tests + +@interface RNCEKVFocusChangeEventTests : XCTestCase +@end + +@implementation RNCEKVFocusChangeEventTests + +#ifdef RCT_NEW_ARCH_ENABLED +- (void)swapFocusChangeEventEmitterImplementations { + Method original = class_getClassMethod([RNCEKVFabricEventHelper class], + @selector(onFocusChangeEventEmmiter:withEmitter:)); + Method replacement = class_getClassMethod([RNCEKVFabricEventHelper class], + @selector(rncekv_test_onFocusChangeEventEmmiter:withEmitter:)); + method_exchangeImplementations(original, replacement); +} +#endif + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +#ifdef RCT_NEW_ARCH_ENABLED + sRNCEKVFocusChangeEmitCallCount = 0; + sRNCEKVFocusChangeEmitLastIsFocused = NO; + [self swapFocusChangeEventEmitterImplementations]; +#endif +} + +- (void)tearDown { +#ifdef RCT_NEW_ARCH_ENABLED + [self swapFocusChangeEventEmitterImplementations]; +#endif + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (RNCEKVFocusChangeEventRecordingView *)recordingViewWithCanBeFocused:(BOOL)canBeFocused + focusableWrapper:(BOOL)focusableWrapper { + RNCEKVFocusChangeEventRecordingView *view = + [[RNCEKVFocusChangeEventRecordingView alloc] initWithFrame:CGRectZero]; + view.canBeFocused = canBeFocused; + view.focusableWrapper = focusableWrapper; + return view; +} + +// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): the caller +// passes this straight into -didUpdateFocusInContext:withAnimationCoordinator:, +// a UIKit-declared method whose context parameter is _Nonnull-audited, so +// the compiler hard-errors on an unrelated-class argument unless it is +// already statically typed (or cast) to UIFocusUpdateContext* — see +// RNCEKVTestFocusContext's header comment for why the double isn't a real +// UIFocusUpdateContext subclass. +- (UIFocusUpdateContext *)contextWithNext:(nullable UIView *)next previous:(nullable UIView *)previous { + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = next; + context.previouslyFocusedView = previous; + return (UIFocusUpdateContext *)context; +} + +- (void)test_focusEnter_setsIsKeyboardFocused_firesHandlerYes { + RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; + + [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] + withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; + + XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES])); + XCTAssertTrue(view.isKeyboardFocused); +} + +- (void)test_unrelatedContext_preservesState_noHandlerCall { + RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; + [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] + withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; + + UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *unrelatedPrevious = [[UIView alloc] initWithFrame:CGRectZero]; + + [view didUpdateFocusInContext:[self contextWithNext:unrelatedNext previous:unrelatedPrevious] + withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; + + XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES])); + XCTAssertTrue(view.isKeyboardFocused); +} + +- (void)test_focusLeave_firesHandlerNo { + RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; + [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] + withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; + + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + [view didUpdateFocusInContext:[self contextWithNext:outside previous:view] + withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; + + XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES, @NO])); + XCTAssertFalse(view.isKeyboardFocused); +} + +#ifdef RCT_NEW_ARCH_ENABLED + +- (void)test_leafEmission_suppressedWithoutHasOnFocusChanged { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + view.hasOnFocusChanged = NO; + + [view onFocusChangeHandler:YES]; + + XCTAssertEqual(sRNCEKVFocusChangeEmitCallCount, 0u); +} + +- (void)test_leafEmission_firesWithHasOnFocusChanged { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + view.hasOnFocusChanged = YES; + + [view onFocusChangeHandler:YES]; + + XCTAssertEqual(sRNCEKVFocusChangeEmitCallCount, 1u); + XCTAssertTrue(sRNCEKVFocusChangeEmitLastIsFocused); +} + +#endif /* RCT_NEW_ARCH_ENABLED */ + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm new file mode 100644 index 0000000..5d48bb3 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm @@ -0,0 +1,144 @@ +// +// RNCEKVFocusDelegateTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import + +#import "RNCEKVFocusDelegate.h" +#import "RNCEKVTestSupport.h" + +@interface RNCEKVFocusDelegateTests : XCTestCase +@end + +@implementation RNCEKVFocusDelegateTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (RNCEKVFocusHostDouble *)hostWithFocusableWrapper:(BOOL)focusableWrapper { + RNCEKVFocusHostDouble *host = [[RNCEKVFocusHostDouble alloc] initWithFrame:CGRectZero]; + host.focusableWrapper = focusableWrapper; + return host; +} + +// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): callers pass +// this straight into -isFocusChanged:, whose declared parameter type this +// double is not a real subclass of (see RNCEKVTestFocusContext's header +// comment) — the cast keeps every call site's static type correct. +- (UIFocusUpdateContext *)contextWithNext:(UIView *)next previous:(UIView *)previous { + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = next; + context.previouslyFocusedView = previous; + return (UIFocusUpdateContext *)context; +} + +- (void)test_focusEnter_nonWrapper_reportsYes { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + + UIFocusUpdateContext *context = [self contextWithNext:host previous:nil]; + + XCTAssertEqualObjects([delegate isFocusChanged:context], @YES); + XCTAssertNil([delegate isFocusChanged:context]); +} + +- (void)test_wrapper_firstEntry_yes_secondDescendantEntry_nil_andRetargets { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:YES]; + UIView *child1 = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *child2 = [[UIView alloc] initWithFrame:CGRectZero]; + [host addSubview:child1]; + [host addSubview:child2]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + + UIFocusUpdateContext *firstEntry = [self contextWithNext:child1 previous:nil]; + XCTAssertEqualObjects([delegate isFocusChanged:firstEntry], @YES); + + UIFocusUpdateContext *secondEntry = [self contextWithNext:child2 previous:child1]; + XCTAssertNil([delegate isFocusChanged:secondEntry]); + + XCTAssertEqualObjects([delegate getFocusingView], child2); +} + +- (void)test_focusLeave_reportsNo { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; + + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *leave = [self contextWithNext:outside previous:host]; + + XCTAssertEqualObjects([delegate isFocusChanged:leave], @NO); +} + +- (void)test_trackedTargetDeallocated_blurStillReported { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:YES]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + + __weak UIView *weakChild; + @autoreleasepool { + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [host addSubview:child]; + weakChild = child; + + [delegate isFocusChanged:[self contextWithNext:child previous:nil]]; + [child removeFromSuperview]; + } + XCTAssertNil(weakChild); + + UIView *other = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *afterDealloc = [self contextWithNext:outside previous:other]; + + XCTAssertEqualObjects([delegate isFocusChanged:afterDealloc], @NO); +} + +- (void)test_secondUnrelatedContext_afterBlur_returnsNil { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; + + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + XCTAssertEqualObjects([delegate isFocusChanged:[self contextWithNext:outside previous:host]], @NO); + + UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; + + XCTAssertNil([delegate isFocusChanged:unrelated]); +} + +- (void)test_reset_clearsTracking { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; + + [delegate reset]; + + UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; + + XCTAssertNil([delegate isFocusChanged:unrelated]); +} + +- (void)test_unrelatedContext_beforeAnyFocus_returnsNil { + RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; + RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + + UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; + + XCTAssertNil([delegate isFocusChanged:unrelated]); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm new file mode 100644 index 0000000..4c67922 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm @@ -0,0 +1,169 @@ +// +// RNCEKVFocusRequestBaseTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import +#import + +#import "RNCEKVExternalKeyboardView.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +@interface RNCEKVFocusRequestBaseTests : XCTestCase +@end + +@implementation RNCEKVFocusRequestBaseTests { + UIWindow *_window; + UIViewController *_rootController; +} + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); + _rootController = [UIViewController new]; + _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + _window.rootViewController = _rootController; + _window.hidden = NO; +} + +- (void)tearDown { + _window.hidden = YES; + _window = nil; + _rootController = nil; + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (RNCEKVExternalKeyboardView *)makeView { + return [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; +} + +- (void)test_focus_detached_parks_thenReplaysOnAttach { + RNCEKVExternalKeyboardView *view = [self makeView]; + + [view focus]; + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"a detached view has no reactViewController, so focus should park rather than route"); + + [_rootController.view addSubview:view]; + + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view, + @"didMoveToWindow should replay the parked focus request once attached"); +} + +- (void)test_attach_withoutPending_doesNotFocus { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = NO; + + [_rootController.view addSubview:view]; + RNCEKVDrainMainQueue(2); + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_cleanReferences_clearsPendingFocus { + RNCEKVExternalKeyboardView *view = [self makeView]; + + [view focus]; + [view cleanReferences]; + [_rootController.view addSubview:view]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"cleanReferences should clear the parked pending focus request before attach can replay it"); +} + +- (void)test_pendingReplay_singleShot_notOnReattach { + RNCEKVExternalKeyboardView *view = [self makeView]; + + [view focus]; + [_rootController.view addSubview:view]; + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view); + + RNCEKVResetRootCustomFocusView(); + [view removeFromSuperview]; + [_rootController.view addSubview:view]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"the parked focus request is single-shot and must not replay on a second attach"); +} + +- (void)test_autoFocus_attach_focusesAfterDoubleDispatch { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = YES; + + [_rootController.view addSubview:view]; + + RNCEKVDrainMainQueue(1); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"the inner dispatch_async is still queued after a single drain cycle"); + + RNCEKVDrainMainQueue(1); + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view, + @"focus should land only once both nested dispatch_async blocks have run"); +} + +- (void)test_autoFocus_generationBumped_staleDispatchDiscarded { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = YES; + + [_rootController.view addSubview:view]; + [view cleanReferences]; + + RNCEKVDrainMainQueue(2); + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"cleanReferences bumps the autofocus generation, so the already-dispatched request is stale"); +} + +- (void)test_autoFocus_detachedBeforeDispatch_doesNotFocus_andNoParkedGhost { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = YES; + + [_rootController.view addSubview:view]; + [view removeFromSuperview]; + + RNCEKVDrainMainQueue(2); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"the window guard should discard the dispatched autofocus while the view is detached"); + + [_rootController.view addSubview:view]; + RNCEKVDrainMainQueue(1); + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"nothing was parked while detached, so re-attaching must not focus the view"); +} + +- (void)test_autoFocus_viewDeallocatedBeforeDispatch_noCrash { + @autoreleasepool { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = YES; + + [_rootController.view addSubview:view]; + [view removeFromSuperview]; + } + + RNCEKVDrainMainQueue(2); + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_autoFocus_singleShot_noRescheduleOnReattach { + RNCEKVExternalKeyboardView *view = [self makeView]; + view.autoFocus = YES; + + [_rootController.view addSubview:view]; + RNCEKVDrainMainQueue(2); + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view); + + RNCEKVResetRootCustomFocusView(); + [view removeFromSuperview]; + [_rootController.view addSubview:view]; + RNCEKVDrainMainQueue(2); + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"_autoFocusRequested is a single-shot latch; re-attaching without cleanReferences must not reschedule"); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm new file mode 100644 index 0000000..0b5d65d --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm @@ -0,0 +1,285 @@ +// +// RNCEKVFocusSequenceDelegateTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import +#import + +#import "RNCEKVFocusSequenceDelegate.h" +#import "RNCEKVOrderLinking.h" +#import "RNCEKVOrderRelationship.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +#pragma mark - RNCEKVSequenceDelegateSpy + +// Overrides the two focus-routing exit points without calling super, so a +// test can assert which view a navigation decision targeted without driving +// RNCEKVKeyboardFocusService or the real UIKit focus engine. +@interface RNCEKVSequenceDelegateSpy : RNCEKVFocusSequenceDelegate + +@property (nonatomic, strong) UIView *keyboardedFocusTarget; +@property (nonatomic, strong) UIView *defaultFocusTarget; +@property (nonatomic, assign) NSUInteger keyboardedFocusCallCount; +@property (nonatomic, assign) NSUInteger defaultFocusCallCount; + +@end + +@implementation RNCEKVSequenceDelegateSpy + +- (void)keyboardedViewFocus:(UIView *)view { + _keyboardedFocusTarget = view; + _keyboardedFocusCallCount += 1; +} + +- (void)defaultViewFocus:(UIView *)view { + _defaultFocusTarget = view; + _defaultFocusCallCount += 1; +} + +@end + +#pragma mark - RNCEKVFocusSequenceDelegateTests + +@interface RNCEKVFocusSequenceDelegateTests : XCTestCase +@end + +@implementation RNCEKVFocusSequenceDelegateTests { + NSMutableArray *_registrations; + NSMutableArray *_windows; +} + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); + _registrations = [NSMutableArray array]; + _windows = [NSMutableArray array]; +} + +- (void)tearDown { + for (NSArray *registration in _registrations) { + [[RNCEKVOrderLinking sharedInstance] remove:registration[0] withOrderKey:registration[1]]; + } + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +#pragma mark - Helpers + +- (NSString *)uniqueOrderGroup { + return [NSUUID UUID].UUIDString; +} + +- (RNCEKVOrderHostDouble *)hostWithGroup:(NSString *)group position:(NSNumber *)position { + RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; + host.orderGroup = group; + host.orderPosition = position; + return host; +} + +- (RNCEKVFocusableItemDouble *)registerItemAtPosition:(NSNumber *)position group:(NSString *)group { + RNCEKVFocusableItemDouble *item = [[RNCEKVFocusableItemDouble alloc] initWithFrame:CGRectZero]; + [[RNCEKVOrderLinking sharedInstance] add:position withOrderKey:group withObject:item]; + [_registrations addObject:@[ position, group ]]; + return item; +} + +- (UIView *)viewInWindow { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; + [window addSubview:view]; + [_windows addObject:window]; + return view; +} + +// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*) — see +// RNCEKVTestFocusContext's header comment for why the double isn't a real +// UIFocusUpdateContext subclass and needs the cast below. +- (UIFocusUpdateContext *)contextWithPrevious:(id)previous + next:(id)next + heading:(UIFocusHeading)heading { + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.previouslyFocusedItem = previous; + context.nextFocusedItem = next; + context.focusHeading = heading; + return (UIFocusUpdateContext *)context; +} + +#pragma mark - handleNextFocus: entry / boundary / middle / no-exit + +- (void)test_entryView_next_focusesFirstItem_returnsHandled { + NSString *group = [self uniqueOrderGroup]; + RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; + UIView *entry = [self viewInWindow]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + relationship.entry = entry; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + BOOL handled = [spy handleNextFocus:entry currentIndex:-1 orderRelationship:relationship]; + + XCTAssertTrue(handled); + XCTAssertEqualObjects(spy.keyboardedFocusTarget, item0); + + NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:entry + next:nil + heading:UIFocusHeadingNext]]; + + XCTAssertEqualObjects(result, @0); +} + +- (void)test_lastItem_next_withExit_routesToExit { + NSString *group = [self uniqueOrderGroup]; + [self registerItemAtPosition:@0 group:group]; + RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; + UIView *exit = [self viewInWindow]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + relationship.exit = exit; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + BOOL handled = [spy handleNextFocus:item1 currentIndex:1 orderRelationship:relationship]; + + XCTAssertTrue(handled); + XCTAssertEqualObjects(spy.defaultFocusTarget, exit); + XCTAssertNil(spy.keyboardedFocusTarget); +} + +- (void)test_middleItem_next_focusesNextItem { + NSString *group = [self uniqueOrderGroup]; + RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; + RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; + [self registerItemAtPosition:@2 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + BOOL handled = [spy handleNextFocus:item0 currentIndex:0 orderRelationship:relationship]; + + XCTAssertTrue(handled); + XCTAssertEqualObjects(spy.keyboardedFocusTarget, item1); +} + +- (void)test_lastItem_next_withoutExit_returnsNO_noFocus { + NSString *group = [self uniqueOrderGroup]; + [self registerItemAtPosition:@0 group:group]; + RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + BOOL handled = [spy handleNextFocus:item1 currentIndex:1 orderRelationship:relationship]; + + XCTAssertFalse(handled); + XCTAssertEqual(spy.keyboardedFocusCallCount, (NSUInteger)0); + XCTAssertEqual(spy.defaultFocusCallCount, (NSUInteger)0); +} + +#pragma mark - shouldUpdateFocusInContext: stale entry/exit revalidation + +- (void)test_staleEntry_windowless_clearedAndRecaptured { + NSString *group = [self uniqueOrderGroup]; + [self registerItemAtPosition:@0 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + UIView *staleEntry = [[UIView alloc] initWithFrame:CGRectZero]; + relationship.entry = staleEntry; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + UIView *outsideC = [[UIView alloc] initWithFrame:CGRectZero]; + [spy shouldUpdateFocusInContext:[self contextWithPrevious:outsideC next:nil heading:UIFocusHeadingNext]]; + + XCTAssertEqualObjects(relationship.entry, outsideC); +} + +- (void)test_staleExit_windowless_cleared { + NSString *group = [self uniqueOrderGroup]; + RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + UIView *staleExit = [[UIView alloc] initWithFrame:CGRectZero]; + relationship.exit = staleExit; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + UIView *outsideD = [[UIView alloc] initWithFrame:CGRectZero]; + [spy shouldUpdateFocusInContext:[self contextWithPrevious:item0 next:outsideD heading:UIFocusHeadingNext]]; + + XCTAssertEqualObjects(relationship.exit, outsideD); +} + +- (void)test_liveEntry_inWindow_notCleared_notOverwritten { + NSString *group = [self uniqueOrderGroup]; + [self registerItemAtPosition:@0 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + UIView *liveEntry = [self viewInWindow]; + relationship.entry = liveEntry; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + UIView *outsideB = [[UIView alloc] initWithFrame:CGRectZero]; + [spy shouldUpdateFocusInContext:[self contextWithPrevious:outsideB next:nil heading:UIFocusHeadingNext]]; + + XCTAssertEqualObjects(relationship.entry, liveEntry); +} + +#pragma mark - defaultViewFocus: real routing + +- (void)test_defaultViewFocus_routesThroughService_toRootController { + NSString *group = [self uniqueOrderGroup]; + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVFocusSequenceDelegate *delegate = [[RNCEKVFocusSequenceDelegate alloc] initWithView:host]; + + UIView *target = [[UIView alloc] initWithFrame:CGRectZero]; + [delegate defaultViewFocus:target]; + + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, target); +} + +#pragma mark - shouldUpdateFocusInContext: previous heading and empty group + +- (void)test_previousHeading_middleItem_focusesPreviousItem_handled { + NSString *group = [self uniqueOrderGroup]; + RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; + RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; + + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:item1 + next:nil + heading:UIFocusHeadingPrevious]]; + + XCTAssertEqualObjects(result, @0); + XCTAssertEqualObjects(spy.keyboardedFocusTarget, item0); +} + +- (void)test_emptyGroup_returnsDefault { + NSString *group = [self uniqueOrderGroup]; + RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; + RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; + + UIView *previous = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *next = [[UIView alloc] initWithFrame:CGRectZero]; + NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:previous + next:next + heading:UIFocusHeadingNext]]; + + XCTAssertNil(result); + XCTAssertNil([[RNCEKVOrderLinking sharedInstance] getInfo:group]); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm new file mode 100644 index 0000000..509d6f6 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm @@ -0,0 +1,65 @@ +// +// RNCEKVKeyboardFocusServiceTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import + +#import "RNCEKVKeyboardFocusService.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +@interface RNCEKVKeyboardFocusServiceTests : XCTestCase +@end + +@implementation RNCEKVKeyboardFocusServiceTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (void)test_focusNil_preservesExistingCustomFocusView { + UIViewController *rootController = RCTKeyWindow().rootViewController; + XCTAssertNotNil(rootController); + + UIView *existingFocusView = [UIView new]; + rootController.rncekvCustomFocusView = existingFocusView; + UIViewController *fallbackController = [UIViewController new]; + + [RNCEKVKeyboardFocusService focus:nil withFallback:fallbackController]; + + XCTAssertEqual(rootController.rncekvCustomFocusView, existingFocusView); +} + +- (void)test_focus_prefersKeyWindowRoot_overFallback { + UIViewController *rootController = RCTKeyWindow().rootViewController; + XCTAssertNotNil(rootController); + + UIViewController *fallbackController = [UIViewController new]; + UIView *focusTarget = [UIView new]; + + [RNCEKVKeyboardFocusService focus:focusTarget withFallback:fallbackController]; + + XCTAssertEqual(rootController.rncekvCustomFocusView, focusTarget); + XCTAssertNil(fallbackController.rncekvCustomFocusView); +} + +- (void)test_focusWrapper_delegatesToFallbackVariant { + UIViewController *rootController = RCTKeyWindow().rootViewController; + XCTAssertNotNil(rootController); + + UIView *focusTarget = [UIView new]; + + [RNCEKVKeyboardFocusService focus:focusTarget]; + + XCTAssertEqual(rootController.rncekvCustomFocusView, focusTarget); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm new file mode 100644 index 0000000..125d522 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm @@ -0,0 +1,288 @@ +// +// RNCEKVLockViewTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import +#import + +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVExternalKeyboardLockView.h" +#import "RNCEKVTestSupport.h" + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +#pragma mark - RNCEKVLockViewSpy + +// Overrides the transition-gated focus requests without calling super, so +// setForceLock:/setLockDisabled: gating (becoming active, staying active, +// re-activating) can be asserted in isolation from what a real request +// would do (route through RNCEKVKeyboardFocusService, post an +// accessibility notification). +@interface RNCEKVLockViewSpy : RNCEKVExternalKeyboardLockView + +@property (nonatomic, assign) NSUInteger requestFocusCount; +@property (nonatomic, assign) NSUInteger requestScreenReaderFocusCount; + +@end + +@implementation RNCEKVLockViewSpy + +- (void)requestFocus { + self.requestFocusCount += 1; +} + +- (void)requestScreenReaderFocus { + self.requestScreenReaderFocusCount += 1; +} + +@end + +#pragma mark - RNCEKVLockViewPropsSpy + +// Counts setForceLock:/setLockDisabled: invocations while still calling +// super, so updateProps:oldProps: can be asserted to invoke the setter +// only when the incoming Fabric prop differs from the current ivar. +@interface RNCEKVLockViewPropsSpy : RNCEKVExternalKeyboardLockView + +@property (nonatomic, assign) NSUInteger forceLockSetterCount; +@property (nonatomic, assign) NSUInteger lockDisabledSetterCount; + +@end + +@implementation RNCEKVLockViewPropsSpy + +- (void)setForceLock:(BOOL)forceLock { + self.forceLockSetterCount += 1; + [super setForceLock:forceLock]; +} + +- (void)setLockDisabled:(BOOL)lockDisabled { + self.lockDisabledSetterCount += 1; + [super setLockDisabled:lockDisabled]; +} + +@end + +#pragma mark - Detached window helper + +// A UIWindow distinct from the host app's real key window, so a real +// (non-spy) lock view can resolve `reactViewController` without touching +// the app's actual view hierarchy. RCTKeyWindow() keeps returning the +// host app's window throughout, which is what the routing tests observe. +static UIWindow *RNCEKVMakeDetachedWindowWithRootViewController(void) { + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + window.rootViewController = [[UIViewController alloc] init]; + return window; +} + +#pragma mark - Tests + +@interface RNCEKVLockViewTests : XCTestCase +@end + +@implementation RNCEKVLockViewTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +#pragma mark setForceLock: / setLockDisabled: transition gating + +- (void)test_forceLock_offToOn_requestsFocusAndScreenReaderOnce { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + + lockView.forceLock = YES; + + XCTAssertEqual(lockView.requestFocusCount, 1u); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, 1u); +} + +- (void)test_forceLock_repeatedYES_noReRequest { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + + lockView.forceLock = YES; + lockView.forceLock = YES; + + XCTAssertEqual(lockView.requestFocusCount, 1u); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, 1u); +} + +- (void)test_forceLock_whileDisabled_noRequest { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + + lockView.lockDisabled = YES; + lockView.forceLock = YES; + + XCTAssertEqual(lockView.requestFocusCount, 0u); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, 0u); +} + +- (void)test_lockDisabled_liftedWhileForceLocked_reRequests { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + lockView.forceLock = YES; + lockView.lockDisabled = YES; + NSUInteger requestFocusCountBeforeLift = lockView.requestFocusCount; + NSUInteger requestScreenReaderFocusCountBeforeLift = lockView.requestScreenReaderFocusCount; + + lockView.lockDisabled = NO; + + XCTAssertEqual(lockView.requestFocusCount, requestFocusCountBeforeLift + 1); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, requestScreenReaderFocusCountBeforeLift + 1); +} + +- (void)test_lockDisabled_turnedOn_noRequest { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + lockView.forceLock = YES; + NSUInteger requestFocusCountAfterActivation = lockView.requestFocusCount; + NSUInteger requestScreenReaderFocusCountAfterActivation = lockView.requestScreenReaderFocusCount; + + lockView.lockDisabled = YES; + + XCTAssertEqual(lockView.requestFocusCount, requestFocusCountAfterActivation); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, requestScreenReaderFocusCountAfterActivation); +} + +#pragma mark requestFocus routing (real view) + +- (void)test_requestFocus_realView_routesThroughService_toKeyWindowRoot { + UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + [detachedWindow.rootViewController.view addSubview:lockView]; + + lockView.forceLock = YES; + + UIViewController *keyRootController = RCTKeyWindow().rootViewController; + XCTAssertNotNil(keyRootController); + XCTAssertEqual(keyRootController.rncekvCustomFocusView, lockView); +} + +- (void)test_requestFocus_inactiveGate_noRouting { + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + lockView.lockDisabled = YES; + + UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + [detachedWindow.rootViewController.view addSubview:lockView]; + + [lockView requestFocus]; + + UIViewController *keyRootController = RCTKeyWindow().rootViewController; + XCTAssertNotNil(keyRootController); + XCTAssertNil(keyRootController.rncekvCustomFocusView); +} + +#pragma mark shouldUpdateFocusInContext: + +- (void)test_shouldUpdateFocus_forceLock_blocksMoveOutsideSubtree { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + lockView.forceLock = YES; + + UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = outsideView; + + // -shouldUpdateFocusInContext: is UIKit-declared with a _Nonnull-audited + // UIFocusUpdateContext parameter, so the double needs an explicit cast + // here (see RNCEKVTestFocusContext's header comment). + XCTAssertFalse([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); +} + +- (void)test_shouldUpdateFocus_noForceLock_allowsOutsideMove { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + + UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = outsideView; + + XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); +} + +- (void)test_shouldUpdateFocus_lockDisabled_bypassesLock { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + lockView.forceLock = YES; + lockView.lockDisabled = YES; + + UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = outsideView; + + XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); +} + +- (void)test_shouldUpdateFocus_insideMove_allowedUnderLock { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + lockView.forceLock = YES; + + UIView *childView = [[UIView alloc] initWithFrame:CGRectZero]; + [lockView addSubview:childView]; + + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = childView; + + XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); +} + +#pragma mark updateProps:oldProps: + +#ifdef RCT_NEW_ARCH_ENABLED + +- (void)test_updateProps_unchangedValues_settersNotInvoked { + RNCEKVLockViewPropsSpy *lockView = [[RNCEKVLockViewPropsSpy alloc] initWithFrame:CGRectZero]; + + auto changedProps = std::make_shared(); + changedProps->forceLock = true; + changedProps->lockDisabled = true; + facebook::react::Props::Shared newProps = changedProps; + facebook::react::Props::Shared oldProps = + std::make_shared(); + + [lockView updateProps:newProps oldProps:oldProps]; + + XCTAssertEqual(lockView.forceLockSetterCount, 1u); + XCTAssertEqual(lockView.lockDisabledSetterCount, 1u); + + auto sameProps = std::make_shared(); + sameProps->forceLock = true; + sameProps->lockDisabled = true; + facebook::react::Props::Shared repeatedProps = sameProps; + + [lockView updateProps:repeatedProps oldProps:newProps]; + + XCTAssertEqual(lockView.forceLockSetterCount, 1u); + XCTAssertEqual(lockView.lockDisabledSetterCount, 1u); +} + +#endif /* RCT_NEW_ARCH_ENABLED */ + +#pragma mark dealloc + +- (void)test_dealloc_removesNotificationObserver_noCrash { + __weak RNCEKVExternalKeyboardLockView *weakLockView; + @autoreleasepool { + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectZero]; + UIView *container = [[UIView alloc] initWithFrame:CGRectZero]; + [container addSubview:lockView]; + weakLockView = lockView; + } + XCTAssertNil(weakLockView); + + [[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityElementFocusedNotification + object:nil + userInfo:@{}]; + + XCTAssertNil(weakLockView, @"posting after dealloc must not resurrect or crash"); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm new file mode 100644 index 0000000..0ef211d --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm @@ -0,0 +1,101 @@ +// +// RNCEKVOrderGroupBaseTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import +#import + +#import "RNCEKVExternalKeyboardView.h" +#import "RNCEKVViewOrderGroupBase.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +@interface RNCEKVOrderGroupBaseTests : XCTestCase +@end + +@implementation RNCEKVOrderGroupBaseTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +/// Attaches `view` under a local (non-key) window's root controller so +/// `reactViewController` resolves without touching the host app's real key window. +- (UIWindow *)attachUnderLocalRootController:(UIView *)view { + UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIViewController *localController = [UIViewController new]; + localWindow.rootViewController = localController; + [localController.view addSubview:view]; + localWindow.hidden = NO; + return localWindow; +} + +- (void)test_getIsViewFocused_descendantNext_true { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *grandchild = [[UIView alloc] initWithFrame:CGRectZero]; + [view addSubview:child]; + [child addSubview:grandchild]; + + RNCEKVTestFocusContext *childContext = [RNCEKVTestFocusContext new]; + childContext.nextFocusedView = child; + XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)childContext]); + + RNCEKVTestFocusContext *grandchildContext = [RNCEKVTestFocusContext new]; + grandchildContext.nextFocusedView = grandchild; + XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)grandchildContext]); +} + +- (void)test_getIsViewFocused_outsideOrNilNext_false { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + + RNCEKVTestFocusContext *outsideContext = [RNCEKVTestFocusContext new]; + outsideContext.nextFocusedView = outside; + XCTAssertFalse([view getIsViewFocused:(UIFocusUpdateContext *)outsideContext]); + + RNCEKVTestFocusContext *nilContext = [RNCEKVTestFocusContext new]; + nilContext.nextFocusedView = nil; + XCTAssertFalse([view getIsViewFocused:(UIFocusUpdateContext *)nilContext]); +} + +// Both tests below target -[RNCEKVViewOrderGroupBase focus] directly (not +// -[RNCEKVViewFocusRequestBase focus], the version that "focus" resolves to +// on the concrete RNCEKVExternalKeyboardView chain, which routes self +// through the service instead of getStoredView) — a plain +// RNCEKVViewOrderGroupBase instance, rather than a leaf view further down +// the base chain, is required for Objective-C dynamic dispatch to reach +// this exact override. +- (void)test_focus_attached_routesStoredViewThroughService { + RNCEKVViewOrderGroupBase *view = [[RNCEKVViewOrderGroupBase alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [view addSubview:child]; + + UIWindow *localWindow = [self attachUnderLocalRootController:view]; + XCTAssertNotNil(localWindow.rootViewController, @"reactViewController resolution requires a live root controller"); + + [view focus]; + + XCTAssertEqualObjects([view getStoredView], child); + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, [view getStoredView]); +} + +- (void)test_focus_detached_noop { + RNCEKVViewOrderGroupBase *view = [[RNCEKVViewOrderGroupBase alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [view addSubview:child]; + + [view focus]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm new file mode 100644 index 0000000..7d02e14 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm @@ -0,0 +1,191 @@ +// +// RNCEKVRetainCycleTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import + +#import "RNCEKVExternalKeyboardView.h" +#import "RNCEKVTextInputFocusWrapper.h" +#import "RNCEKVExternalKeyboardLockView.h" +#import "RNCEKVFocusDelegate.h" +#import "RNCEKVFocusLinkDelegate.h" +#import "RNCEKVFocusSequenceDelegate.h" +#import "RNCEKVGroupIdentifierDelegate.h" +#import "RNCEKVGroupIdentifierProtocol.h" +#import "RNCEKVHaloDelegate.h" +#import "RNCEKVHaloProtocol.h" +#import "RNCEKVOrderRelationship.h" + +#import "RNCEKVTestSupport.h" + +#pragma mark - Host doubles outside RNCEKVTestSupport's coverage + +// RNCEKVTestSupport doubles RNCEKVFocusProtocol and RNCEKVFocusOrderProtocol hosts +// only. RNCEKVGroupIdentifierDelegate and RNCEKVHaloDelegate need hosts for their own +// protocols, so those two doubles are file-local per the test plan. + +@interface RNCEKVGroupIdHostDouble : UIView +@property (nonatomic, copy) NSString *customGroupId; +@end + +@implementation RNCEKVGroupIdHostDouble +- (UIView *)getFocusTargetView { + return self; +} +@end + +@interface RNCEKVHaloHostDouble : UIView +@property (nonatomic, assign) BOOL isHaloHidden; +@property (nonatomic, assign) CGFloat haloCornerRadius; +@property (nonatomic, assign) CGFloat haloExpendX; +@property (nonatomic, assign) CGFloat haloExpendY; +@property (nonatomic, assign) BOOL roundedHaloFix; +@end + +@implementation RNCEKVHaloHostDouble +- (UIView *)getFocusTargetView { + return self; +} +@end + +@interface RNCEKVRetainCycleTests : XCTestCase +@end + +@implementation RNCEKVRetainCycleTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (void)test_externalKeyboardView_deallocates_noDelegateCycle { + __weak RNCEKVExternalKeyboardView *weakView; + @autoreleasepool { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + weakView = view; + } + XCTAssertNil(weakView); +} + +- (void)test_textInputFocusWrapper_deallocates { + __weak RNCEKVTextInputFocusWrapper *weakWrapper; + @autoreleasepool { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + weakWrapper = wrapper; + } + XCTAssertNil(weakWrapper); +} + +- (void)test_eachDelegate_survivesHostDealloc_lateCallsSafe { + { + __weak RNCEKVFocusHostDouble *weakHost; + RNCEKVFocusDelegate *delegate; + @autoreleasepool { + RNCEKVFocusHostDouble *host = [[RNCEKVFocusHostDouble alloc] initWithFrame:CGRectZero]; + host.canBeFocused = YES; + host.focusableWrapper = NO; + weakHost = host; + delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; + } + XCTAssertNil(weakHost); + XCTAssertNoThrow([delegate getFocusingView]); + XCTAssertNil([delegate getFocusingView]); + XCTAssertNoThrow([delegate canBecomeFocused]); + XCTAssertFalse([delegate canBecomeFocused]); + } + + { + __weak RNCEKVOrderHostDouble *weakHost; + RNCEKVFocusSequenceDelegate *delegate; + @autoreleasepool { + RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; + weakHost = host; + delegate = [[RNCEKVFocusSequenceDelegate alloc] initWithView:host]; + } + XCTAssertNil(weakHost); + UIFocusUpdateContext *context = (UIFocusUpdateContext *)[RNCEKVTestFocusContext new]; + XCTAssertNoThrow([delegate shouldUpdateFocusInContext:context]); + XCTAssertNil([delegate shouldUpdateFocusInContext:context]); + } + + { + __weak RNCEKVOrderHostDouble *weakHost; + RNCEKVFocusLinkDelegate *delegate; + @autoreleasepool { + RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; + weakHost = host; + delegate = [[RNCEKVFocusLinkDelegate alloc] initWithView:host]; + } + XCTAssertNil(weakHost); + UIFocusUpdateContext *context = (UIFocusUpdateContext *)[RNCEKVTestFocusContext new]; + XCTAssertNoThrow([delegate shouldUpdateFocusInContext:context]); + XCTAssertNil([delegate shouldUpdateFocusInContext:context]); + } + + { + __weak RNCEKVGroupIdHostDouble *weakHost; + RNCEKVGroupIdentifierDelegate *delegate; + @autoreleasepool { + RNCEKVGroupIdHostDouble *host = [[RNCEKVGroupIdHostDouble alloc] initWithFrame:CGRectZero]; + weakHost = host; + delegate = [[RNCEKVGroupIdentifierDelegate alloc] initWithView:host]; + } + XCTAssertNil(weakHost); + NSString *identifier = nil; + XCTAssertNoThrow(identifier = delegate.focusGroupIdentifier); + XCTAssertNotNil(identifier); + } + + if (@available(iOS 15.0, *)) { + __weak RNCEKVHaloHostDouble *weakHost; + RNCEKVHaloDelegate *delegate; + @autoreleasepool { + RNCEKVHaloHostDouble *host = [[RNCEKVHaloHostDouble alloc] initWithFrame:CGRectZero]; + weakHost = host; + delegate = [[RNCEKVHaloDelegate alloc] initWithView:host]; + } + XCTAssertNil(weakHost); + UIFocusEffect *effect = nil; + XCTAssertNoThrow(effect = delegate.focusEffect); + XCTAssertNil(effect); + } +} + +- (void)test_orderRelationship_entryExit_zeroOnDealloc { + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderRelationship alloc] init]; + __weak UIView *weakEntry; + __weak UIView *weakExit; + @autoreleasepool { + UIView *entry = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *exit = [[UIView alloc] initWithFrame:CGRectZero]; + relationship.entry = entry; + relationship.exit = exit; + weakEntry = entry; + weakExit = exit; + } + XCTAssertNil(weakEntry); + XCTAssertNil(weakExit); + XCTAssertNil(relationship.entry); + XCTAssertNil(relationship.exit); +} + +- (void)test_lockView_deallocates { + __weak RNCEKVExternalKeyboardLockView *weakLockView; + @autoreleasepool { + RNCEKVExternalKeyboardLockView *lockView = [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectZero]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + [window addSubview:lockView]; + [lockView removeFromSuperview]; + weakLockView = lockView; + } + XCTAssertNil(weakLockView); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h new file mode 100644 index 0000000..e3422cb --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h @@ -0,0 +1,135 @@ +// +// RNCEKVTestSupport.h +// ExternalKeyboardExampleTests +// +// Shared test doubles, a settable UIFocusUpdateContext stand-in, a +// main-queue draining helper, and the no-implementation "Testing" +// category declarations that expose private library methods to the +// suite. Every other test file imports this header. +// + +#ifndef RNCEKVTestSupport_h +#define RNCEKVTestSupport_h + +#import + +#import "RNCEKVFocusProtocol.h" +#import "RNCEKVFocusOrderProtocol.h" +#import "RNCEKVKeyboardFocusableProtocol.h" +#import "RNCEKVFocusSequenceDelegate.h" +#import "RNCEKVOrderRelationship.h" +#import "RNCEKVViewOrderGroupBase.h" +#import "RNCEKVExternalKeyboardLockView.h" + +NS_ASSUME_NONNULL_BEGIN + +#pragma mark - Testing categories + +// RNCEKVFocusSequenceDelegate's index-navigation and focus-routing methods +// are internal to the .mm and absent from the public header. +@interface RNCEKVFocusSequenceDelegate (Testing) + +- (BOOL)handleNextFocus:(nullable UIView *)current + currentIndex:(NSInteger)currentIndex + orderRelationship:(RNCEKVOrderRelationship *)orderRelationship; + +- (BOOL)handlePrevFocus:(nullable UIView *)current + currentIndex:(NSInteger)currentIndex + orderRelationship:(RNCEKVOrderRelationship *)orderRelationship; + +- (void)defaultViewFocus:(UIView *)view; +- (void)keyboardedViewFocus:(UIView *)view; + +@end + +// RNCEKVViewOrderGroupBase's descendant-focus check is internal to the .mm +// and absent from the public header. +@interface RNCEKVViewOrderGroupBase (Testing) + +- (BOOL)getIsViewFocused:(UIFocusUpdateContext *)context; + +@end + +// RNCEKVExternalKeyboardLockView's focus-routing methods are both private +// and absent from the public header. +@interface RNCEKVExternalKeyboardLockView (Testing) + +- (void)requestFocus; +- (void)requestScreenReaderFocus; + +@end + +#pragma mark - RNCEKVTestFocusContext + +// A UIFocusUpdateContext stand-in exposing the same next/previous focused +// view/item and focus heading accessors UIKit's real context declares +// read-only, so a test can drive isFocusChanged:/shouldUpdateFocusInContext:/ +// getIsViewFocused: with an arbitrary next/previous pair instead of a live +// focus engine. +// +// This does NOT subclass UIFocusUpdateContext: UIFocusUpdateContext has no +// public initializer, and plain [[UIFocusUpdateContext alloc] init] (which +// is all NSObject's default -init gives a subclass) trips an internal +// consistency check ("Invalid parameter not satisfying: focusSystem") on +// current UIKit, so a subclass instance throws at construction time before +// any test body runs. Instead this is a plain NSObject double; callers pass +// it to library methods typed to take UIFocusUpdateContext* via an explicit +// cast — those methods only ever message the five accessors below, which +// this class implements, so dynamic dispatch resolves correctly despite the +// unrelated static type. +@interface RNCEKVTestFocusContext : NSObject + +@property (nonatomic, strong, nullable) UIView *nextFocusedView; +@property (nonatomic, strong, nullable) UIView *previouslyFocusedView; +@property (nonatomic, strong, nullable) id nextFocusedItem; +@property (nonatomic, strong, nullable) id previouslyFocusedItem; +@property (nonatomic, assign) UIFocusHeading focusHeading; + +@end + +#pragma mark - RNCEKVFocusHostDouble + +// Minimal RNCEKVFocusProtocol host for RNCEKVFocusDelegate tests. Both +// protocol methods are backed by a settable property of the same name. +@interface RNCEKVFocusHostDouble : UIView + +@property (nonatomic, assign) BOOL canBeFocused; +@property (nonatomic, assign) BOOL focusableWrapper; + +@end + +#pragma mark - RNCEKVOrderHostDouble + +// Minimal RNCEKVFocusOrderProtocol host for RNCEKVFocusSequenceDelegate / +// RNCEKVViewOrderGroupBase tests. Every order prop declared by the +// protocol is synthesized in the companion .mm; -getFocusTargetView +// returns the double itself. +@interface RNCEKVOrderHostDouble : UIView +@end + +#pragma mark - RNCEKVFocusableItemDouble + +// Records every -focus call it receives, so a test can assert which item +// a sequence/order delegate routed focus to. +@interface RNCEKVFocusableItemDouble : UIView + +@property (nonatomic, assign, readonly) NSUInteger focusCallCount; + +@end + +#pragma mark - Shared helpers + +// Spins the main run loop for `cycles` iterations: each cycle schedules a +// dispatch_async(main) block that fulfills an XCTestExpectation and waits +// on it (5s timeout), draining already-queued main-thread blocks — e.g. +// focusOnMount's nested dispatch_async — in FIFO order without a sleep. +FOUNDATION_EXPORT void RNCEKVDrainMainQueue(NSUInteger cycles); + +// Clears the key window's root view controller's rncekvCustomFocusView. +// Every test class calls this in both setUp and tearDown so tests observe +// a known starting state and don't leak focus state into the next test. +FOUNDATION_EXPORT void RNCEKVResetRootCustomFocusView(void); + +NS_ASSUME_NONNULL_END + +#endif /* RNCEKVTestSupport_h */ diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm new file mode 100644 index 0000000..5df1313 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm @@ -0,0 +1,81 @@ +// +// RNCEKVTestSupport.mm +// ExternalKeyboardExampleTests +// + +#import "RNCEKVTestSupport.h" + +#import +#import +#import "UIViewController+RNCEKVExternalKeyboard.h" + +#pragma mark - RNCEKVTestFocusContext + +@implementation RNCEKVTestFocusContext + +// No custom -init: this is a plain NSObject double (see the header comment +// for why it does not subclass UIFocusUpdateContext), so the inherited +// NSObject default is sufficient; the suite constructs it with plain +// [RNCEKVTestFocusContext new]. +@synthesize nextFocusedView = _nextFocusedView; +@synthesize previouslyFocusedView = _previouslyFocusedView; +@synthesize nextFocusedItem = _nextFocusedItem; +@synthesize previouslyFocusedItem = _previouslyFocusedItem; +@synthesize focusHeading = _focusHeading; + +@end + +#pragma mark - RNCEKVFocusHostDouble + +@implementation RNCEKVFocusHostDouble +@end + +#pragma mark - RNCEKVOrderHostDouble + +@implementation RNCEKVOrderHostDouble + +@synthesize orderGroup = _orderGroup; +@synthesize lockFocus = _lockFocus; +@synthesize orderPosition = _orderPosition; +@synthesize orderLeft = _orderLeft; +@synthesize orderRight = _orderRight; +@synthesize orderUp = _orderUp; +@synthesize orderDown = _orderDown; +@synthesize orderForward = _orderForward; +@synthesize orderBackward = _orderBackward; +@synthesize orderLast = _orderLast; +@synthesize orderFirst = _orderFirst; +@synthesize orderId = _orderId; + +- (UIView *)getFocusTargetView { + return self; +} + +@end + +#pragma mark - RNCEKVFocusableItemDouble + +@implementation RNCEKVFocusableItemDouble + +- (void)focus { + _focusCallCount += 1; +} + +@end + +#pragma mark - Shared helpers + +void RNCEKVDrainMainQueue(NSUInteger cycles) { + for (NSUInteger cycle = 0; cycle < cycles; cycle++) { + XCTestExpectation *expectation = + [[XCTestExpectation alloc] initWithDescription:@"RNCEKVDrainMainQueue"]; + dispatch_async(dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [XCTWaiter waitForExpectations:@[ expectation ] timeout:5.0]; + } +} + +void RNCEKVResetRootCustomFocusView(void) { + RCTKeyWindow().rootViewController.rncekvCustomFocusView = nil; +} diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm new file mode 100644 index 0000000..81b4400 --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm @@ -0,0 +1,121 @@ +// +// RNCEKVTextInputFocusWrapperTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import +#import + +#import "RNCEKVTextInputFocusWrapper.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +// -updateFocus: is internal to RNCEKVTextInputFocusWrapper.mm and absent from +// the public header; exposed here for the single test that drives it directly. +@interface RNCEKVTextInputFocusWrapper (Testing) +- (void)updateFocus:(UIViewController *)controller; +@end + +@interface RNCEKVTextInputFocusWrapperTests : XCTestCase +@end + +@implementation RNCEKVTextInputFocusWrapperTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +/// Attaches `view` under a local (non-key) window's root controller so +/// `reactViewController` resolves without touching the host app's real key window. +- (UIWindow *)attachUnderLocalRootController:(UIView *)view { + UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIViewController *localController = [UIViewController new]; + localWindow.rootViewController = localController; + [localController.view addSubview:view]; + localWindow.hidden = NO; + return localWindow; +} + +- (void)test_focus_detached_parks { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + + [wrapper focus]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_didMoveToWindow_replaysPendingFocus_toFirstSubview { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + [wrapper focus]; + + UIView *first = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *second = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:first]; + [wrapper addSubview:second]; + + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + XCTAssertNotNil(localWindow.rootViewController, @"reactViewController resolution requires a live root controller"); + + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, first); +} + +- (void)test_replay_singleShot { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + [wrapper focus]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, child, + @"replay must have run once before the single-shot leg is exercised"); + + RNCEKVResetRootCustomFocusView(); + [wrapper removeFromSuperview]; + [localWindow.rootViewController.view addSubview:wrapper]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_cleanReferences_clearsPending { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + [wrapper focus]; + + [wrapper cleanReferences]; + + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + [self attachUnderLocalRootController:wrapper]; + + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_updateFocus_noSubviews_serviceNilGuard_noop { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + + UIView *preExistingFocusView = [UIView new]; + RCTKeyWindow().rootViewController.rncekvCustomFocusView = preExistingFocusView; + + [wrapper updateFocus:localWindow.rootViewController]; + + XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, preExistingFocusView); +} + +- (void)test_newArch_onFocusChange_gate_noCrashBothWays { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + + wrapper.hasOnFocusChanged = NO; + XCTAssertNoThrow([wrapper onFocusChangeHandler:YES]); + + wrapper.hasOnFocusChanged = YES; + XCTAssertNoThrow([wrapper onFocusChangeHandler:NO]); +} + +@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm new file mode 100644 index 0000000..da2146f --- /dev/null +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm @@ -0,0 +1,100 @@ +// +// RNCEKVViewControllerExtensionTests.mm +// ExternalKeyboardExampleTests +// + +#import +#import + +#import "UIViewController+RNCEKVExternalKeyboard.h" +#import "RNCEKVTestSupport.h" + +@interface RNCEKVViewControllerExtensionTests : XCTestCase +@end + +@implementation RNCEKVViewControllerExtensionTests + +- (void)setUp { + [super setUp]; + RNCEKVResetRootCustomFocusView(); +} + +- (void)tearDown { + RNCEKVResetRootCustomFocusView(); + [super tearDown]; +} + +- (void)test_customFocusView_setGet_roundtrip { + UIViewController *vc = [UIViewController new]; + UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; + + vc.rncekvCustomFocusView = view; + XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); + + vc.rncekvCustomFocusView = nil; + XCTAssertNil(vc.rncekvCustomFocusView); +} + +- (void)test_customFocusView_notRetained_zeroesAfterDealloc { + UIViewController *vc = [UIViewController new]; + + __weak UIView *weakView; + @autoreleasepool { + UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; + weakView = view; + vc.rncekvCustomFocusView = view; + XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); + } + + XCTAssertNil(weakView); + XCTAssertNil(vc.rncekvCustomFocusView); +} + +- (void)test_preferredFocusEnvironments_noCustomView_passthrough { + UIViewController *vc = [UIViewController new]; + + NSArray> *first = vc.preferredFocusEnvironments; + NSArray> *second = vc.preferredFocusEnvironments; + + XCTAssertEqualObjects(first, second); + XCTAssertNil(vc.rncekvCustomFocusView); +} + +- (void)test_preferredFocusEnvironments_viewInWindow_insertedFirst { + UIViewController *vc = [UIViewController new]; + NSArray> *originalEnvironments = vc.preferredFocusEnvironments; + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; + [window addSubview:view]; + vc.rncekvCustomFocusView = view; + + NSArray> *result = vc.preferredFocusEnvironments; + + XCTAssertEqualObjects(result.firstObject, view); + XCTAssertEqualObjects([result subarrayWithRange:NSMakeRange(1, result.count - 1)], originalEnvironments); +} + +- (void)test_preferredFocusEnvironments_windowlessView_clearedAndPassthrough { + UIViewController *vc = [UIViewController new]; + UIView *detachedView = [[UIView alloc] initWithFrame:CGRectZero]; + vc.rncekvCustomFocusView = detachedView; + + NSArray> *result = vc.preferredFocusEnvironments; + + XCTAssertFalse([result containsObject:detachedView]); + XCTAssertNil(vc.rncekvCustomFocusView); +} + +- (void)test_rncekvFocusView_setsHolderSynchronously_schedulesFocusUpdate { + UIViewController *vc = [UIViewController new]; + UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; + + [vc rncekvFocusView:view]; + + XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); + + RNCEKVDrainMainQueue(1); +} + +@end diff --git a/example/ios/Podfile b/example/ios/Podfile index adefdd7..3721e61 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -25,6 +25,10 @@ target 'ExternalKeyboardExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + target 'ExternalKeyboardExampleTests' do + inherit! :search_paths + end + post_install do |installer| react_native_post_install( installer, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5b6ac12..4ef0b47 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1892,7 +1892,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-external-keyboard (1.0.0-beta.2): + - react-native-external-keyboard (1.1.0): - boost - DoubleConversion - fast_float @@ -2915,83 +2915,83 @@ SPEC CHECKSUMS: FBLazyVector: 82d1d7996af4c5850242966eb81e73f9a6dfab1e fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: ee62a2e033aea92a25a072d5964fcf22d52bea88 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + hermes-engine: 070be10ed4a7d129af6a8b353d192288f33e6778 + RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f RCTDeprecation: 9da1d0cf93db23ca8b41e8efe9ae558fd9c0077f RCTRequired: 92a63c7041031a131fa5206eb082d53f95729b79 RCTSwiftUI: 395b65655229fa2006415207adcfcb6e35dc78ed - RCTSwiftUIWrapper: 91351441a592e07e09a2f94d2cbdf088fde7e2e1 + RCTSwiftUIWrapper: 0bef3bf5c2d757c95ab295bce340252cbd8b78db RCTTypeSafety: 091ec3b2994c00939652cbe91cfa9ee8a4ae75b5 React: 3e14066ac707b3e369d09e2e923d8bee7f8c33ff React-callinvoker: 2d95e8e26fbab01f06fbf006d2c370f834a3537b - React-Core: 0e73cf940736e6d32683d1b9e427ca9e92f96e5a - React-CoreModules: a252c33b178381722498afe5fa475bb110cc2943 - React-cxxreact: 271c58e22ece5be60e9a6ee7d3d40474028833fc + React-Core: ec627cd25596e357550c6c1aecdd0a8ed6133511 + React-CoreModules: 3f00acadb1d5521469682279f8158e7f7a3a62a9 + React-cxxreact: a221d0dfbba5a7f2379e6b4dd66d71e8ab0b63ca React-debug: 0081691903fcdbaa533500f83d358f1f3dbf6052 - React-defaultsnativemodule: f7e7dafd3f5ebd8733ef0bb2f9b61bb0415136f6 - React-domnativemodule: 77e61307cd9ba1e2fa0f480d70db9bb8f1a79d52 - React-Fabric: 8b4d1e26350ff7eaae4ee81a90e8c936123e2018 - React-FabricComponents: 4e8a2981969664f6655e2532e52d225881c8929c - React-FabricImage: f57463e90686da3ba74339091e327bd99816175b - React-featureflags: be8c8414da416342a8cedb0a6b7512e7973f85ac - React-featureflagsnativemodule: d5b573eee59a8de006a948d3766b6a38f6d085b8 - React-graphics: 774bd8afdf9d8ef70faecddbffb53dce2ea7e5b5 - React-hermes: 26feaea19d95e73a794d6f84cfcbce63f85cb9ec - React-idlecallbacksnativemodule: 6ec2446be4a579d5aaed1af31519b679fc076329 - React-ImageManager: 1de64915c16b058d9e635c98cf5d786454ca48cf - React-intersectionobservernativemodule: 3e91ec7069afe60d5dace2e2960f7fd7abeb2f64 - React-jserrorhandler: 458ca75c0df7c8dd046a3c74c0dec719fd0aa863 - React-jsi: 8442310fcae4f17ed2c2df00cc8a53fb479bef1b - React-jsiexecutor: e73fa2e25be645f8f98f00893adcf24e449de8ce - React-jsinspector: 5f756f86c8263f3e0e462f4b12b8da3b677686a4 - React-jsinspectorcdp: d6bcfdb732d99f6240e3ed6b82da58f7391a4ce2 - React-jsinspectornetwork: 9e2a9df177614e7e4a058c37ae2d7cefe59a7d8d - React-jsinspectortracing: 106ef2423c9c90c88d01f7e9b86cc86668d06bb4 - React-jsitooling: 5c7a6e98c27452fa0043c112ae53a7b499d08d30 - React-jsitracing: d68eea24f3feea58726ae44fab02d571b9011f36 - React-logger: 993e4b9793768764e0fdd379ad1d6582f7905463 - React-Mapbuffer: 3a5f700ed673820ab4b1b35ba0cf8476400bc4c5 - React-microtasksnativemodule: 094677e625f12276a8f871844a5ee6a945a90221 - react-native-external-keyboard: 5a0ffde5b88e9df0c32ca81ac51c369f938e3676 - react-native-safe-area-context: befb5404eb8a16fdc07fa2bebab3568ecabcbb8a - React-NativeModulesApple: 29290351acc118784e158aa7b23c42719dc57617 - React-networking: d01f94f15d1a6fce689a8c57d2397a5a40b0b5aa + React-defaultsnativemodule: 9f25b85274d9ea93beac442bf674f908969643cc + React-domnativemodule: 269edbf850b8d63243254e7e7e77181aa1622e18 + React-Fabric: 0f1b938e204322012d74f7b5acfe0fbf7e461551 + React-FabricComponents: e7887e24f53b016d3a677c3666f43ee9e7bb7f1d + React-FabricImage: c1bf10a24d67f06075e889d00eecc09518d236e6 + React-featureflags: d627d51b3ed1422cef102999fbb538c330fff217 + React-featureflagsnativemodule: f70983c8e3115f41994b26147c1bcc204d0452a4 + React-graphics: 4f3594197ef5f74d4090068c9789e0dfb11304c4 + React-hermes: 0d7350bea2662e7971d67fabef3511210bc10228 + React-idlecallbacksnativemodule: 78cfc6e6b33485d08a2cebb219742ded453d2f43 + React-ImageManager: 798a0140733dabf8d525b6fb094d7e596ed252f7 + React-intersectionobservernativemodule: 05bb55b5a8c53b56f0bf189c1f12f59e6665b5a0 + React-jserrorhandler: 6a1dbf8148dba195f51c79d9122550e1ab5a2b38 + React-jsi: a6f3e6a263e595d3e26dbeb0fc7efc3b04c8f207 + React-jsiexecutor: 2181494c9e4033feb6beb1886d47ccdf2bd04dd6 + React-jsinspector: 4b1a068673423943397f784f1868f3dda2f4728e + React-jsinspectorcdp: 17d408897b0a350205ef4bc4add5e5ae3bbac33a + React-jsinspectornetwork: 2c01f6a6264fdf91a6109277c0594c0994428484 + React-jsinspectortracing: c2e0ba315133d6b7037cd27d7ca768bead432b7c + React-jsitooling: 02024b1e482ff51d4eecd2289bb539ed24deb305 + React-jsitracing: 66975e51708f79678b7805e28f5de8a354535ddc + React-logger: 2a182a9d48eea1bc58834649d4b8436994e179ad + React-Mapbuffer: 486b7ebf69aa5cd9c2f0d4232d78ed8190e14004 + React-microtasksnativemodule: 6550ec51ff7ed24fe58830e5ef5d09629cf086c5 + react-native-external-keyboard: fa94237f46bec6ac415b605e77e825ea93faca28 + react-native-safe-area-context: 0f4986a88ec555aff660503b483d6e4bd6980a9a + React-NativeModulesApple: fd9c17d032baa5376f22615d3819f212a31d6386 + React-networking: 11c7a1a9830d4493ae07094a1460e8fc68793c6b React-oscompat: 854967d380ee2921c848790cdb942b42d22017d8 - React-perflogger: bb302310d56078ced79111225a74815465b5c9f9 - React-performancecdpmetrics: 7e14712c518d27e6f211040093f33d34eccc0361 - React-performancetimeline: 5a370c3e1370a80947806e67796683bc27477200 + React-perflogger: faa87892131b1712062b64b9f30100ab833a326c + React-performancecdpmetrics: e20e83d38700b3e8c3f8f64657adf13908528974 + React-performancetimeline: 7109ab7e26870fa42488e71a2c15156f7e0dd462 React-RCTActionSheet: 1182e251a2f93857ab7a4a13732c881449cc225f - React-RCTAnimation: 7fff267277af4af4abcec3b7d8dc4e3956aaf414 - React-RCTAppDelegate: 5e0010863f9a433d724f0811c9a4518a96cec535 - React-RCTBlob: 44ada012ff2dfa9a88f979d9631808138356b1f4 - React-RCTFabric: 23df68c60fd3af1a7dc893ab68f76d353fe51568 - React-RCTFBReactNativeSpec: 7f16922a8ce55cfb31a0ff161e212cd655cf68ee - React-RCTImage: e02f7772bbd165ef13c0051de1b9da6baefd11e6 - React-RCTLinking: 68ffd8feb4f0ea6fe3f10a264568901e17a7575c - React-RCTNetwork: 2f99990cb2ada2f2409b83174a96e6b901d254f8 - React-RCTRuntime: cc1ea7dc30d1e69ef2e6728e16e66dce9b65fabb - React-RCTSettings: a0ccf26bdca389ee6f6d897bf208293f86234814 - React-RCTText: d24b35c913a17b68b6207b0211967587e5c64c81 - React-RCTVibration: 3ab7eb971e4fa0774a3e0a376f3ea14dc6c7f963 + React-RCTAnimation: 3c764b70693dd89e1bde74e96ab590f177c6009d + React-RCTAppDelegate: 01cdef423260048457165341e419b794fe92780a + React-RCTBlob: ff7a3fc166d8d928d6ca4f9005f79ac77a246f39 + React-RCTFabric: a784e4764a205c0a055c9e79b885f3ea8bc5246a + React-RCTFBReactNativeSpec: b7a176dbd9973048ed0e6fd4804b517e5ef4775e + React-RCTImage: 279514ec0dd6d58e86a93e0df41f71b34a5e22e5 + React-RCTLinking: 9d5d986fdddfdd209dc9803e2b319c9b59ee9ff2 + React-RCTNetwork: 95e428f78dbf156beee8f346e32ca00feb28bf60 + React-RCTRuntime: 3e7c5aeb03e6a101d1b925fe09e0031f15e496f0 + React-RCTSettings: b852c96a4fd297275e38c68beba38ac04fe56289 + React-RCTText: 930121a255447eb15ed20cd65a0681ddd004d82e + React-RCTVibration: 056fb120c5308a329c1413bd09be79eb2a5862e2 React-rendererconsistency: 5a51c5d21f0131a9461c7a76809f96057c7f6a21 - React-renderercss: 9d27964853430a8823d448be4b1579f99714c8ed - React-rendererdebug: 1537ac6507182a3c9277922528e280b07181644f - React-RuntimeApple: 7a1f5c9fcfea8c7640e0c7e2893b30b2de117d3c - React-RuntimeCore: ac6333333f8cf86a3373ddc84611c3716ca8e1e9 - React-runtimeexecutor: 000669b14a58e1fe8a816e7057c6f24f58d514ad - React-RuntimeHermes: 2cfc0d3621dbe0674cce922e23aec31e02d3c809 - React-runtimescheduler: f232a0ed6911f641117933dd0ad4660b0cef5a04 - React-timing: 2b03ad9baf91c453e1ef28c37c8ec8bc1e8edc55 - React-utils: 2867547ccbc03b50de3ed04f1d9ca23efcf8651a - React-webperformancenativemodule: 39b4be54aa0174429654e84570dd7d4704da9def - ReactAppDependencyProvider: 2b19d66e5ddfe8dc7afb6338a4626156cbf2bab1 - ReactCodegen: 53a01767e04b3a23f128b5a3c6542b4ab24fb921 - ReactCommon: 5901ef412ae35cc727b9584d4f7e3e1f7f17c251 - RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 - RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f + React-renderercss: b7248f9b8ae48c3720ed9f2f0c98def7839c255c + React-rendererdebug: f5b25114a932d4ffcbb4bd2cd023b456b94170a1 + React-RuntimeApple: 443347984c005bde55f621ea03b697ddfe46269e + React-RuntimeCore: b4fb2cc929c81d7ed31c582ab6da2a48c5768934 + React-runtimeexecutor: 99fc61809f5ae8b42eebaf87a60b3df4277581ef + React-RuntimeHermes: d24cf1d92d22c426239ccf7b3eb0224f788534a7 + React-runtimescheduler: db87ef3574c2ba59be392a03d0e4372817c898bd + React-timing: caf22a459eafeba7c2d60bebefcfdfde587b61a4 + React-utils: 28aa8196560099cb5a4327df43310deabaca5e2f + React-webperformancenativemodule: 0f1b1eada5692af98b61002061dd1849bdda3cb0 + ReactAppDependencyProvider: c067d0b6558ad6ae392b96909de597a1b36f97e1 + ReactCodegen: 5f11b1e16f48dea0a9a858fe6834a6fbd0e93334 + ReactCommon: 0f7a365837f2dbec4342e458c1c1d3876683f492 + RNGestureHandler: 77eecab5fd636666ca73a55bb61e2f1a685b7e84 + RNScreens: 7179cc1ba31b4e18ed29f10abf20c24a7961cf4c SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: b669e79fa0f8d3f6f5e35372345f54b99e06b13c + Yoga: 19371ad8ad69b080bfe3bd28bb8ddf6aa0aa0eac -PODFILE CHECKSUM: 8d1304d1eddbaf9ff796c80223c1961a265745d7 +PODFILE CHECKSUM: b437bac6e1ba7c5722ddc6024410f8a07b920d46 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/example/ios/scripts/setup_unit_tests.rb b/example/ios/scripts/setup_unit_tests.rb new file mode 100644 index 0000000..ba48f9a --- /dev/null +++ b/example/ios/scripts/setup_unit_tests.rb @@ -0,0 +1,28 @@ +#!/usr/bin/env ruby +require 'xcodeproj' +project_path = File.expand_path('../ExternalKeyboardExample.xcodeproj', __dir__) +project = Xcodeproj::Project.open(project_path) +target = project.targets.find { |t| t.name == 'ExternalKeyboardExampleTests' } +abort('test target missing') unless target +group = project.main_group.find_subpath('ExternalKeyboardExampleTests', true) +group.set_source_tree('') +group.set_path('ExternalKeyboardExampleTests') +existing = target.source_build_phase.files_references.map(&:path).compact +Dir[File.expand_path('../ExternalKeyboardExampleTests/*.mm', __dir__)].sort.each do |f| + base = File.basename(f) + next if existing.include?(base) + ref = group.find_file_by_path(base) || group.new_reference(base) + target.add_file_references([ref]) +end +plist = group.find_file_by_path('Info.plist') || group.new_reference('Info.plist') +target.build_configurations.each do |config| + bs = config.build_settings + bs['PRODUCT_BUNDLE_IDENTIFIER'] = 'externalkeyboard.example.tests' + defs = Array(bs['GCC_PREPROCESSOR_DEFINITIONS'] || ['$(inherited)']) + defs << '$(inherited)' unless defs.include?('$(inherited)') + defs << 'RCT_NEW_ARCH_ENABLED=1' unless defs.include?('RCT_NEW_ARCH_ENABLED=1') + bs['GCC_PREPROCESSOR_DEFINITIONS'] = defs + bs['CLANG_ENABLE_MODULES'] = 'YES' +end +project.save +puts 'ExternalKeyboardExampleTests hydrated.' From 1bfc9c9e757e6055e54ff6532ff532a359a1670a Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Wed, 26 Aug 2026 13:36:56 +1000 Subject: [PATCH 5/9] fix: park focus requests until window attach and route via the target window root - Readiness for imperative keyboard focus is now window-based: the request base parks when the controller or window is missing, and the text-input wrapper additionally parks until its native child exists; both replay from didMoveToWindow. - RNCEKVKeyboardFocusService resolves the routing controller from the target view's own window root first, then the key-window root, then the supplied fallback, and returns the controller it routed to. - Detach and cleanReferences now clear the controller's preferred-focus target when it still points at the view's own request, so recycled or navigated-away views cannot be revived as stale preferred targets. - screenReaderFocus gets the same park-and-replay as keyboard focus, so both halves of the JS focus() call survive a pre-attach request. - A same-generation autofocus that is skipped while detached returns its attempt, so the next attach retries instead of losing autofocus. - The text-input wrapper now inherits the focus delegate's tracked focus state machine (single focus per wrapper-level entry, blur after the tracked child deallocates) instead of the descendant-only checks. --- .../RNCEKVFocusRequestBaseTests.mm | 162 ++++++++++++++++-- .../RNCEKVKeyboardFocusServiceTests.mm | 42 ++++- .../RNCEKVLockViewTests.mm | 10 +- .../RNCEKVOrderGroupBaseTests.mm | 3 +- .../RNCEKVTextInputFocusWrapperTests.mm | 137 ++++++++++++++- ios/Services/RNCEKVKeyboardFocusService.h | 13 +- ios/Services/RNCEKVKeyboardFocusService.mm | 10 +- .../RNCEKVViewFocusRequestBase.mm | 40 ++++- .../RNCEKVTextInputFocusWrapper.mm | 46 +++-- 9 files changed, 410 insertions(+), 53 deletions(-) diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm index 4c67922..49196fa 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm @@ -11,6 +11,22 @@ #import "UIViewController+RNCEKVExternalKeyboard.h" #import "RNCEKVTestSupport.h" +// Counts getFocusTargetView calls so screenReaderFocus park/replay tests can +// assert on the delta around a step instead of an absolute count, keeping +// them immune to incidental getFocusTargetView traffic elsewhere. +@interface RNCEKVScreenReaderSpyView : RNCEKVExternalKeyboardView +@property (nonatomic, assign) NSUInteger focusTargetQueryCount; +@end + +@implementation RNCEKVScreenReaderSpyView + +- (UIView *)getFocusTargetView { + self.focusTargetQueryCount += 1; + return [super getFocusTargetView]; +} + +@end + @interface RNCEKVFocusRequestBaseTests : XCTestCase @end @@ -29,6 +45,7 @@ - (void)setUp { } - (void)tearDown { + _rootController.rncekvCustomFocusView = nil; _window.hidden = YES; _window = nil; _rootController = nil; @@ -46,11 +63,15 @@ - (void)test_focus_detached_parks_thenReplaysOnAttach { [view focus]; XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, @"a detached view has no reactViewController, so focus should park rather than route"); + XCTAssertNil(_rootController.rncekvCustomFocusView, + @"a detached view has no reactViewController, so focus should park rather than route"); [_rootController.view addSubview:view]; - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view, - @"didMoveToWindow should replay the parked focus request once attached"); + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, + @"replay routes through the view's own window root"); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + @"the key-window root is only a fallback for windowless targets"); } - (void)test_attach_withoutPending_doesNotFocus { @@ -61,6 +82,7 @@ - (void)test_attach_withoutPending_doesNotFocus { RNCEKVDrainMainQueue(2); XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + XCTAssertNil(_rootController.rncekvCustomFocusView); } - (void)test_cleanReferences_clearsPendingFocus { @@ -72,6 +94,8 @@ - (void)test_cleanReferences_clearsPendingFocus { XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, @"cleanReferences should clear the parked pending focus request before attach can replay it"); + XCTAssertNil(_rootController.rncekvCustomFocusView, + @"cleanReferences should clear the parked pending focus request before attach can replay it"); } - (void)test_pendingReplay_singleShot_notOnReattach { @@ -79,13 +103,14 @@ - (void)test_pendingReplay_singleShot_notOnReattach { [view focus]; [_rootController.view addSubview:view]; - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view); + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); + _rootController.rncekvCustomFocusView = nil; RNCEKVResetRootCustomFocusView(); [view removeFromSuperview]; [_rootController.view addSubview:view]; - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + XCTAssertNil(_rootController.rncekvCustomFocusView, @"the parked focus request is single-shot and must not replay on a second attach"); } @@ -96,11 +121,11 @@ - (void)test_autoFocus_attach_focusesAfterDoubleDispatch { [_rootController.view addSubview:view]; RNCEKVDrainMainQueue(1); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + XCTAssertNil(_rootController.rncekvCustomFocusView, @"the inner dispatch_async is still queued after a single drain cycle"); RNCEKVDrainMainQueue(1); - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view, + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, @"focus should land only once both nested dispatch_async blocks have run"); } @@ -115,9 +140,11 @@ - (void)test_autoFocus_generationBumped_staleDispatchDiscarded { XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, @"cleanReferences bumps the autofocus generation, so the already-dispatched request is stale"); + XCTAssertNil(_rootController.rncekvCustomFocusView, + @"cleanReferences bumps the autofocus generation, so the already-dispatched request is stale"); } -- (void)test_autoFocus_detachedBeforeDispatch_doesNotFocus_andNoParkedGhost { +- (void)test_autoFocus_detachedBeforeDispatch_retriesOnNextAttach { RNCEKVExternalKeyboardView *view = [self makeView]; view.autoFocus = YES; @@ -125,14 +152,14 @@ - (void)test_autoFocus_detachedBeforeDispatch_doesNotFocus_andNoParkedGhost { [view removeFromSuperview]; RNCEKVDrainMainQueue(2); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"the window guard should discard the dispatched autofocus while the view is detached"); + XCTAssertNil(_rootController.rncekvCustomFocusView, + @"the window guard discards the dispatched autofocus while detached"); [_rootController.view addSubview:view]; - RNCEKVDrainMainQueue(1); + RNCEKVDrainMainQueue(2); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"nothing was parked while detached, so re-attaching must not focus the view"); + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, + @"the detached skip returns the attempt, so the next attach retries autofocus"); } - (void)test_autoFocus_viewDeallocatedBeforeDispatch_noCrash { @@ -147,6 +174,7 @@ - (void)test_autoFocus_viewDeallocatedBeforeDispatch_noCrash { RNCEKVDrainMainQueue(2); XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + XCTAssertNil(_rootController.rncekvCustomFocusView); } - (void)test_autoFocus_singleShot_noRescheduleOnReattach { @@ -155,15 +183,121 @@ - (void)test_autoFocus_singleShot_noRescheduleOnReattach { [_rootController.view addSubview:view]; RNCEKVDrainMainQueue(2); - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, view); + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); + _rootController.rncekvCustomFocusView = nil; RNCEKVResetRootCustomFocusView(); [view removeFromSuperview]; [_rootController.view addSubview:view]; RNCEKVDrainMainQueue(2); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, + XCTAssertNil(_rootController.rncekvCustomFocusView, @"_autoFocusRequested is a single-shot latch; re-attaching without cleanReferences must not reschedule"); } +- (void)test_focus_controllerPresent_windowNil_parks_thenReplays { + UIViewController *vc = [UIViewController new]; + RNCEKVExternalKeyboardView *view = [self makeView]; + [vc.view addSubview:view]; + + [view focus]; + XCTAssertNil(vc.rncekvCustomFocusView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + window.rootViewController = vc; + window.hidden = NO; + + XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); + + window.hidden = YES; +} + +- (void)test_focus_attached_routesToOwnWindowRoot_notKeyRoot { + RNCEKVExternalKeyboardView *view = [self makeView]; + [_rootController.view addSubview:view]; + + [view focus]; + + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_detach_clearsOwnRoutedPreference { + RNCEKVExternalKeyboardView *view = [self makeView]; + [_rootController.view addSubview:view]; + [view focus]; + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); + + [view removeFromSuperview]; + + XCTAssertNil(_rootController.rncekvCustomFocusView); +} + +- (void)test_detach_preservesForeignPreference { + RNCEKVExternalKeyboardView *view = [self makeView]; + [_rootController.view addSubview:view]; + [view focus]; + + UIView *other = [UIView new]; + _rootController.rncekvCustomFocusView = other; + [view removeFromSuperview]; + + XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, other); +} + +- (void)test_cleanReferences_clearsOwnRoutedPreference { + RNCEKVExternalKeyboardView *view = [self makeView]; + [_rootController.view addSubview:view]; + [view focus]; + + [view cleanReferences]; + + XCTAssertNil(_rootController.rncekvCustomFocusView); +} + +- (void)test_screenReaderFocus_detached_parks_noDispatch { + RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; + + NSUInteger baseline = spy.focusTargetQueryCount; + [spy screenReaderFocus]; + RNCEKVDrainMainQueue(1); + + XCTAssertEqual(spy.focusTargetQueryCount - baseline, 0u); +} + +- (void)test_screenReaderFocus_replaysOnAttach { + RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; + + [spy screenReaderFocus]; + NSUInteger baseline = spy.focusTargetQueryCount; + [_rootController.view addSubview:spy]; + RNCEKVDrainMainQueue(1); + + XCTAssertEqual(spy.focusTargetQueryCount - baseline, 1u); +} + +- (void)test_screenReaderFocus_attached_postsAfterDispatch { + RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; + [_rootController.view addSubview:spy]; + + NSUInteger baseline = spy.focusTargetQueryCount; + [spy screenReaderFocus]; + RNCEKVDrainMainQueue(1); + + XCTAssertEqual(spy.focusTargetQueryCount - baseline, 1u); +} + +- (void)test_cleanReferences_clearsPendingScreenReaderFocus { + RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; + + [spy screenReaderFocus]; + [spy cleanReferences]; + NSUInteger baseline = spy.focusTargetQueryCount; + [_rootController.view addSubview:spy]; + RNCEKVDrainMainQueue(1); + + XCTAssertEqual(spy.focusTargetQueryCount - baseline, 0u); +} + @end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm index 509d6f6..c31e6d8 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm @@ -38,7 +38,7 @@ - (void)test_focusNil_preservesExistingCustomFocusView { XCTAssertEqual(rootController.rncekvCustomFocusView, existingFocusView); } -- (void)test_focus_prefersKeyWindowRoot_overFallback { +- (void)test_focus_windowlessTarget_fallsBackToKeyWindowRoot { UIViewController *rootController = RCTKeyWindow().rootViewController; XCTAssertNotNil(rootController); @@ -62,4 +62,44 @@ - (void)test_focusWrapper_delegatesToFallbackVariant { XCTAssertEqual(rootController.rncekvCustomFocusView, focusTarget); } +- (void)test_focus_targetWithWindow_prefersTargetWindowRoot { + UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + localWindow.rootViewController = [UIViewController new]; + UIView *target = [UIView new]; + [localWindow.rootViewController.view addSubview:target]; + localWindow.hidden = NO; + + UIViewController *fallback = [UIViewController new]; + + [RNCEKVKeyboardFocusService focus:target withFallback:fallback]; + + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, target); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + XCTAssertNil(fallback.rncekvCustomFocusView); + + localWindow.hidden = YES; +} + +- (void)test_focus_returnsRoutedController { + UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; + localWindow.rootViewController = [UIViewController new]; + UIView *target = [UIView new]; + [localWindow.rootViewController.view addSubview:target]; + localWindow.hidden = NO; + + UIViewController *fallback = [UIViewController new]; + + UIViewController *routed = [RNCEKVKeyboardFocusService focus:target withFallback:fallback]; + XCTAssertEqualObjects(routed, localWindow.rootViewController); + + XCTAssertEqualObjects([RNCEKVKeyboardFocusService focus:[UIView new] withFallback:fallback], + RCTKeyWindow().rootViewController); + + localWindow.hidden = YES; +} + +- (void)test_focus_nilView_returnsNil { + XCTAssertNil([RNCEKVKeyboardFocusService focus:nil withFallback:[UIViewController new]]); +} + @end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm index 125d522..c82458c 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm @@ -154,17 +154,19 @@ - (void)test_lockDisabled_turnedOn_noRequest { #pragma mark requestFocus routing (real view) -- (void)test_requestFocus_realView_routesThroughService_toKeyWindowRoot { +- (void)test_requestFocus_realView_routesThroughService_toOwnWindowRoot { UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + detachedWindow.hidden = NO; RNCEKVExternalKeyboardLockView *lockView = [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; [detachedWindow.rootViewController.view addSubview:lockView]; lockView.forceLock = YES; - UIViewController *keyRootController = RCTKeyWindow().rootViewController; - XCTAssertNotNil(keyRootController); - XCTAssertEqual(keyRootController.rncekvCustomFocusView, lockView); + XCTAssertEqualObjects(detachedWindow.rootViewController.rncekvCustomFocusView, lockView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + detachedWindow.hidden = YES; } - (void)test_requestFocus_inactiveGate_noRouting { diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm index 0ef211d..f0183ca 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm @@ -85,7 +85,8 @@ - (void)test_focus_attached_routesStoredViewThroughService { [view focus]; XCTAssertEqualObjects([view getStoredView], child); - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, [view getStoredView]); + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, [view getStoredView]); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); } - (void)test_focus_detached_noop { diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm index 81b4400..a2c25d6 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm @@ -63,7 +63,8 @@ - (void)test_didMoveToWindow_replaysPendingFocus_toFirstSubview { UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; XCTAssertNotNil(localWindow.rootViewController, @"reactViewController resolution requires a live root controller"); - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, first); + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, first); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); } - (void)test_replay_singleShot { @@ -73,14 +74,15 @@ - (void)test_replay_singleShot { [wrapper addSubview:child]; UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, child, + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child, @"replay must have run once before the single-shot leg is exercised"); + localWindow.rootViewController.rncekvCustomFocusView = nil; RNCEKVResetRootCustomFocusView(); [wrapper removeFromSuperview]; [localWindow.rootViewController.view addSubview:wrapper]; - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); } - (void)test_cleanReferences_clearsPending { @@ -91,9 +93,10 @@ - (void)test_cleanReferences_clearsPending { UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; [wrapper addSubview:child]; - [self attachUnderLocalRootController:wrapper]; + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); } - (void)test_updateFocus_noSubviews_serviceNilGuard_noop { @@ -118,4 +121,130 @@ - (void)test_newArch_onFocusChange_gate_noCrashBothWays { XCTAssertNoThrow([wrapper onFocusChangeHandler:NO]); } +- (void)test_focus_attachedWithoutChild_parks_thenReplaysAfterReattachWithChild { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + + [wrapper focus]; + XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + [wrapper removeFromSuperview]; + UIView *child = [UIView new]; + [wrapper addSubview:child]; + [localWindow.rootViewController.view addSubview:wrapper]; + + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child, + @"pending survives detach and replays once the child exists"); +} + +- (void)test_focus_windowNilWithController_parks { + UIViewController *vc = [UIViewController new]; + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [UIView new]; + [wrapper addSubview:child]; + [vc.view addSubview:wrapper]; + + [wrapper focus]; + + XCTAssertNil(vc.rncekvCustomFocusView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); +} + +- (void)test_detach_clearsRoutedChildPreference { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [UIView new]; + [wrapper addSubview:child]; + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + + [wrapper focus]; + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child); + + [wrapper removeFromSuperview]; + + XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); +} + +- (void)test_detach_preservesForeignPreference { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [UIView new]; + [wrapper addSubview:child]; + UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; + + [wrapper focus]; + + UIView *other = [UIView new]; + localWindow.rootViewController.rncekvCustomFocusView = other; + [wrapper removeFromSuperview]; + + XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, other); +} + +// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): -resolveFocusChange: +// is declared to take UIFocusUpdateContext*, whose static type this double does +// not subclass (see RNCEKVTestFocusContext's header comment) — the cast keeps the +// call site's static type correct while dynamic dispatch resolves against the +// accessors the double actually implements. +- (UIFocusUpdateContext *)contextWithNext:(UIView *)next previous:(UIView *)previous { + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = next; + context.previouslyFocusedView = previous; + return (UIFocusUpdateContext *)context; +} + +- (void)test_resolveFocusChange_firstDescendantEntry_yes { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + + UIFocusUpdateContext *context = [self contextWithNext:child previous:nil]; + + XCTAssertEqualObjects([wrapper resolveFocusChange:context], @YES); +} + +- (void)test_resolveFocusChange_descendantToDescendant_nil { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + UIView *child2 = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + [wrapper addSubview:child2]; + + [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; + + UIFocusUpdateContext *secondEntry = [self contextWithNext:child2 previous:child]; + XCTAssertNil([wrapper resolveFocusChange:secondEntry]); +} + +- (void)test_resolveFocusChange_leave_reportsNo { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; + + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *leave = [self contextWithNext:outside previous:child]; + + XCTAssertEqualObjects([wrapper resolveFocusChange:leave], @NO); +} + +- (void)test_resolveFocusChange_trackedChildDeallocated_blurStillReported { + RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; + + __weak UIView *weakChild; + @autoreleasepool { + UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; + [wrapper addSubview:child]; + weakChild = child; + + [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; + [child removeFromSuperview]; + } + XCTAssertNil(weakChild); + + UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; + UIFocusUpdateContext *afterDealloc = [self contextWithNext:outside previous:[UIView new]]; + + XCTAssertEqualObjects([wrapper resolveFocusChange:afterDealloc], @NO); +} + @end diff --git a/ios/Services/RNCEKVKeyboardFocusService.h b/ios/Services/RNCEKVKeyboardFocusService.h index 87c977f..d9e7019 100644 --- a/ios/Services/RNCEKVKeyboardFocusService.h +++ b/ios/Services/RNCEKVKeyboardFocusService.h @@ -23,12 +23,13 @@ /// Moves keyboard focus to the given view on the next focus update. + (void)focus:(UIView *)view; -/// Like `focus:`, but falls back to the given controller when no key-window root -/// controller exists. The root is preferred because UIKit honors a focus update -/// only when the environment it is requested on contains the currently focused -/// item — a nearest-ancestor controller often does not (nested controllers, -/// react-native-screens), and the request is then silently discarded. -+ (void)focus:(UIView *)view withFallback:(UIViewController *)controller; +/// Like `focus:`, but resolves the routing controller as: the target view's own +/// window root first (UIKit honors a focus update only when the environment it is +/// requested on contains the currently focused item, and only the target's own +/// scene is guaranteed to contain the target), then the key-window root for +/// not-yet-attached targets, then the supplied fallback. Returns the controller +/// the request was routed to, or nil when nothing was routed. ++ (UIViewController *)focus:(UIView *)view withFallback:(UIViewController *)controller; @end diff --git a/ios/Services/RNCEKVKeyboardFocusService.mm b/ios/Services/RNCEKVKeyboardFocusService.mm index f9d5df9..5f80725 100644 --- a/ios/Services/RNCEKVKeyboardFocusService.mm +++ b/ios/Services/RNCEKVKeyboardFocusService.mm @@ -44,14 +44,16 @@ + (void)focus:(UIView *)view { [self focus:view withFallback:nil]; } -+ (void)focus:(UIView *)view withFallback:(UIViewController *)controller { ++ (UIViewController *)focus:(UIView *)view withFallback:(UIViewController *)controller { if (!view) { - return; + return nil; } - UIWindow *window = RCTKeyWindow(); - UIViewController *targetController = window.rootViewController ?: controller; + UIViewController *targetController = view.window.rootViewController + ?: RCTKeyWindow().rootViewController + ?: controller; [targetController rncekvFocusView:view]; + return targetController; } @end diff --git a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm index b60846c..e802422 100644 --- a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm +++ b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm @@ -10,6 +10,7 @@ #import "UIView+React.h" #import "RNCEKVViewFocusRequestBase.h" #import "RNCEKVKeyboardFocusService.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" #ifdef RCT_NEW_ARCH_ENABLED #import "RNCEKVNativeProps.h" @@ -19,16 +20,31 @@ @implementation RNCEKVViewFocusRequestBase { BOOL _autoFocusRequested; BOOL _pendingFocusRequest; + BOOL _pendingScreenReaderFocus; NSUInteger _autoFocusGeneration; + __weak UIViewController *_focusRoutedController; } - (void)cleanReferences { [super cleanReferences]; + [self clearRoutedFocusTarget]; _autoFocusRequested = NO; _pendingFocusRequest = NO; + _pendingScreenReaderFocus = NO; _autoFocusGeneration++; } +// Clears the controller preference this view installed via the focus service, +// but only while it still points at this view — a later request routed by +// another view must not be discarded. +- (void)clearRoutedFocusTarget { + UIViewController *routedController = _focusRoutedController; + if (routedController != nil && routedController.rncekvCustomFocusView == self) { + routedController.rncekvCustomFocusView = nil; + } + _focusRoutedController = nil; +} + - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { _autoFocusRequested = NO; @@ -39,14 +55,18 @@ - (instancetype)initWithFrame:(CGRect)frame { - (void)focus { UIViewController *controller = self.reactViewController; - if (controller == nil) { + if (controller == nil || self.window == nil) { _pendingFocusRequest = YES; return; } - [RNCEKVKeyboardFocusService focus:self withFallback:controller]; + _focusRoutedController = [RNCEKVKeyboardFocusService focus:self withFallback:controller]; } - (void)screenReaderFocus { + if (self.window == nil) { + _pendingScreenReaderFocus = YES; + return; + } dispatch_async(dispatch_get_main_queue(), ^{ UIView *focusView = [self getFocusTargetView]; UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, @@ -83,7 +103,13 @@ - (void)focusOnMount { if (strongSelf == nil || strongSelf->_autoFocusGeneration != generation) { return; } - if (strongSelf.window && strongSelf.autoFocus) { + if (strongSelf.window == nil) { + // Detached during the dispatch hop: return the consumed attempt so the + // next attach can retry instead of losing autofocus permanently. + strongSelf->_autoFocusRequested = NO; + return; + } + if (strongSelf.autoFocus) { [strongSelf focus]; } }); @@ -101,7 +127,15 @@ - (void)didMoveToWindow { _pendingFocusRequest = NO; [self focus]; } + if (_pendingScreenReaderFocus) { + _pendingScreenReaderFocus = NO; + [self screenReaderFocus]; + } [self onAttached]; + } else { + // Detach invalidates the preference this view installed; a recycled or + // navigated-away view must not remain the controller's preferred target. + [self clearRoutedFocusTarget]; } } diff --git a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm index 909d5dd..7f1e930 100644 --- a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm +++ b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm @@ -7,6 +7,7 @@ #import "RCTBaseTextInputView.h" #import "RNCEKVOrderLinking.h" #import "RNCEKVKeyboardFocusService.h" +#import "UIViewController+RNCEKVExternalKeyboard.h" #ifdef RCT_NEW_ARCH_ENABLED #import "RCTTextInputComponentView+RNCEKVExternalKeyboard.h" @@ -43,6 +44,8 @@ @interface RNCEKVTextInputFocusWrapper () @implementation RNCEKVTextInputFocusWrapper { BOOL _pendingFocusRequest; + __weak UIViewController *_focusRoutedController; + __weak UIView *_focusRoutedTarget; } - (instancetype)initWithFrame:(CGRect)frame @@ -167,7 +170,8 @@ - (void)onMultiplyTextSubmitHandler: (RCTUITextView*) textView { - (void)focus { UIViewController *viewController = self.reactViewController; - if (viewController == nil || self.superview == nil) { + if (viewController == nil || self.superview == nil || self.window == nil || + self.subviews.count == 0) { _pendingFocusRequest = YES; return; } @@ -176,16 +180,35 @@ - (void)focus { - (void)updateFocus:(UIViewController *)controller { UIView *focusingView = self.subviews.count ? self.subviews[0] : nil; - if (self.superview != nil && controller != nil) { - [RNCEKVKeyboardFocusService focus:focusingView withFallback:controller]; + if (self.superview != nil && controller != nil && focusingView != nil) { + _focusRoutedController = [RNCEKVKeyboardFocusService focus:focusingView withFallback:controller]; + _focusRoutedTarget = focusingView; } } +// Clears the controller preference this wrapper installed for its child, but +// only while it still points at that child — a later request routed by +// another view must not be discarded. +- (void)clearRoutedFocusTarget { + UIViewController *routedController = _focusRoutedController; + UIView *routedTarget = _focusRoutedTarget; + if (routedController != nil && routedTarget != nil && + routedController.rncekvCustomFocusView == routedTarget) { + routedController.rncekvCustomFocusView = nil; + } + _focusRoutedController = nil; + _focusRoutedTarget = nil; +} + - (void)didMoveToWindow { [super didMoveToWindow]; - if (self.window && _pendingFocusRequest) { - _pendingFocusRequest = NO; - [self focus]; + if (self.window) { + if (_pendingFocusRequest) { + _pendingFocusRequest = NO; + [self focus]; + } + } else { + [self clearRoutedFocusTarget]; } } @@ -197,16 +220,6 @@ - (UIView*)getStoredView { return _textField; } -- (NSNumber *)resolveFocusChange:(UIFocusUpdateContext *)context { - if([context.nextFocusedView isDescendantOfView:self]) { - return @YES; - } else if([context.previouslyFocusedView isDescendantOfView:self]) { - return @NO; - } - - return nil; -} - - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { @@ -261,6 +274,7 @@ - (UIView*)getTextFieldComponent { - (void)cleanReferences{ [super cleanReferences]; + [self clearRoutedFocusTarget]; _textField = nil; _textView = nil; _pendingFocusRequest = NO; From 94115dcb0441d8f4e3913f594b99f0eb389694b9 Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Wed, 26 Aug 2026 14:15:15 +1000 Subject: [PATCH 6/9] fix: correct focus-lock guards, nested-wrapper guide ownership, and group endpoint cleanup - Both lock-view request guards now reject any inactive or disabled state (matching onAccessibilityFocusChanged:), and the Fabric prop diff applies lockDisabled before forceLock so a compound activate-and-disable commit never passes through a momentarily-active state. didMoveToWindow doubles as the attach replay for an active trap whose early request had no controller. - Directional-guide enablement uses nearest-wrapper ownership: a nested order-group wrapper (or a focused wrapper itself) owns its focus, so a parent's guides no longer activate for a nested wrapper's focus. - RNCEKVOrderRelationship.clear also nils its entry/exit endpoints, so emptying a group releases its cached boundaries on the unlink path. --- .../RNCEKVFocusSequenceDelegateTests.mm | 29 +++++++++ .../RNCEKVLockViewTests.mm | 63 +++++++++++++++++++ .../RNCEKVOrderGroupBaseTests.mm | 40 ++++++++++++ .../RNCEKVOrderRelationship.mm | 2 + .../RNCEKVViewOrderGroupBase.mm | 16 ++++- .../RNCEKVExternalKeyboardLockView.mm | 16 +++-- 6 files changed, 160 insertions(+), 6 deletions(-) diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm index 0b5d65d..64c084f 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm @@ -282,4 +282,33 @@ - (void)test_emptyGroup_returnsDefault { XCTAssertNil([[RNCEKVOrderLinking sharedInstance] getInfo:group]); } +#pragma mark - RNCEKVOrderRelationship.clear endpoint cleanup + +- (void)test_relationshipClear_nilsEntryAndExit { + RNCEKVOrderRelationship *relationship = [RNCEKVOrderRelationship new]; + relationship.entry = [self viewInWindow]; + relationship.exit = [self viewInWindow]; + + [relationship clear]; + + XCTAssertNil(relationship.entry); + XCTAssertNil(relationship.exit); + XCTAssertEqual([relationship count], 0); +} + +- (void)test_lastMemberRemoved_clearsEndpoints { + NSString *group = [self uniqueOrderGroup]; + [self registerItemAtPosition:@0 group:group]; + + RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; + relationship.entry = [self viewInWindow]; + relationship.exit = [self viewInWindow]; + + [[RNCEKVOrderLinking sharedInstance] remove:@0 withOrderKey:group]; + + XCTAssertNil(relationship.entry, @"emptying the group clears its endpoints"); + XCTAssertNil(relationship.exit); + XCTAssertNil([[RNCEKVOrderLinking sharedInstance] getInfo:group]); +} + @end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm index c82458c..79cebc7 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm @@ -184,6 +184,53 @@ - (void)test_requestFocus_inactiveGate_noRouting { XCTAssertNil(keyRootController.rncekvCustomFocusView); } +- (void)test_didMoveToWindow_inactiveDefaults_noRequest { + UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + detachedWindow.hidden = NO; + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + + [detachedWindow.rootViewController.view addSubview:lockView]; + + XCTAssertNil(detachedWindow.rootViewController.rncekvCustomFocusView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + detachedWindow.hidden = YES; +} + +- (void)test_didMoveToWindow_disabledTrap_noRequest { + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + lockView.forceLock = YES; + lockView.lockDisabled = YES; + + UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + detachedWindow.hidden = NO; + + [detachedWindow.rootViewController.view addSubview:lockView]; + + XCTAssertNil(detachedWindow.rootViewController.rncekvCustomFocusView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + detachedWindow.hidden = YES; +} + +- (void)test_attach_activeTrap_requestReplaysToOwnWindowRoot { + RNCEKVExternalKeyboardLockView *lockView = + [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; + lockView.forceLock = YES; + + UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); + detachedWindow.hidden = NO; + + [detachedWindow.rootViewController.view addSubview:lockView]; + + XCTAssertEqualObjects(detachedWindow.rootViewController.rncekvCustomFocusView, lockView); + XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); + + detachedWindow.hidden = YES; +} + #pragma mark shouldUpdateFocusInContext: - (void)test_shouldUpdateFocus_forceLock_blocksMoveOutsideSubtree { @@ -265,6 +312,22 @@ - (void)test_updateProps_unchangedValues_settersNotInvoked { XCTAssertEqual(lockView.lockDisabledSetterCount, 1u); } +- (void)test_updateProps_compoundForceLockAndDisable_noRequest { + RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; + + auto newViewProps = std::make_shared(); + newViewProps->forceLock = true; + newViewProps->lockDisabled = true; + facebook::react::Props::Shared newProps = newViewProps; + facebook::react::Props::Shared oldProps = + std::make_shared(); + + [lockView updateProps:newProps oldProps:oldProps]; + + XCTAssertEqual(lockView.requestFocusCount, 0u); + XCTAssertEqual(lockView.requestScreenReaderFocusCount, 0u); +} + #endif /* RCT_NEW_ARCH_ENABLED */ #pragma mark dealloc diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm index f0183ca..ba89fcd 100644 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm +++ b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm @@ -99,4 +99,44 @@ - (void)test_focus_detached_noop { XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); } +- (void)test_getIsViewFocused_selfNext_true { + RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = view; + + XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)context]); +} + +- (void)test_getIsViewFocused_nestedWrapperNext_falseForParent_trueForNested { + RNCEKVExternalKeyboardView *parent = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + UIView *mid = [[UIView alloc] initWithFrame:CGRectZero]; + RNCEKVExternalKeyboardView *nested = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + [parent addSubview:mid]; + [mid addSubview:nested]; + + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = nested; + + XCTAssertFalse([parent getIsViewFocused:(UIFocusUpdateContext *)context], + @"a nested wrapper owns its own focus"); + XCTAssertTrue([nested getIsViewFocused:(UIFocusUpdateContext *)context]); +} + +- (void)test_getIsViewFocused_childInsideNestedWrapper_falseForParent_trueForNested { + RNCEKVExternalKeyboardView *parent = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + UIView *mid = [[UIView alloc] initWithFrame:CGRectZero]; + RNCEKVExternalKeyboardView *nested = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; + UIView *leaf = [[UIView alloc] initWithFrame:CGRectZero]; + [parent addSubview:mid]; + [mid addSubview:nested]; + [nested addSubview:leaf]; + + RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; + context.nextFocusedView = leaf; + + XCTAssertFalse([parent getIsViewFocused:(UIFocusUpdateContext *)context]); + XCTAssertTrue([nested getIsViewFocused:(UIFocusUpdateContext *)context]); +} + @end diff --git a/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.mm b/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.mm index d041114..0643b78 100644 --- a/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.mm +++ b/ios/Services/RNCEKVKeyboardOrderManager/RNCEKVOrderRelationship/RNCEKVOrderRelationship.mm @@ -35,6 +35,8 @@ - (void)update:(NSNumber*)lastPosition withPosition:(NSNumber*)position withObje -(void)clear { [_positions clear]; + self.entry = nil; + self.exit = nil; } - (int)getItemIndex:(UIView *)element { diff --git a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm index fbe9e03..0e1cad8 100644 --- a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm +++ b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm @@ -35,7 +35,21 @@ - (instancetype)initWithFrame:(CGRect)frame - (BOOL)getIsViewFocused:(UIFocusUpdateContext *)context { UIView *next = context.nextFocusedView; - return next != nil && [next isDescendantOfView:self]; + if (next == self) { + return YES; + } + if (next == nil || ![next isDescendantOfView:self]) { + return NO; + } + // Nearest-wrapper ownership: when the focused view sits inside a nested + // order-group wrapper (or is one itself), that nested wrapper owns the + // focus and this view's directional guides must stay off. + for (UIView *view = next; view != nil && view != self; view = view.superview) { + if ([view isKindOfClass:[RNCEKVViewOrderGroupBase class]]) { + return NO; + } + } + return YES; } - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context diff --git a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm index ac3e3ac..bd4b5f7 100644 --- a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm +++ b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm @@ -109,7 +109,7 @@ - (BOOL)shouldUpdateFocusInContext:(UIFocusUpdateContext *)context { } - (void)requestFocus { - if (!_forceLock && _lockDisabled) return; + if (!_forceLock || _lockDisabled) return; UIViewController *controller = self.reactViewController; if (controller != nil) { @@ -118,7 +118,7 @@ - (void)requestFocus { } - (void)requestScreenReaderFocus { - if (!_forceLock && _lockDisabled) return; + if (!_forceLock || _lockDisabled) return; UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, self); } @@ -148,12 +148,15 @@ - (void)updateProps:(Props::Shared const &)props *std::static_pointer_cast(props); [super updateProps:props oldProps:oldProps]; - if (_forceLock != newViewProps.forceLock) { - self.forceLock = newViewProps.forceLock; - } + // lockDisabled must be applied before forceLock: a compound + // { forceLock: true, lockDisabled: true } commit from defaults must never + // pass through a momentarily-active state that steals focus. if (_lockDisabled != newViewProps.lockDisabled) { self.lockDisabled = newViewProps.lockDisabled; } + if (_forceLock != newViewProps.forceLock) { + self.forceLock = newViewProps.forceLock; + } } Class ExternalKeyboardLockViewCls(void) @@ -166,6 +169,9 @@ - (void)updateProps:(Props::Shared const &)props - (void)didMoveToWindow { [super didMoveToWindow]; + // Doubles as the attach replay: an active trap whose setter-time request was + // dropped for lack of a controller re-requests here, while the guards above + // keep an inactive or disabled trap from stealing focus on mount. if (self.window) { [self requestFocus]; [self requestScreenReaderFocus]; From 8ef3723dbdfafe7e9f2732dc0ad067eb066a4bab Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Wed, 26 Aug 2026 14:38:01 +1000 Subject: [PATCH 7/9] chore: remove test scaffolding to keep the diff minimal The branch was developed and validated with a full XCTest suite (97 unit tests over the changed focus-path methods, plus coverage and mutation scoring). It lives in history: 6d0b46a adds the suite and test target, 1bfc9c9 and 94115dc extend it alongside the fixes they verify. Since the project has no existing native test infrastructure, this commit removes the suite, the test-target project changes, and the Podfile/Podfile.lock edits from the PR tip to keep the reviewable diff limited to the library sources. Revert this commit to restore the complete, passing test setup. --- .../project.pbxproj | 101 +---- .../ExternalKeyboardExampleTests/Info.plist | 12 - .../RNCEKVFocusChangeEventTests.mm | 201 ---------- .../RNCEKVFocusDelegateTests.mm | 144 ------- .../RNCEKVFocusRequestBaseTests.mm | 303 --------------- .../RNCEKVFocusSequenceDelegateTests.mm | 314 ---------------- .../RNCEKVKeyboardFocusServiceTests.mm | 105 ------ .../RNCEKVLockViewTests.mm | 353 ------------------ .../RNCEKVOrderGroupBaseTests.mm | 142 ------- .../RNCEKVRetainCycleTests.mm | 191 ---------- .../RNCEKVTestSupport.h | 135 ------- .../RNCEKVTestSupport.mm | 81 ---- .../RNCEKVTextInputFocusWrapperTests.mm | 250 ------------- .../RNCEKVViewControllerExtensionTests.mm | 100 ----- example/ios/Podfile | 4 - example/ios/Podfile.lock | 136 +++---- example/ios/scripts/setup_unit_tests.rb | 28 -- 17 files changed, 71 insertions(+), 2529 deletions(-) delete mode 100644 example/ios/ExternalKeyboardExampleTests/Info.plist delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm delete mode 100644 example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm delete mode 100644 example/ios/scripts/setup_unit_tests.rb diff --git a/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj b/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj index c6b1397..9b22e15 100644 --- a/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj +++ b/example/ios/ExternalKeyboardExample.xcodeproj/project.pbxproj @@ -8,22 +8,10 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; - 1EEE3A3E0203CF05C09377F4 /* RNCEKVViewControllerExtensionTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */; }; - 1F305FA47A9B6677B57844F1 /* RNCEKVLockViewTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */; }; - 2DE37770475F7057B5599438 /* RNCEKVRetainCycleTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */; }; - 2FBF7B772C58DD5E1A887A77 /* RNCEKVFocusChangeEventTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */; }; 49BB6AB52F57756100D611EC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49BB6AB42F57756100D611EC /* AppDelegate.swift */; }; - 53A9D0E943566D33D18AEC20 /* RNCEKVTestSupport.mm in Sources */ = {isa = PBXBuildFile; fileRef = D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */; }; - 70ADC087D979C21AF5FD4BFD /* RNCEKVFocusRequestBaseTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */; }; - 74CE1FA6E9882F06425F601D /* RNCEKVFocusSequenceDelegateTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */; }; - 7E544FB94E365B379EF4CE81 /* libPods-ExternalKeyboardExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */; }; - 7FA320F6D655DD131985493C /* RNCEKVKeyboardFocusServiceTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 8C8CDA348C04716D7E978977 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; - 9AB31AFBF154E1F653ABFDCC /* RNCEKVTextInputFocusWrapperTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */; }; - AFA2C580C8C0FA06CD2B0649 /* RNCEKVOrderGroupBaseTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */; }; C7DA79396D52E915C74FA1F4 /* libPods-ExternalKeyboardExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */; }; - FE3AA40BEAA4EDD086C0B018 /* RNCEKVFocusDelegateTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,32 +26,17 @@ /* Begin PBXFileReference section */ 00E356EE1AD99517003FC87E /* ExternalKeyboardExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExternalKeyboardExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusChangeEventTests.mm; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* ExternalKeyboardExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExternalKeyboardExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = ExternalKeyboardExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ExternalKeyboardExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ExternalKeyboardExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 1BA93C23CA0B2C4A9E920F95 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ExternalKeyboardExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 37EFAB3374F22BB8020F5BD3 /* Pods-ExternalKeyboardExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExample.release.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample.release.xcconfig"; sourceTree = ""; }; - 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVViewControllerExtensionTests.mm; sourceTree = ""; }; - 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExampleTests/Pods-ExternalKeyboardExampleTests.debug.xcconfig"; sourceTree = ""; }; - 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVOrderGroupBaseTests.mm; sourceTree = ""; }; 49BB6AB42F57756100D611EC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 49BB6AB62F57756400D611EC /* ExternalKeyboardExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ExternalKeyboardExample-Bridging-Header.h"; sourceTree = ""; }; 4C1BC52B9D066024791DCF89 /* Pods-ExternalKeyboardExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExample.debug.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample.debug.xcconfig"; sourceTree = ""; }; - 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVRetainCycleTests.mm; sourceTree = ""; }; - 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ExternalKeyboardExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusRequestBaseTests.mm; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ExternalKeyboardExample/LaunchScreen.storyboard; sourceTree = ""; }; - 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVKeyboardFocusServiceTests.mm; sourceTree = ""; }; - AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVTextInputFocusWrapperTests.mm; sourceTree = ""; }; - AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVLockViewTests.mm; sourceTree = ""; }; - C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusDelegateTests.mm; sourceTree = ""; }; - D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVTestSupport.mm; sourceTree = ""; }; - E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ExternalKeyboardExampleTests.release.xcconfig"; path = "Target Support Files/Pods-ExternalKeyboardExampleTests/Pods-ExternalKeyboardExampleTests.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; - FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; path = RNCEKVFocusSequenceDelegateTests.mm; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -71,7 +44,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7E544FB94E365B379EF4CE81 /* libPods-ExternalKeyboardExampleTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -104,7 +76,6 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 21127FDCC5EFDD7380EBF19C /* libPods-ExternalKeyboardExample.a */, - 7A9BB9718193ECCA0E69A5EB /* libPods-ExternalKeyboardExampleTests.a */, ); name = Frameworks; sourceTree = ""; @@ -124,7 +95,6 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, - A9AC4704AAD612D7990297BE /* ExternalKeyboardExampleTests */, ); indentWidth = 2; sourceTree = ""; @@ -140,33 +110,11 @@ name = Products; sourceTree = ""; }; - A9AC4704AAD612D7990297BE /* ExternalKeyboardExampleTests */ = { - isa = PBXGroup; - children = ( - 0EE8D2342205B1004448CBDA /* RNCEKVFocusChangeEventTests.mm */, - C57666B7F5A54F1F2729CAC6 /* RNCEKVFocusDelegateTests.mm */, - 8161B5B461B7D74DBEEFDAC5 /* RNCEKVFocusRequestBaseTests.mm */, - FB8BBFA46086F6504CF8553F /* RNCEKVFocusSequenceDelegateTests.mm */, - 8E564B145073D9377721FC68 /* RNCEKVKeyboardFocusServiceTests.mm */, - AF7F64189EF3E74B2841473C /* RNCEKVLockViewTests.mm */, - 4863D7E967FC9D5E70EED0D8 /* RNCEKVOrderGroupBaseTests.mm */, - 4E43FA7399399E4D366EA80A /* RNCEKVRetainCycleTests.mm */, - D8199B21F7B30A13A49C6220 /* RNCEKVTestSupport.mm */, - AC046858A2BAA75608DA261A /* RNCEKVTextInputFocusWrapperTests.mm */, - 3846C23D07A57E855A3F9B4C /* RNCEKVViewControllerExtensionTests.mm */, - 1BA93C23CA0B2C4A9E920F95 /* Info.plist */, - ); - name = ExternalKeyboardExampleTests; - path = ExternalKeyboardExampleTests; - sourceTree = ""; - }; BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( 4C1BC52B9D066024791DCF89 /* Pods-ExternalKeyboardExample.debug.xcconfig */, 37EFAB3374F22BB8020F5BD3 /* Pods-ExternalKeyboardExample.release.xcconfig */, - 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */, - E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -178,7 +126,6 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "ExternalKeyboardExampleTests" */; buildPhases = ( - CB5AFCFB03868E4E84DCE553 /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, @@ -285,7 +232,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n\"$WITH_ENVIRONMENT\" \"$REACT_NATIVE_XCODE\"\n"; + shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; 16BED33323DB9400FA927456 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -326,28 +273,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ExternalKeyboardExample/Pods-ExternalKeyboardExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; - CB5AFCFB03868E4E84DCE553 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-ExternalKeyboardExampleTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; ECDD31C7EEDF518249EFE814 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -372,17 +297,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2FBF7B772C58DD5E1A887A77 /* RNCEKVFocusChangeEventTests.mm in Sources */, - FE3AA40BEAA4EDD086C0B018 /* RNCEKVFocusDelegateTests.mm in Sources */, - 70ADC087D979C21AF5FD4BFD /* RNCEKVFocusRequestBaseTests.mm in Sources */, - 74CE1FA6E9882F06425F601D /* RNCEKVFocusSequenceDelegateTests.mm in Sources */, - 7FA320F6D655DD131985493C /* RNCEKVKeyboardFocusServiceTests.mm in Sources */, - 1F305FA47A9B6677B57844F1 /* RNCEKVLockViewTests.mm in Sources */, - AFA2C580C8C0FA06CD2B0649 /* RNCEKVOrderGroupBaseTests.mm in Sources */, - 2DE37770475F7057B5599438 /* RNCEKVRetainCycleTests.mm in Sources */, - 53A9D0E943566D33D18AEC20 /* RNCEKVTestSupport.mm in Sources */, - 9AB31AFBF154E1F653ABFDCC /* RNCEKVTextInputFocusWrapperTests.mm in Sources */, - 1EEE3A3E0203CF05C09377F4 /* RNCEKVViewControllerExtensionTests.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -407,14 +321,11 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3A4DB40861BDECF47DA62E67 /* Pods-ExternalKeyboardExampleTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ENABLE_MODULES = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", - "RCT_NEW_ARCH_ENABLED=1", ); INFOPLIST_FILE = ExternalKeyboardExampleTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -428,7 +339,7 @@ "-lc++", "$(inherited)", ); - PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example.tests; + PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ExternalKeyboardExample.app/ExternalKeyboardExample"; }; @@ -436,15 +347,9 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E87FD7CE298F92A624BB2357 /* Pods-ExternalKeyboardExampleTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "RCT_NEW_ARCH_ENABLED=1", - ); INFOPLIST_FILE = ExternalKeyboardExampleTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -457,7 +362,7 @@ "-lc++", "$(inherited)", ); - PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example.tests; + PRODUCT_BUNDLE_IDENTIFIER = externalkeyboard.example; PRODUCT_NAME = "$(TARGET_NAME)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ExternalKeyboardExample.app/ExternalKeyboardExample"; }; diff --git a/example/ios/ExternalKeyboardExampleTests/Info.plist b/example/ios/ExternalKeyboardExampleTests/Info.plist deleted file mode 100644 index 33e4700..0000000 --- a/example/ios/ExternalKeyboardExampleTests/Info.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - -CFBundleDevelopmentRegionen -CFBundleExecutable$(EXECUTABLE_NAME) -CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) -CFBundleInfoDictionaryVersion6.0 -CFBundleName$(PRODUCT_NAME) -CFBundlePackageTypeBNDL -CFBundleShortVersionString1.0 -CFBundleVersion1 - diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm deleted file mode 100644 index 323d908..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusChangeEventTests.mm +++ /dev/null @@ -1,201 +0,0 @@ -// -// RNCEKVFocusChangeEventTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import - -#import "RNCEKVExternalKeyboardView.h" -#import "RNCEKVTestSupport.h" - -#ifdef RCT_NEW_ARCH_ENABLED -#import -#import "RNCEKVFabricEventHelper.h" -#endif - -#pragma mark - RNCEKVFocusChangeEventRecordingView - -// Records every -onFocusChangeHandler: argument before forwarding to super, -// so a test can assert the exact sequence of focus-change events the base -// class fired without a live focus engine or JS-side event wiring. -@interface RNCEKVFocusChangeEventRecordingView : RNCEKVExternalKeyboardView - -@property (nonatomic, strong, readonly) NSArray *recordedFocusChanges; - -@end - -@implementation RNCEKVFocusChangeEventRecordingView { - NSMutableArray *_focusChangeLog; -} - -- (instancetype)initWithFrame:(CGRect)frame { - if (self = [super initWithFrame:frame]) { - _focusChangeLog = [NSMutableArray array]; - } - return self; -} - -- (NSArray *)recordedFocusChanges { - return [_focusChangeLog copy]; -} - -- (void)onFocusChangeHandler:(BOOL)isFocused { - [_focusChangeLog addObject:@(isFocused)]; - [super onFocusChangeHandler:isFocused]; -} - -@end - -#ifdef RCT_NEW_ARCH_ENABLED - -#pragma mark - RNCEKVFabricEventHelper counting replacement - -// Byte-identical C++ signature to +onFocusChangeEventEmmiter:withEmitter:, -// swapped in via method_exchangeImplementations so the leaf-gate tests can -// count invocations without constructing a real SharedViewEventEmitter. -// imp_implementationWithBlock is avoided here: it is ABI-delicate over a -// by-value std::shared_ptr parameter, while a compiled category preserves -// the C++ calling convention exactly. -@interface RNCEKVFabricEventHelper (RNCEKVFocusChangeEventCounting) - -+ (void)rncekv_test_onFocusChangeEventEmmiter:(BOOL)isFocused - withEmitter:(facebook::react::SharedViewEventEmitter)emitter; - -@end - -static NSUInteger sRNCEKVFocusChangeEmitCallCount; -static BOOL sRNCEKVFocusChangeEmitLastIsFocused; - -@implementation RNCEKVFabricEventHelper (RNCEKVFocusChangeEventCounting) - -+ (void)rncekv_test_onFocusChangeEventEmmiter:(BOOL)isFocused - withEmitter:(facebook::react::SharedViewEventEmitter)emitter { - sRNCEKVFocusChangeEmitCallCount += 1; - sRNCEKVFocusChangeEmitLastIsFocused = isFocused; -} - -@end - -#endif /* RCT_NEW_ARCH_ENABLED */ - -#pragma mark - Tests - -@interface RNCEKVFocusChangeEventTests : XCTestCase -@end - -@implementation RNCEKVFocusChangeEventTests - -#ifdef RCT_NEW_ARCH_ENABLED -- (void)swapFocusChangeEventEmitterImplementations { - Method original = class_getClassMethod([RNCEKVFabricEventHelper class], - @selector(onFocusChangeEventEmmiter:withEmitter:)); - Method replacement = class_getClassMethod([RNCEKVFabricEventHelper class], - @selector(rncekv_test_onFocusChangeEventEmmiter:withEmitter:)); - method_exchangeImplementations(original, replacement); -} -#endif - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -#ifdef RCT_NEW_ARCH_ENABLED - sRNCEKVFocusChangeEmitCallCount = 0; - sRNCEKVFocusChangeEmitLastIsFocused = NO; - [self swapFocusChangeEventEmitterImplementations]; -#endif -} - -- (void)tearDown { -#ifdef RCT_NEW_ARCH_ENABLED - [self swapFocusChangeEventEmitterImplementations]; -#endif - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (RNCEKVFocusChangeEventRecordingView *)recordingViewWithCanBeFocused:(BOOL)canBeFocused - focusableWrapper:(BOOL)focusableWrapper { - RNCEKVFocusChangeEventRecordingView *view = - [[RNCEKVFocusChangeEventRecordingView alloc] initWithFrame:CGRectZero]; - view.canBeFocused = canBeFocused; - view.focusableWrapper = focusableWrapper; - return view; -} - -// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): the caller -// passes this straight into -didUpdateFocusInContext:withAnimationCoordinator:, -// a UIKit-declared method whose context parameter is _Nonnull-audited, so -// the compiler hard-errors on an unrelated-class argument unless it is -// already statically typed (or cast) to UIFocusUpdateContext* — see -// RNCEKVTestFocusContext's header comment for why the double isn't a real -// UIFocusUpdateContext subclass. -- (UIFocusUpdateContext *)contextWithNext:(nullable UIView *)next previous:(nullable UIView *)previous { - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = next; - context.previouslyFocusedView = previous; - return (UIFocusUpdateContext *)context; -} - -- (void)test_focusEnter_setsIsKeyboardFocused_firesHandlerYes { - RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; - - [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] - withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; - - XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES])); - XCTAssertTrue(view.isKeyboardFocused); -} - -- (void)test_unrelatedContext_preservesState_noHandlerCall { - RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; - [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] - withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; - - UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *unrelatedPrevious = [[UIView alloc] initWithFrame:CGRectZero]; - - [view didUpdateFocusInContext:[self contextWithNext:unrelatedNext previous:unrelatedPrevious] - withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; - - XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES])); - XCTAssertTrue(view.isKeyboardFocused); -} - -- (void)test_focusLeave_firesHandlerNo { - RNCEKVFocusChangeEventRecordingView *view = [self recordingViewWithCanBeFocused:YES focusableWrapper:NO]; - [view didUpdateFocusInContext:[self contextWithNext:view previous:nil] - withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; - - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - [view didUpdateFocusInContext:[self contextWithNext:outside previous:view] - withAnimationCoordinator:(UIFocusAnimationCoordinator *)nil]; - - XCTAssertEqualObjects(view.recordedFocusChanges, (@[@YES, @NO])); - XCTAssertFalse(view.isKeyboardFocused); -} - -#ifdef RCT_NEW_ARCH_ENABLED - -- (void)test_leafEmission_suppressedWithoutHasOnFocusChanged { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - view.hasOnFocusChanged = NO; - - [view onFocusChangeHandler:YES]; - - XCTAssertEqual(sRNCEKVFocusChangeEmitCallCount, 0u); -} - -- (void)test_leafEmission_firesWithHasOnFocusChanged { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - view.hasOnFocusChanged = YES; - - [view onFocusChangeHandler:YES]; - - XCTAssertEqual(sRNCEKVFocusChangeEmitCallCount, 1u); - XCTAssertTrue(sRNCEKVFocusChangeEmitLastIsFocused); -} - -#endif /* RCT_NEW_ARCH_ENABLED */ - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm deleted file mode 100644 index 5d48bb3..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusDelegateTests.mm +++ /dev/null @@ -1,144 +0,0 @@ -// -// RNCEKVFocusDelegateTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import - -#import "RNCEKVFocusDelegate.h" -#import "RNCEKVTestSupport.h" - -@interface RNCEKVFocusDelegateTests : XCTestCase -@end - -@implementation RNCEKVFocusDelegateTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (RNCEKVFocusHostDouble *)hostWithFocusableWrapper:(BOOL)focusableWrapper { - RNCEKVFocusHostDouble *host = [[RNCEKVFocusHostDouble alloc] initWithFrame:CGRectZero]; - host.focusableWrapper = focusableWrapper; - return host; -} - -// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): callers pass -// this straight into -isFocusChanged:, whose declared parameter type this -// double is not a real subclass of (see RNCEKVTestFocusContext's header -// comment) — the cast keeps every call site's static type correct. -- (UIFocusUpdateContext *)contextWithNext:(UIView *)next previous:(UIView *)previous { - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = next; - context.previouslyFocusedView = previous; - return (UIFocusUpdateContext *)context; -} - -- (void)test_focusEnter_nonWrapper_reportsYes { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - - UIFocusUpdateContext *context = [self contextWithNext:host previous:nil]; - - XCTAssertEqualObjects([delegate isFocusChanged:context], @YES); - XCTAssertNil([delegate isFocusChanged:context]); -} - -- (void)test_wrapper_firstEntry_yes_secondDescendantEntry_nil_andRetargets { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:YES]; - UIView *child1 = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *child2 = [[UIView alloc] initWithFrame:CGRectZero]; - [host addSubview:child1]; - [host addSubview:child2]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - - UIFocusUpdateContext *firstEntry = [self contextWithNext:child1 previous:nil]; - XCTAssertEqualObjects([delegate isFocusChanged:firstEntry], @YES); - - UIFocusUpdateContext *secondEntry = [self contextWithNext:child2 previous:child1]; - XCTAssertNil([delegate isFocusChanged:secondEntry]); - - XCTAssertEqualObjects([delegate getFocusingView], child2); -} - -- (void)test_focusLeave_reportsNo { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; - - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *leave = [self contextWithNext:outside previous:host]; - - XCTAssertEqualObjects([delegate isFocusChanged:leave], @NO); -} - -- (void)test_trackedTargetDeallocated_blurStillReported { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:YES]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - - __weak UIView *weakChild; - @autoreleasepool { - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [host addSubview:child]; - weakChild = child; - - [delegate isFocusChanged:[self contextWithNext:child previous:nil]]; - [child removeFromSuperview]; - } - XCTAssertNil(weakChild); - - UIView *other = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *afterDealloc = [self contextWithNext:outside previous:other]; - - XCTAssertEqualObjects([delegate isFocusChanged:afterDealloc], @NO); -} - -- (void)test_secondUnrelatedContext_afterBlur_returnsNil { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; - - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - XCTAssertEqualObjects([delegate isFocusChanged:[self contextWithNext:outside previous:host]], @NO); - - UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; - - XCTAssertNil([delegate isFocusChanged:unrelated]); -} - -- (void)test_reset_clearsTracking { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - [delegate isFocusChanged:[self contextWithNext:host previous:nil]]; - - [delegate reset]; - - UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; - - XCTAssertNil([delegate isFocusChanged:unrelated]); -} - -- (void)test_unrelatedContext_beforeAnyFocus_returnsNil { - RNCEKVFocusHostDouble *host = [self hostWithFocusableWrapper:NO]; - RNCEKVFocusDelegate *delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - - UIView *unrelatedNext = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *unrelatedPrev = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *unrelated = [self contextWithNext:unrelatedNext previous:unrelatedPrev]; - - XCTAssertNil([delegate isFocusChanged:unrelated]); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm deleted file mode 100644 index 49196fa..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusRequestBaseTests.mm +++ /dev/null @@ -1,303 +0,0 @@ -// -// RNCEKVFocusRequestBaseTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import -#import - -#import "RNCEKVExternalKeyboardView.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -// Counts getFocusTargetView calls so screenReaderFocus park/replay tests can -// assert on the delta around a step instead of an absolute count, keeping -// them immune to incidental getFocusTargetView traffic elsewhere. -@interface RNCEKVScreenReaderSpyView : RNCEKVExternalKeyboardView -@property (nonatomic, assign) NSUInteger focusTargetQueryCount; -@end - -@implementation RNCEKVScreenReaderSpyView - -- (UIView *)getFocusTargetView { - self.focusTargetQueryCount += 1; - return [super getFocusTargetView]; -} - -@end - -@interface RNCEKVFocusRequestBaseTests : XCTestCase -@end - -@implementation RNCEKVFocusRequestBaseTests { - UIWindow *_window; - UIViewController *_rootController; -} - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); - _rootController = [UIViewController new]; - _window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - _window.rootViewController = _rootController; - _window.hidden = NO; -} - -- (void)tearDown { - _rootController.rncekvCustomFocusView = nil; - _window.hidden = YES; - _window = nil; - _rootController = nil; - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (RNCEKVExternalKeyboardView *)makeView { - return [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; -} - -- (void)test_focus_detached_parks_thenReplaysOnAttach { - RNCEKVExternalKeyboardView *view = [self makeView]; - - [view focus]; - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"a detached view has no reactViewController, so focus should park rather than route"); - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"a detached view has no reactViewController, so focus should park rather than route"); - - [_rootController.view addSubview:view]; - - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, - @"replay routes through the view's own window root"); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"the key-window root is only a fallback for windowless targets"); -} - -- (void)test_attach_withoutPending_doesNotFocus { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = NO; - - [_rootController.view addSubview:view]; - RNCEKVDrainMainQueue(2); - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - XCTAssertNil(_rootController.rncekvCustomFocusView); -} - -- (void)test_cleanReferences_clearsPendingFocus { - RNCEKVExternalKeyboardView *view = [self makeView]; - - [view focus]; - [view cleanReferences]; - [_rootController.view addSubview:view]; - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"cleanReferences should clear the parked pending focus request before attach can replay it"); - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"cleanReferences should clear the parked pending focus request before attach can replay it"); -} - -- (void)test_pendingReplay_singleShot_notOnReattach { - RNCEKVExternalKeyboardView *view = [self makeView]; - - [view focus]; - [_rootController.view addSubview:view]; - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); - - _rootController.rncekvCustomFocusView = nil; - RNCEKVResetRootCustomFocusView(); - [view removeFromSuperview]; - [_rootController.view addSubview:view]; - - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"the parked focus request is single-shot and must not replay on a second attach"); -} - -- (void)test_autoFocus_attach_focusesAfterDoubleDispatch { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = YES; - - [_rootController.view addSubview:view]; - - RNCEKVDrainMainQueue(1); - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"the inner dispatch_async is still queued after a single drain cycle"); - - RNCEKVDrainMainQueue(1); - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, - @"focus should land only once both nested dispatch_async blocks have run"); -} - -- (void)test_autoFocus_generationBumped_staleDispatchDiscarded { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = YES; - - [_rootController.view addSubview:view]; - [view cleanReferences]; - - RNCEKVDrainMainQueue(2); - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView, - @"cleanReferences bumps the autofocus generation, so the already-dispatched request is stale"); - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"cleanReferences bumps the autofocus generation, so the already-dispatched request is stale"); -} - -- (void)test_autoFocus_detachedBeforeDispatch_retriesOnNextAttach { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = YES; - - [_rootController.view addSubview:view]; - [view removeFromSuperview]; - - RNCEKVDrainMainQueue(2); - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"the window guard discards the dispatched autofocus while detached"); - - [_rootController.view addSubview:view]; - RNCEKVDrainMainQueue(2); - - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view, - @"the detached skip returns the attempt, so the next attach retries autofocus"); -} - -- (void)test_autoFocus_viewDeallocatedBeforeDispatch_noCrash { - @autoreleasepool { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = YES; - - [_rootController.view addSubview:view]; - [view removeFromSuperview]; - } - - RNCEKVDrainMainQueue(2); - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - XCTAssertNil(_rootController.rncekvCustomFocusView); -} - -- (void)test_autoFocus_singleShot_noRescheduleOnReattach { - RNCEKVExternalKeyboardView *view = [self makeView]; - view.autoFocus = YES; - - [_rootController.view addSubview:view]; - RNCEKVDrainMainQueue(2); - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); - - _rootController.rncekvCustomFocusView = nil; - RNCEKVResetRootCustomFocusView(); - [view removeFromSuperview]; - [_rootController.view addSubview:view]; - RNCEKVDrainMainQueue(2); - - XCTAssertNil(_rootController.rncekvCustomFocusView, - @"_autoFocusRequested is a single-shot latch; re-attaching without cleanReferences must not reschedule"); -} - -- (void)test_focus_controllerPresent_windowNil_parks_thenReplays { - UIViewController *vc = [UIViewController new]; - RNCEKVExternalKeyboardView *view = [self makeView]; - [vc.view addSubview:view]; - - [view focus]; - XCTAssertNil(vc.rncekvCustomFocusView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - window.rootViewController = vc; - window.hidden = NO; - - XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); - - window.hidden = YES; -} - -- (void)test_focus_attached_routesToOwnWindowRoot_notKeyRoot { - RNCEKVExternalKeyboardView *view = [self makeView]; - [_rootController.view addSubview:view]; - - [view focus]; - - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_detach_clearsOwnRoutedPreference { - RNCEKVExternalKeyboardView *view = [self makeView]; - [_rootController.view addSubview:view]; - [view focus]; - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, view); - - [view removeFromSuperview]; - - XCTAssertNil(_rootController.rncekvCustomFocusView); -} - -- (void)test_detach_preservesForeignPreference { - RNCEKVExternalKeyboardView *view = [self makeView]; - [_rootController.view addSubview:view]; - [view focus]; - - UIView *other = [UIView new]; - _rootController.rncekvCustomFocusView = other; - [view removeFromSuperview]; - - XCTAssertEqualObjects(_rootController.rncekvCustomFocusView, other); -} - -- (void)test_cleanReferences_clearsOwnRoutedPreference { - RNCEKVExternalKeyboardView *view = [self makeView]; - [_rootController.view addSubview:view]; - [view focus]; - - [view cleanReferences]; - - XCTAssertNil(_rootController.rncekvCustomFocusView); -} - -- (void)test_screenReaderFocus_detached_parks_noDispatch { - RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; - - NSUInteger baseline = spy.focusTargetQueryCount; - [spy screenReaderFocus]; - RNCEKVDrainMainQueue(1); - - XCTAssertEqual(spy.focusTargetQueryCount - baseline, 0u); -} - -- (void)test_screenReaderFocus_replaysOnAttach { - RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; - - [spy screenReaderFocus]; - NSUInteger baseline = spy.focusTargetQueryCount; - [_rootController.view addSubview:spy]; - RNCEKVDrainMainQueue(1); - - XCTAssertEqual(spy.focusTargetQueryCount - baseline, 1u); -} - -- (void)test_screenReaderFocus_attached_postsAfterDispatch { - RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; - [_rootController.view addSubview:spy]; - - NSUInteger baseline = spy.focusTargetQueryCount; - [spy screenReaderFocus]; - RNCEKVDrainMainQueue(1); - - XCTAssertEqual(spy.focusTargetQueryCount - baseline, 1u); -} - -- (void)test_cleanReferences_clearsPendingScreenReaderFocus { - RNCEKVScreenReaderSpyView *spy = [[RNCEKVScreenReaderSpyView alloc] initWithFrame:CGRectMake(0, 0, 44, 44)]; - - [spy screenReaderFocus]; - [spy cleanReferences]; - NSUInteger baseline = spy.focusTargetQueryCount; - [_rootController.view addSubview:spy]; - RNCEKVDrainMainQueue(1); - - XCTAssertEqual(spy.focusTargetQueryCount - baseline, 0u); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm deleted file mode 100644 index 64c084f..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVFocusSequenceDelegateTests.mm +++ /dev/null @@ -1,314 +0,0 @@ -// -// RNCEKVFocusSequenceDelegateTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import -#import - -#import "RNCEKVFocusSequenceDelegate.h" -#import "RNCEKVOrderLinking.h" -#import "RNCEKVOrderRelationship.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -#pragma mark - RNCEKVSequenceDelegateSpy - -// Overrides the two focus-routing exit points without calling super, so a -// test can assert which view a navigation decision targeted without driving -// RNCEKVKeyboardFocusService or the real UIKit focus engine. -@interface RNCEKVSequenceDelegateSpy : RNCEKVFocusSequenceDelegate - -@property (nonatomic, strong) UIView *keyboardedFocusTarget; -@property (nonatomic, strong) UIView *defaultFocusTarget; -@property (nonatomic, assign) NSUInteger keyboardedFocusCallCount; -@property (nonatomic, assign) NSUInteger defaultFocusCallCount; - -@end - -@implementation RNCEKVSequenceDelegateSpy - -- (void)keyboardedViewFocus:(UIView *)view { - _keyboardedFocusTarget = view; - _keyboardedFocusCallCount += 1; -} - -- (void)defaultViewFocus:(UIView *)view { - _defaultFocusTarget = view; - _defaultFocusCallCount += 1; -} - -@end - -#pragma mark - RNCEKVFocusSequenceDelegateTests - -@interface RNCEKVFocusSequenceDelegateTests : XCTestCase -@end - -@implementation RNCEKVFocusSequenceDelegateTests { - NSMutableArray *_registrations; - NSMutableArray *_windows; -} - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); - _registrations = [NSMutableArray array]; - _windows = [NSMutableArray array]; -} - -- (void)tearDown { - for (NSArray *registration in _registrations) { - [[RNCEKVOrderLinking sharedInstance] remove:registration[0] withOrderKey:registration[1]]; - } - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -#pragma mark - Helpers - -- (NSString *)uniqueOrderGroup { - return [NSUUID UUID].UUIDString; -} - -- (RNCEKVOrderHostDouble *)hostWithGroup:(NSString *)group position:(NSNumber *)position { - RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; - host.orderGroup = group; - host.orderPosition = position; - return host; -} - -- (RNCEKVFocusableItemDouble *)registerItemAtPosition:(NSNumber *)position group:(NSString *)group { - RNCEKVFocusableItemDouble *item = [[RNCEKVFocusableItemDouble alloc] initWithFrame:CGRectZero]; - [[RNCEKVOrderLinking sharedInstance] add:position withOrderKey:group withObject:item]; - [_registrations addObject:@[ position, group ]]; - return item; -} - -- (UIView *)viewInWindow { - UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; - [window addSubview:view]; - [_windows addObject:window]; - return view; -} - -// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*) — see -// RNCEKVTestFocusContext's header comment for why the double isn't a real -// UIFocusUpdateContext subclass and needs the cast below. -- (UIFocusUpdateContext *)contextWithPrevious:(id)previous - next:(id)next - heading:(UIFocusHeading)heading { - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.previouslyFocusedItem = previous; - context.nextFocusedItem = next; - context.focusHeading = heading; - return (UIFocusUpdateContext *)context; -} - -#pragma mark - handleNextFocus: entry / boundary / middle / no-exit - -- (void)test_entryView_next_focusesFirstItem_returnsHandled { - NSString *group = [self uniqueOrderGroup]; - RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; - UIView *entry = [self viewInWindow]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - relationship.entry = entry; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - BOOL handled = [spy handleNextFocus:entry currentIndex:-1 orderRelationship:relationship]; - - XCTAssertTrue(handled); - XCTAssertEqualObjects(spy.keyboardedFocusTarget, item0); - - NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:entry - next:nil - heading:UIFocusHeadingNext]]; - - XCTAssertEqualObjects(result, @0); -} - -- (void)test_lastItem_next_withExit_routesToExit { - NSString *group = [self uniqueOrderGroup]; - [self registerItemAtPosition:@0 group:group]; - RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; - UIView *exit = [self viewInWindow]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - relationship.exit = exit; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - BOOL handled = [spy handleNextFocus:item1 currentIndex:1 orderRelationship:relationship]; - - XCTAssertTrue(handled); - XCTAssertEqualObjects(spy.defaultFocusTarget, exit); - XCTAssertNil(spy.keyboardedFocusTarget); -} - -- (void)test_middleItem_next_focusesNextItem { - NSString *group = [self uniqueOrderGroup]; - RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; - RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; - [self registerItemAtPosition:@2 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - BOOL handled = [spy handleNextFocus:item0 currentIndex:0 orderRelationship:relationship]; - - XCTAssertTrue(handled); - XCTAssertEqualObjects(spy.keyboardedFocusTarget, item1); -} - -- (void)test_lastItem_next_withoutExit_returnsNO_noFocus { - NSString *group = [self uniqueOrderGroup]; - [self registerItemAtPosition:@0 group:group]; - RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - BOOL handled = [spy handleNextFocus:item1 currentIndex:1 orderRelationship:relationship]; - - XCTAssertFalse(handled); - XCTAssertEqual(spy.keyboardedFocusCallCount, (NSUInteger)0); - XCTAssertEqual(spy.defaultFocusCallCount, (NSUInteger)0); -} - -#pragma mark - shouldUpdateFocusInContext: stale entry/exit revalidation - -- (void)test_staleEntry_windowless_clearedAndRecaptured { - NSString *group = [self uniqueOrderGroup]; - [self registerItemAtPosition:@0 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - UIView *staleEntry = [[UIView alloc] initWithFrame:CGRectZero]; - relationship.entry = staleEntry; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - UIView *outsideC = [[UIView alloc] initWithFrame:CGRectZero]; - [spy shouldUpdateFocusInContext:[self contextWithPrevious:outsideC next:nil heading:UIFocusHeadingNext]]; - - XCTAssertEqualObjects(relationship.entry, outsideC); -} - -- (void)test_staleExit_windowless_cleared { - NSString *group = [self uniqueOrderGroup]; - RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - UIView *staleExit = [[UIView alloc] initWithFrame:CGRectZero]; - relationship.exit = staleExit; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - UIView *outsideD = [[UIView alloc] initWithFrame:CGRectZero]; - [spy shouldUpdateFocusInContext:[self contextWithPrevious:item0 next:outsideD heading:UIFocusHeadingNext]]; - - XCTAssertEqualObjects(relationship.exit, outsideD); -} - -- (void)test_liveEntry_inWindow_notCleared_notOverwritten { - NSString *group = [self uniqueOrderGroup]; - [self registerItemAtPosition:@0 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - UIView *liveEntry = [self viewInWindow]; - relationship.entry = liveEntry; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - UIView *outsideB = [[UIView alloc] initWithFrame:CGRectZero]; - [spy shouldUpdateFocusInContext:[self contextWithPrevious:outsideB next:nil heading:UIFocusHeadingNext]]; - - XCTAssertEqualObjects(relationship.entry, liveEntry); -} - -#pragma mark - defaultViewFocus: real routing - -- (void)test_defaultViewFocus_routesThroughService_toRootController { - NSString *group = [self uniqueOrderGroup]; - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVFocusSequenceDelegate *delegate = [[RNCEKVFocusSequenceDelegate alloc] initWithView:host]; - - UIView *target = [[UIView alloc] initWithFrame:CGRectZero]; - [delegate defaultViewFocus:target]; - - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, target); -} - -#pragma mark - shouldUpdateFocusInContext: previous heading and empty group - -- (void)test_previousHeading_middleItem_focusesPreviousItem_handled { - NSString *group = [self uniqueOrderGroup]; - RNCEKVFocusableItemDouble *item0 = [self registerItemAtPosition:@0 group:group]; - RNCEKVFocusableItemDouble *item1 = [self registerItemAtPosition:@1 group:group]; - - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:item1 - next:nil - heading:UIFocusHeadingPrevious]]; - - XCTAssertEqualObjects(result, @0); - XCTAssertEqualObjects(spy.keyboardedFocusTarget, item0); -} - -- (void)test_emptyGroup_returnsDefault { - NSString *group = [self uniqueOrderGroup]; - RNCEKVOrderHostDouble *host = [self hostWithGroup:group position:@0]; - RNCEKVSequenceDelegateSpy *spy = [[RNCEKVSequenceDelegateSpy alloc] initWithView:host]; - - UIView *previous = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *next = [[UIView alloc] initWithFrame:CGRectZero]; - NSNumber *result = [spy shouldUpdateFocusInContext:[self contextWithPrevious:previous - next:next - heading:UIFocusHeadingNext]]; - - XCTAssertNil(result); - XCTAssertNil([[RNCEKVOrderLinking sharedInstance] getInfo:group]); -} - -#pragma mark - RNCEKVOrderRelationship.clear endpoint cleanup - -- (void)test_relationshipClear_nilsEntryAndExit { - RNCEKVOrderRelationship *relationship = [RNCEKVOrderRelationship new]; - relationship.entry = [self viewInWindow]; - relationship.exit = [self viewInWindow]; - - [relationship clear]; - - XCTAssertNil(relationship.entry); - XCTAssertNil(relationship.exit); - XCTAssertEqual([relationship count], 0); -} - -- (void)test_lastMemberRemoved_clearsEndpoints { - NSString *group = [self uniqueOrderGroup]; - [self registerItemAtPosition:@0 group:group]; - - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderLinking sharedInstance] getInfo:group]; - relationship.entry = [self viewInWindow]; - relationship.exit = [self viewInWindow]; - - [[RNCEKVOrderLinking sharedInstance] remove:@0 withOrderKey:group]; - - XCTAssertNil(relationship.entry, @"emptying the group clears its endpoints"); - XCTAssertNil(relationship.exit); - XCTAssertNil([[RNCEKVOrderLinking sharedInstance] getInfo:group]); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm deleted file mode 100644 index c31e6d8..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVKeyboardFocusServiceTests.mm +++ /dev/null @@ -1,105 +0,0 @@ -// -// RNCEKVKeyboardFocusServiceTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import - -#import "RNCEKVKeyboardFocusService.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -@interface RNCEKVKeyboardFocusServiceTests : XCTestCase -@end - -@implementation RNCEKVKeyboardFocusServiceTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (void)test_focusNil_preservesExistingCustomFocusView { - UIViewController *rootController = RCTKeyWindow().rootViewController; - XCTAssertNotNil(rootController); - - UIView *existingFocusView = [UIView new]; - rootController.rncekvCustomFocusView = existingFocusView; - UIViewController *fallbackController = [UIViewController new]; - - [RNCEKVKeyboardFocusService focus:nil withFallback:fallbackController]; - - XCTAssertEqual(rootController.rncekvCustomFocusView, existingFocusView); -} - -- (void)test_focus_windowlessTarget_fallsBackToKeyWindowRoot { - UIViewController *rootController = RCTKeyWindow().rootViewController; - XCTAssertNotNil(rootController); - - UIViewController *fallbackController = [UIViewController new]; - UIView *focusTarget = [UIView new]; - - [RNCEKVKeyboardFocusService focus:focusTarget withFallback:fallbackController]; - - XCTAssertEqual(rootController.rncekvCustomFocusView, focusTarget); - XCTAssertNil(fallbackController.rncekvCustomFocusView); -} - -- (void)test_focusWrapper_delegatesToFallbackVariant { - UIViewController *rootController = RCTKeyWindow().rootViewController; - XCTAssertNotNil(rootController); - - UIView *focusTarget = [UIView new]; - - [RNCEKVKeyboardFocusService focus:focusTarget]; - - XCTAssertEqual(rootController.rncekvCustomFocusView, focusTarget); -} - -- (void)test_focus_targetWithWindow_prefersTargetWindowRoot { - UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - localWindow.rootViewController = [UIViewController new]; - UIView *target = [UIView new]; - [localWindow.rootViewController.view addSubview:target]; - localWindow.hidden = NO; - - UIViewController *fallback = [UIViewController new]; - - [RNCEKVKeyboardFocusService focus:target withFallback:fallback]; - - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, target); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - XCTAssertNil(fallback.rncekvCustomFocusView); - - localWindow.hidden = YES; -} - -- (void)test_focus_returnsRoutedController { - UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - localWindow.rootViewController = [UIViewController new]; - UIView *target = [UIView new]; - [localWindow.rootViewController.view addSubview:target]; - localWindow.hidden = NO; - - UIViewController *fallback = [UIViewController new]; - - UIViewController *routed = [RNCEKVKeyboardFocusService focus:target withFallback:fallback]; - XCTAssertEqualObjects(routed, localWindow.rootViewController); - - XCTAssertEqualObjects([RNCEKVKeyboardFocusService focus:[UIView new] withFallback:fallback], - RCTKeyWindow().rootViewController); - - localWindow.hidden = YES; -} - -- (void)test_focus_nilView_returnsNil { - XCTAssertNil([RNCEKVKeyboardFocusService focus:nil withFallback:[UIViewController new]]); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm deleted file mode 100644 index 79cebc7..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVLockViewTests.mm +++ /dev/null @@ -1,353 +0,0 @@ -// -// RNCEKVLockViewTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import -#import - -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVExternalKeyboardLockView.h" -#import "RNCEKVTestSupport.h" - -#ifdef RCT_NEW_ARCH_ENABLED -#import -#endif - -#pragma mark - RNCEKVLockViewSpy - -// Overrides the transition-gated focus requests without calling super, so -// setForceLock:/setLockDisabled: gating (becoming active, staying active, -// re-activating) can be asserted in isolation from what a real request -// would do (route through RNCEKVKeyboardFocusService, post an -// accessibility notification). -@interface RNCEKVLockViewSpy : RNCEKVExternalKeyboardLockView - -@property (nonatomic, assign) NSUInteger requestFocusCount; -@property (nonatomic, assign) NSUInteger requestScreenReaderFocusCount; - -@end - -@implementation RNCEKVLockViewSpy - -- (void)requestFocus { - self.requestFocusCount += 1; -} - -- (void)requestScreenReaderFocus { - self.requestScreenReaderFocusCount += 1; -} - -@end - -#pragma mark - RNCEKVLockViewPropsSpy - -// Counts setForceLock:/setLockDisabled: invocations while still calling -// super, so updateProps:oldProps: can be asserted to invoke the setter -// only when the incoming Fabric prop differs from the current ivar. -@interface RNCEKVLockViewPropsSpy : RNCEKVExternalKeyboardLockView - -@property (nonatomic, assign) NSUInteger forceLockSetterCount; -@property (nonatomic, assign) NSUInteger lockDisabledSetterCount; - -@end - -@implementation RNCEKVLockViewPropsSpy - -- (void)setForceLock:(BOOL)forceLock { - self.forceLockSetterCount += 1; - [super setForceLock:forceLock]; -} - -- (void)setLockDisabled:(BOOL)lockDisabled { - self.lockDisabledSetterCount += 1; - [super setLockDisabled:lockDisabled]; -} - -@end - -#pragma mark - Detached window helper - -// A UIWindow distinct from the host app's real key window, so a real -// (non-spy) lock view can resolve `reactViewController` without touching -// the app's actual view hierarchy. RCTKeyWindow() keeps returning the -// host app's window throughout, which is what the routing tests observe. -static UIWindow *RNCEKVMakeDetachedWindowWithRootViewController(void) { - UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - window.rootViewController = [[UIViewController alloc] init]; - return window; -} - -#pragma mark - Tests - -@interface RNCEKVLockViewTests : XCTestCase -@end - -@implementation RNCEKVLockViewTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -#pragma mark setForceLock: / setLockDisabled: transition gating - -- (void)test_forceLock_offToOn_requestsFocusAndScreenReaderOnce { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - - lockView.forceLock = YES; - - XCTAssertEqual(lockView.requestFocusCount, 1u); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, 1u); -} - -- (void)test_forceLock_repeatedYES_noReRequest { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - - lockView.forceLock = YES; - lockView.forceLock = YES; - - XCTAssertEqual(lockView.requestFocusCount, 1u); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, 1u); -} - -- (void)test_forceLock_whileDisabled_noRequest { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - - lockView.lockDisabled = YES; - lockView.forceLock = YES; - - XCTAssertEqual(lockView.requestFocusCount, 0u); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, 0u); -} - -- (void)test_lockDisabled_liftedWhileForceLocked_reRequests { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - lockView.forceLock = YES; - lockView.lockDisabled = YES; - NSUInteger requestFocusCountBeforeLift = lockView.requestFocusCount; - NSUInteger requestScreenReaderFocusCountBeforeLift = lockView.requestScreenReaderFocusCount; - - lockView.lockDisabled = NO; - - XCTAssertEqual(lockView.requestFocusCount, requestFocusCountBeforeLift + 1); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, requestScreenReaderFocusCountBeforeLift + 1); -} - -- (void)test_lockDisabled_turnedOn_noRequest { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - lockView.forceLock = YES; - NSUInteger requestFocusCountAfterActivation = lockView.requestFocusCount; - NSUInteger requestScreenReaderFocusCountAfterActivation = lockView.requestScreenReaderFocusCount; - - lockView.lockDisabled = YES; - - XCTAssertEqual(lockView.requestFocusCount, requestFocusCountAfterActivation); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, requestScreenReaderFocusCountAfterActivation); -} - -#pragma mark requestFocus routing (real view) - -- (void)test_requestFocus_realView_routesThroughService_toOwnWindowRoot { - UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); - detachedWindow.hidden = NO; - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - [detachedWindow.rootViewController.view addSubview:lockView]; - - lockView.forceLock = YES; - - XCTAssertEqualObjects(detachedWindow.rootViewController.rncekvCustomFocusView, lockView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - detachedWindow.hidden = YES; -} - -- (void)test_requestFocus_inactiveGate_noRouting { - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - lockView.lockDisabled = YES; - - UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); - [detachedWindow.rootViewController.view addSubview:lockView]; - - [lockView requestFocus]; - - UIViewController *keyRootController = RCTKeyWindow().rootViewController; - XCTAssertNotNil(keyRootController); - XCTAssertNil(keyRootController.rncekvCustomFocusView); -} - -- (void)test_didMoveToWindow_inactiveDefaults_noRequest { - UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); - detachedWindow.hidden = NO; - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - - [detachedWindow.rootViewController.view addSubview:lockView]; - - XCTAssertNil(detachedWindow.rootViewController.rncekvCustomFocusView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - detachedWindow.hidden = YES; -} - -- (void)test_didMoveToWindow_disabledTrap_noRequest { - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - lockView.forceLock = YES; - lockView.lockDisabled = YES; - - UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); - detachedWindow.hidden = NO; - - [detachedWindow.rootViewController.view addSubview:lockView]; - - XCTAssertNil(detachedWindow.rootViewController.rncekvCustomFocusView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - detachedWindow.hidden = YES; -} - -- (void)test_attach_activeTrap_requestReplaysToOwnWindowRoot { - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)]; - lockView.forceLock = YES; - - UIWindow *detachedWindow = RNCEKVMakeDetachedWindowWithRootViewController(); - detachedWindow.hidden = NO; - - [detachedWindow.rootViewController.view addSubview:lockView]; - - XCTAssertEqualObjects(detachedWindow.rootViewController.rncekvCustomFocusView, lockView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - detachedWindow.hidden = YES; -} - -#pragma mark shouldUpdateFocusInContext: - -- (void)test_shouldUpdateFocus_forceLock_blocksMoveOutsideSubtree { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - lockView.forceLock = YES; - - UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = outsideView; - - // -shouldUpdateFocusInContext: is UIKit-declared with a _Nonnull-audited - // UIFocusUpdateContext parameter, so the double needs an explicit cast - // here (see RNCEKVTestFocusContext's header comment). - XCTAssertFalse([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); -} - -- (void)test_shouldUpdateFocus_noForceLock_allowsOutsideMove { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - - UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = outsideView; - - XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); -} - -- (void)test_shouldUpdateFocus_lockDisabled_bypassesLock { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - lockView.forceLock = YES; - lockView.lockDisabled = YES; - - UIView *outsideView = [[UIView alloc] initWithFrame:CGRectZero]; - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = outsideView; - - XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); -} - -- (void)test_shouldUpdateFocus_insideMove_allowedUnderLock { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - lockView.forceLock = YES; - - UIView *childView = [[UIView alloc] initWithFrame:CGRectZero]; - [lockView addSubview:childView]; - - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = childView; - - XCTAssertTrue([lockView shouldUpdateFocusInContext:(UIFocusUpdateContext *)context]); -} - -#pragma mark updateProps:oldProps: - -#ifdef RCT_NEW_ARCH_ENABLED - -- (void)test_updateProps_unchangedValues_settersNotInvoked { - RNCEKVLockViewPropsSpy *lockView = [[RNCEKVLockViewPropsSpy alloc] initWithFrame:CGRectZero]; - - auto changedProps = std::make_shared(); - changedProps->forceLock = true; - changedProps->lockDisabled = true; - facebook::react::Props::Shared newProps = changedProps; - facebook::react::Props::Shared oldProps = - std::make_shared(); - - [lockView updateProps:newProps oldProps:oldProps]; - - XCTAssertEqual(lockView.forceLockSetterCount, 1u); - XCTAssertEqual(lockView.lockDisabledSetterCount, 1u); - - auto sameProps = std::make_shared(); - sameProps->forceLock = true; - sameProps->lockDisabled = true; - facebook::react::Props::Shared repeatedProps = sameProps; - - [lockView updateProps:repeatedProps oldProps:newProps]; - - XCTAssertEqual(lockView.forceLockSetterCount, 1u); - XCTAssertEqual(lockView.lockDisabledSetterCount, 1u); -} - -- (void)test_updateProps_compoundForceLockAndDisable_noRequest { - RNCEKVLockViewSpy *lockView = [[RNCEKVLockViewSpy alloc] initWithFrame:CGRectZero]; - - auto newViewProps = std::make_shared(); - newViewProps->forceLock = true; - newViewProps->lockDisabled = true; - facebook::react::Props::Shared newProps = newViewProps; - facebook::react::Props::Shared oldProps = - std::make_shared(); - - [lockView updateProps:newProps oldProps:oldProps]; - - XCTAssertEqual(lockView.requestFocusCount, 0u); - XCTAssertEqual(lockView.requestScreenReaderFocusCount, 0u); -} - -#endif /* RCT_NEW_ARCH_ENABLED */ - -#pragma mark dealloc - -- (void)test_dealloc_removesNotificationObserver_noCrash { - __weak RNCEKVExternalKeyboardLockView *weakLockView; - @autoreleasepool { - RNCEKVExternalKeyboardLockView *lockView = - [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectZero]; - UIView *container = [[UIView alloc] initWithFrame:CGRectZero]; - [container addSubview:lockView]; - weakLockView = lockView; - } - XCTAssertNil(weakLockView); - - [[NSNotificationCenter defaultCenter] postNotificationName:UIAccessibilityElementFocusedNotification - object:nil - userInfo:@{}]; - - XCTAssertNil(weakLockView, @"posting after dealloc must not resurrect or crash"); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm deleted file mode 100644 index ba89fcd..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVOrderGroupBaseTests.mm +++ /dev/null @@ -1,142 +0,0 @@ -// -// RNCEKVOrderGroupBaseTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import -#import - -#import "RNCEKVExternalKeyboardView.h" -#import "RNCEKVViewOrderGroupBase.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -@interface RNCEKVOrderGroupBaseTests : XCTestCase -@end - -@implementation RNCEKVOrderGroupBaseTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -/// Attaches `view` under a local (non-key) window's root controller so -/// `reactViewController` resolves without touching the host app's real key window. -- (UIWindow *)attachUnderLocalRootController:(UIView *)view { - UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIViewController *localController = [UIViewController new]; - localWindow.rootViewController = localController; - [localController.view addSubview:view]; - localWindow.hidden = NO; - return localWindow; -} - -- (void)test_getIsViewFocused_descendantNext_true { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *grandchild = [[UIView alloc] initWithFrame:CGRectZero]; - [view addSubview:child]; - [child addSubview:grandchild]; - - RNCEKVTestFocusContext *childContext = [RNCEKVTestFocusContext new]; - childContext.nextFocusedView = child; - XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)childContext]); - - RNCEKVTestFocusContext *grandchildContext = [RNCEKVTestFocusContext new]; - grandchildContext.nextFocusedView = grandchild; - XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)grandchildContext]); -} - -- (void)test_getIsViewFocused_outsideOrNilNext_false { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - - RNCEKVTestFocusContext *outsideContext = [RNCEKVTestFocusContext new]; - outsideContext.nextFocusedView = outside; - XCTAssertFalse([view getIsViewFocused:(UIFocusUpdateContext *)outsideContext]); - - RNCEKVTestFocusContext *nilContext = [RNCEKVTestFocusContext new]; - nilContext.nextFocusedView = nil; - XCTAssertFalse([view getIsViewFocused:(UIFocusUpdateContext *)nilContext]); -} - -// Both tests below target -[RNCEKVViewOrderGroupBase focus] directly (not -// -[RNCEKVViewFocusRequestBase focus], the version that "focus" resolves to -// on the concrete RNCEKVExternalKeyboardView chain, which routes self -// through the service instead of getStoredView) — a plain -// RNCEKVViewOrderGroupBase instance, rather than a leaf view further down -// the base chain, is required for Objective-C dynamic dispatch to reach -// this exact override. -- (void)test_focus_attached_routesStoredViewThroughService { - RNCEKVViewOrderGroupBase *view = [[RNCEKVViewOrderGroupBase alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [view addSubview:child]; - - UIWindow *localWindow = [self attachUnderLocalRootController:view]; - XCTAssertNotNil(localWindow.rootViewController, @"reactViewController resolution requires a live root controller"); - - [view focus]; - - XCTAssertEqualObjects([view getStoredView], child); - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, [view getStoredView]); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_focus_detached_noop { - RNCEKVViewOrderGroupBase *view = [[RNCEKVViewOrderGroupBase alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [view addSubview:child]; - - [view focus]; - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_getIsViewFocused_selfNext_true { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = view; - - XCTAssertTrue([view getIsViewFocused:(UIFocusUpdateContext *)context]); -} - -- (void)test_getIsViewFocused_nestedWrapperNext_falseForParent_trueForNested { - RNCEKVExternalKeyboardView *parent = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - UIView *mid = [[UIView alloc] initWithFrame:CGRectZero]; - RNCEKVExternalKeyboardView *nested = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - [parent addSubview:mid]; - [mid addSubview:nested]; - - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = nested; - - XCTAssertFalse([parent getIsViewFocused:(UIFocusUpdateContext *)context], - @"a nested wrapper owns its own focus"); - XCTAssertTrue([nested getIsViewFocused:(UIFocusUpdateContext *)context]); -} - -- (void)test_getIsViewFocused_childInsideNestedWrapper_falseForParent_trueForNested { - RNCEKVExternalKeyboardView *parent = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - UIView *mid = [[UIView alloc] initWithFrame:CGRectZero]; - RNCEKVExternalKeyboardView *nested = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - UIView *leaf = [[UIView alloc] initWithFrame:CGRectZero]; - [parent addSubview:mid]; - [mid addSubview:nested]; - [nested addSubview:leaf]; - - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = leaf; - - XCTAssertFalse([parent getIsViewFocused:(UIFocusUpdateContext *)context]); - XCTAssertTrue([nested getIsViewFocused:(UIFocusUpdateContext *)context]); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm deleted file mode 100644 index 7d02e14..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVRetainCycleTests.mm +++ /dev/null @@ -1,191 +0,0 @@ -// -// RNCEKVRetainCycleTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import - -#import "RNCEKVExternalKeyboardView.h" -#import "RNCEKVTextInputFocusWrapper.h" -#import "RNCEKVExternalKeyboardLockView.h" -#import "RNCEKVFocusDelegate.h" -#import "RNCEKVFocusLinkDelegate.h" -#import "RNCEKVFocusSequenceDelegate.h" -#import "RNCEKVGroupIdentifierDelegate.h" -#import "RNCEKVGroupIdentifierProtocol.h" -#import "RNCEKVHaloDelegate.h" -#import "RNCEKVHaloProtocol.h" -#import "RNCEKVOrderRelationship.h" - -#import "RNCEKVTestSupport.h" - -#pragma mark - Host doubles outside RNCEKVTestSupport's coverage - -// RNCEKVTestSupport doubles RNCEKVFocusProtocol and RNCEKVFocusOrderProtocol hosts -// only. RNCEKVGroupIdentifierDelegate and RNCEKVHaloDelegate need hosts for their own -// protocols, so those two doubles are file-local per the test plan. - -@interface RNCEKVGroupIdHostDouble : UIView -@property (nonatomic, copy) NSString *customGroupId; -@end - -@implementation RNCEKVGroupIdHostDouble -- (UIView *)getFocusTargetView { - return self; -} -@end - -@interface RNCEKVHaloHostDouble : UIView -@property (nonatomic, assign) BOOL isHaloHidden; -@property (nonatomic, assign) CGFloat haloCornerRadius; -@property (nonatomic, assign) CGFloat haloExpendX; -@property (nonatomic, assign) CGFloat haloExpendY; -@property (nonatomic, assign) BOOL roundedHaloFix; -@end - -@implementation RNCEKVHaloHostDouble -- (UIView *)getFocusTargetView { - return self; -} -@end - -@interface RNCEKVRetainCycleTests : XCTestCase -@end - -@implementation RNCEKVRetainCycleTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (void)test_externalKeyboardView_deallocates_noDelegateCycle { - __weak RNCEKVExternalKeyboardView *weakView; - @autoreleasepool { - RNCEKVExternalKeyboardView *view = [[RNCEKVExternalKeyboardView alloc] initWithFrame:CGRectZero]; - weakView = view; - } - XCTAssertNil(weakView); -} - -- (void)test_textInputFocusWrapper_deallocates { - __weak RNCEKVTextInputFocusWrapper *weakWrapper; - @autoreleasepool { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - weakWrapper = wrapper; - } - XCTAssertNil(weakWrapper); -} - -- (void)test_eachDelegate_survivesHostDealloc_lateCallsSafe { - { - __weak RNCEKVFocusHostDouble *weakHost; - RNCEKVFocusDelegate *delegate; - @autoreleasepool { - RNCEKVFocusHostDouble *host = [[RNCEKVFocusHostDouble alloc] initWithFrame:CGRectZero]; - host.canBeFocused = YES; - host.focusableWrapper = NO; - weakHost = host; - delegate = [[RNCEKVFocusDelegate alloc] initWithView:host]; - } - XCTAssertNil(weakHost); - XCTAssertNoThrow([delegate getFocusingView]); - XCTAssertNil([delegate getFocusingView]); - XCTAssertNoThrow([delegate canBecomeFocused]); - XCTAssertFalse([delegate canBecomeFocused]); - } - - { - __weak RNCEKVOrderHostDouble *weakHost; - RNCEKVFocusSequenceDelegate *delegate; - @autoreleasepool { - RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; - weakHost = host; - delegate = [[RNCEKVFocusSequenceDelegate alloc] initWithView:host]; - } - XCTAssertNil(weakHost); - UIFocusUpdateContext *context = (UIFocusUpdateContext *)[RNCEKVTestFocusContext new]; - XCTAssertNoThrow([delegate shouldUpdateFocusInContext:context]); - XCTAssertNil([delegate shouldUpdateFocusInContext:context]); - } - - { - __weak RNCEKVOrderHostDouble *weakHost; - RNCEKVFocusLinkDelegate *delegate; - @autoreleasepool { - RNCEKVOrderHostDouble *host = [[RNCEKVOrderHostDouble alloc] initWithFrame:CGRectZero]; - weakHost = host; - delegate = [[RNCEKVFocusLinkDelegate alloc] initWithView:host]; - } - XCTAssertNil(weakHost); - UIFocusUpdateContext *context = (UIFocusUpdateContext *)[RNCEKVTestFocusContext new]; - XCTAssertNoThrow([delegate shouldUpdateFocusInContext:context]); - XCTAssertNil([delegate shouldUpdateFocusInContext:context]); - } - - { - __weak RNCEKVGroupIdHostDouble *weakHost; - RNCEKVGroupIdentifierDelegate *delegate; - @autoreleasepool { - RNCEKVGroupIdHostDouble *host = [[RNCEKVGroupIdHostDouble alloc] initWithFrame:CGRectZero]; - weakHost = host; - delegate = [[RNCEKVGroupIdentifierDelegate alloc] initWithView:host]; - } - XCTAssertNil(weakHost); - NSString *identifier = nil; - XCTAssertNoThrow(identifier = delegate.focusGroupIdentifier); - XCTAssertNotNil(identifier); - } - - if (@available(iOS 15.0, *)) { - __weak RNCEKVHaloHostDouble *weakHost; - RNCEKVHaloDelegate *delegate; - @autoreleasepool { - RNCEKVHaloHostDouble *host = [[RNCEKVHaloHostDouble alloc] initWithFrame:CGRectZero]; - weakHost = host; - delegate = [[RNCEKVHaloDelegate alloc] initWithView:host]; - } - XCTAssertNil(weakHost); - UIFocusEffect *effect = nil; - XCTAssertNoThrow(effect = delegate.focusEffect); - XCTAssertNil(effect); - } -} - -- (void)test_orderRelationship_entryExit_zeroOnDealloc { - RNCEKVOrderRelationship *relationship = [[RNCEKVOrderRelationship alloc] init]; - __weak UIView *weakEntry; - __weak UIView *weakExit; - @autoreleasepool { - UIView *entry = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *exit = [[UIView alloc] initWithFrame:CGRectZero]; - relationship.entry = entry; - relationship.exit = exit; - weakEntry = entry; - weakExit = exit; - } - XCTAssertNil(weakEntry); - XCTAssertNil(weakExit); - XCTAssertNil(relationship.entry); - XCTAssertNil(relationship.exit); -} - -- (void)test_lockView_deallocates { - __weak RNCEKVExternalKeyboardLockView *weakLockView; - @autoreleasepool { - RNCEKVExternalKeyboardLockView *lockView = [[RNCEKVExternalKeyboardLockView alloc] initWithFrame:CGRectZero]; - UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - [window addSubview:lockView]; - [lockView removeFromSuperview]; - weakLockView = lockView; - } - XCTAssertNil(weakLockView); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h deleted file mode 100644 index e3422cb..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.h +++ /dev/null @@ -1,135 +0,0 @@ -// -// RNCEKVTestSupport.h -// ExternalKeyboardExampleTests -// -// Shared test doubles, a settable UIFocusUpdateContext stand-in, a -// main-queue draining helper, and the no-implementation "Testing" -// category declarations that expose private library methods to the -// suite. Every other test file imports this header. -// - -#ifndef RNCEKVTestSupport_h -#define RNCEKVTestSupport_h - -#import - -#import "RNCEKVFocusProtocol.h" -#import "RNCEKVFocusOrderProtocol.h" -#import "RNCEKVKeyboardFocusableProtocol.h" -#import "RNCEKVFocusSequenceDelegate.h" -#import "RNCEKVOrderRelationship.h" -#import "RNCEKVViewOrderGroupBase.h" -#import "RNCEKVExternalKeyboardLockView.h" - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - Testing categories - -// RNCEKVFocusSequenceDelegate's index-navigation and focus-routing methods -// are internal to the .mm and absent from the public header. -@interface RNCEKVFocusSequenceDelegate (Testing) - -- (BOOL)handleNextFocus:(nullable UIView *)current - currentIndex:(NSInteger)currentIndex - orderRelationship:(RNCEKVOrderRelationship *)orderRelationship; - -- (BOOL)handlePrevFocus:(nullable UIView *)current - currentIndex:(NSInteger)currentIndex - orderRelationship:(RNCEKVOrderRelationship *)orderRelationship; - -- (void)defaultViewFocus:(UIView *)view; -- (void)keyboardedViewFocus:(UIView *)view; - -@end - -// RNCEKVViewOrderGroupBase's descendant-focus check is internal to the .mm -// and absent from the public header. -@interface RNCEKVViewOrderGroupBase (Testing) - -- (BOOL)getIsViewFocused:(UIFocusUpdateContext *)context; - -@end - -// RNCEKVExternalKeyboardLockView's focus-routing methods are both private -// and absent from the public header. -@interface RNCEKVExternalKeyboardLockView (Testing) - -- (void)requestFocus; -- (void)requestScreenReaderFocus; - -@end - -#pragma mark - RNCEKVTestFocusContext - -// A UIFocusUpdateContext stand-in exposing the same next/previous focused -// view/item and focus heading accessors UIKit's real context declares -// read-only, so a test can drive isFocusChanged:/shouldUpdateFocusInContext:/ -// getIsViewFocused: with an arbitrary next/previous pair instead of a live -// focus engine. -// -// This does NOT subclass UIFocusUpdateContext: UIFocusUpdateContext has no -// public initializer, and plain [[UIFocusUpdateContext alloc] init] (which -// is all NSObject's default -init gives a subclass) trips an internal -// consistency check ("Invalid parameter not satisfying: focusSystem") on -// current UIKit, so a subclass instance throws at construction time before -// any test body runs. Instead this is a plain NSObject double; callers pass -// it to library methods typed to take UIFocusUpdateContext* via an explicit -// cast — those methods only ever message the five accessors below, which -// this class implements, so dynamic dispatch resolves correctly despite the -// unrelated static type. -@interface RNCEKVTestFocusContext : NSObject - -@property (nonatomic, strong, nullable) UIView *nextFocusedView; -@property (nonatomic, strong, nullable) UIView *previouslyFocusedView; -@property (nonatomic, strong, nullable) id nextFocusedItem; -@property (nonatomic, strong, nullable) id previouslyFocusedItem; -@property (nonatomic, assign) UIFocusHeading focusHeading; - -@end - -#pragma mark - RNCEKVFocusHostDouble - -// Minimal RNCEKVFocusProtocol host for RNCEKVFocusDelegate tests. Both -// protocol methods are backed by a settable property of the same name. -@interface RNCEKVFocusHostDouble : UIView - -@property (nonatomic, assign) BOOL canBeFocused; -@property (nonatomic, assign) BOOL focusableWrapper; - -@end - -#pragma mark - RNCEKVOrderHostDouble - -// Minimal RNCEKVFocusOrderProtocol host for RNCEKVFocusSequenceDelegate / -// RNCEKVViewOrderGroupBase tests. Every order prop declared by the -// protocol is synthesized in the companion .mm; -getFocusTargetView -// returns the double itself. -@interface RNCEKVOrderHostDouble : UIView -@end - -#pragma mark - RNCEKVFocusableItemDouble - -// Records every -focus call it receives, so a test can assert which item -// a sequence/order delegate routed focus to. -@interface RNCEKVFocusableItemDouble : UIView - -@property (nonatomic, assign, readonly) NSUInteger focusCallCount; - -@end - -#pragma mark - Shared helpers - -// Spins the main run loop for `cycles` iterations: each cycle schedules a -// dispatch_async(main) block that fulfills an XCTestExpectation and waits -// on it (5s timeout), draining already-queued main-thread blocks — e.g. -// focusOnMount's nested dispatch_async — in FIFO order without a sleep. -FOUNDATION_EXPORT void RNCEKVDrainMainQueue(NSUInteger cycles); - -// Clears the key window's root view controller's rncekvCustomFocusView. -// Every test class calls this in both setUp and tearDown so tests observe -// a known starting state and don't leak focus state into the next test. -FOUNDATION_EXPORT void RNCEKVResetRootCustomFocusView(void); - -NS_ASSUME_NONNULL_END - -#endif /* RNCEKVTestSupport_h */ diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm deleted file mode 100644 index 5df1313..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVTestSupport.mm +++ /dev/null @@ -1,81 +0,0 @@ -// -// RNCEKVTestSupport.mm -// ExternalKeyboardExampleTests -// - -#import "RNCEKVTestSupport.h" - -#import -#import -#import "UIViewController+RNCEKVExternalKeyboard.h" - -#pragma mark - RNCEKVTestFocusContext - -@implementation RNCEKVTestFocusContext - -// No custom -init: this is a plain NSObject double (see the header comment -// for why it does not subclass UIFocusUpdateContext), so the inherited -// NSObject default is sufficient; the suite constructs it with plain -// [RNCEKVTestFocusContext new]. -@synthesize nextFocusedView = _nextFocusedView; -@synthesize previouslyFocusedView = _previouslyFocusedView; -@synthesize nextFocusedItem = _nextFocusedItem; -@synthesize previouslyFocusedItem = _previouslyFocusedItem; -@synthesize focusHeading = _focusHeading; - -@end - -#pragma mark - RNCEKVFocusHostDouble - -@implementation RNCEKVFocusHostDouble -@end - -#pragma mark - RNCEKVOrderHostDouble - -@implementation RNCEKVOrderHostDouble - -@synthesize orderGroup = _orderGroup; -@synthesize lockFocus = _lockFocus; -@synthesize orderPosition = _orderPosition; -@synthesize orderLeft = _orderLeft; -@synthesize orderRight = _orderRight; -@synthesize orderUp = _orderUp; -@synthesize orderDown = _orderDown; -@synthesize orderForward = _orderForward; -@synthesize orderBackward = _orderBackward; -@synthesize orderLast = _orderLast; -@synthesize orderFirst = _orderFirst; -@synthesize orderId = _orderId; - -- (UIView *)getFocusTargetView { - return self; -} - -@end - -#pragma mark - RNCEKVFocusableItemDouble - -@implementation RNCEKVFocusableItemDouble - -- (void)focus { - _focusCallCount += 1; -} - -@end - -#pragma mark - Shared helpers - -void RNCEKVDrainMainQueue(NSUInteger cycles) { - for (NSUInteger cycle = 0; cycle < cycles; cycle++) { - XCTestExpectation *expectation = - [[XCTestExpectation alloc] initWithDescription:@"RNCEKVDrainMainQueue"]; - dispatch_async(dispatch_get_main_queue(), ^{ - [expectation fulfill]; - }); - [XCTWaiter waitForExpectations:@[ expectation ] timeout:5.0]; - } -} - -void RNCEKVResetRootCustomFocusView(void) { - RCTKeyWindow().rootViewController.rncekvCustomFocusView = nil; -} diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm deleted file mode 100644 index a2c25d6..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVTextInputFocusWrapperTests.mm +++ /dev/null @@ -1,250 +0,0 @@ -// -// RNCEKVTextInputFocusWrapperTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import -#import - -#import "RNCEKVTextInputFocusWrapper.h" -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -// -updateFocus: is internal to RNCEKVTextInputFocusWrapper.mm and absent from -// the public header; exposed here for the single test that drives it directly. -@interface RNCEKVTextInputFocusWrapper (Testing) -- (void)updateFocus:(UIViewController *)controller; -@end - -@interface RNCEKVTextInputFocusWrapperTests : XCTestCase -@end - -@implementation RNCEKVTextInputFocusWrapperTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -/// Attaches `view` under a local (non-key) window's root controller so -/// `reactViewController` resolves without touching the host app's real key window. -- (UIWindow *)attachUnderLocalRootController:(UIView *)view { - UIWindow *localWindow = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIViewController *localController = [UIViewController new]; - localWindow.rootViewController = localController; - [localController.view addSubview:view]; - localWindow.hidden = NO; - return localWindow; -} - -- (void)test_focus_detached_parks { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - - [wrapper focus]; - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_didMoveToWindow_replaysPendingFocus_toFirstSubview { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - [wrapper focus]; - - UIView *first = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *second = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:first]; - [wrapper addSubview:second]; - - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - XCTAssertNotNil(localWindow.rootViewController, @"reactViewController resolution requires a live root controller"); - - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, first); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_replay_singleShot { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - [wrapper focus]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child, - @"replay must have run once before the single-shot leg is exercised"); - - localWindow.rootViewController.rncekvCustomFocusView = nil; - RNCEKVResetRootCustomFocusView(); - [wrapper removeFromSuperview]; - [localWindow.rootViewController.view addSubview:wrapper]; - - XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); -} - -- (void)test_cleanReferences_clearsPending { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - [wrapper focus]; - - [wrapper cleanReferences]; - - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); -} - -- (void)test_updateFocus_noSubviews_serviceNilGuard_noop { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - - UIView *preExistingFocusView = [UIView new]; - RCTKeyWindow().rootViewController.rncekvCustomFocusView = preExistingFocusView; - - [wrapper updateFocus:localWindow.rootViewController]; - - XCTAssertEqualObjects(RCTKeyWindow().rootViewController.rncekvCustomFocusView, preExistingFocusView); -} - -- (void)test_newArch_onFocusChange_gate_noCrashBothWays { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - - wrapper.hasOnFocusChanged = NO; - XCTAssertNoThrow([wrapper onFocusChangeHandler:YES]); - - wrapper.hasOnFocusChanged = YES; - XCTAssertNoThrow([wrapper onFocusChangeHandler:NO]); -} - -- (void)test_focus_attachedWithoutChild_parks_thenReplaysAfterReattachWithChild { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - - [wrapper focus]; - XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); - - [wrapper removeFromSuperview]; - UIView *child = [UIView new]; - [wrapper addSubview:child]; - [localWindow.rootViewController.view addSubview:wrapper]; - - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child, - @"pending survives detach and replays once the child exists"); -} - -- (void)test_focus_windowNilWithController_parks { - UIViewController *vc = [UIViewController new]; - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [UIView new]; - [wrapper addSubview:child]; - [vc.view addSubview:wrapper]; - - [wrapper focus]; - - XCTAssertNil(vc.rncekvCustomFocusView); - XCTAssertNil(RCTKeyWindow().rootViewController.rncekvCustomFocusView); -} - -- (void)test_detach_clearsRoutedChildPreference { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [UIView new]; - [wrapper addSubview:child]; - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - - [wrapper focus]; - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, child); - - [wrapper removeFromSuperview]; - - XCTAssertNil(localWindow.rootViewController.rncekvCustomFocusView); -} - -- (void)test_detach_preservesForeignPreference { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [UIView new]; - [wrapper addSubview:child]; - UIWindow *localWindow = [self attachUnderLocalRootController:wrapper]; - - [wrapper focus]; - - UIView *other = [UIView new]; - localWindow.rootViewController.rncekvCustomFocusView = other; - [wrapper removeFromSuperview]; - - XCTAssertEqualObjects(localWindow.rootViewController.rncekvCustomFocusView, other); -} - -// Returns UIFocusUpdateContext* (not RNCEKVTestFocusContext*): -resolveFocusChange: -// is declared to take UIFocusUpdateContext*, whose static type this double does -// not subclass (see RNCEKVTestFocusContext's header comment) — the cast keeps the -// call site's static type correct while dynamic dispatch resolves against the -// accessors the double actually implements. -- (UIFocusUpdateContext *)contextWithNext:(UIView *)next previous:(UIView *)previous { - RNCEKVTestFocusContext *context = [RNCEKVTestFocusContext new]; - context.nextFocusedView = next; - context.previouslyFocusedView = previous; - return (UIFocusUpdateContext *)context; -} - -- (void)test_resolveFocusChange_firstDescendantEntry_yes { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - - UIFocusUpdateContext *context = [self contextWithNext:child previous:nil]; - - XCTAssertEqualObjects([wrapper resolveFocusChange:context], @YES); -} - -- (void)test_resolveFocusChange_descendantToDescendant_nil { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - UIView *child2 = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - [wrapper addSubview:child2]; - - [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; - - UIFocusUpdateContext *secondEntry = [self contextWithNext:child2 previous:child]; - XCTAssertNil([wrapper resolveFocusChange:secondEntry]); -} - -- (void)test_resolveFocusChange_leave_reportsNo { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; - - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *leave = [self contextWithNext:outside previous:child]; - - XCTAssertEqualObjects([wrapper resolveFocusChange:leave], @NO); -} - -- (void)test_resolveFocusChange_trackedChildDeallocated_blurStillReported { - RNCEKVTextInputFocusWrapper *wrapper = [[RNCEKVTextInputFocusWrapper alloc] initWithFrame:CGRectZero]; - - __weak UIView *weakChild; - @autoreleasepool { - UIView *child = [[UIView alloc] initWithFrame:CGRectZero]; - [wrapper addSubview:child]; - weakChild = child; - - [wrapper resolveFocusChange:[self contextWithNext:child previous:nil]]; - [child removeFromSuperview]; - } - XCTAssertNil(weakChild); - - UIView *outside = [[UIView alloc] initWithFrame:CGRectZero]; - UIFocusUpdateContext *afterDealloc = [self contextWithNext:outside previous:[UIView new]]; - - XCTAssertEqualObjects([wrapper resolveFocusChange:afterDealloc], @NO); -} - -@end diff --git a/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm b/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm deleted file mode 100644 index da2146f..0000000 --- a/example/ios/ExternalKeyboardExampleTests/RNCEKVViewControllerExtensionTests.mm +++ /dev/null @@ -1,100 +0,0 @@ -// -// RNCEKVViewControllerExtensionTests.mm -// ExternalKeyboardExampleTests -// - -#import -#import - -#import "UIViewController+RNCEKVExternalKeyboard.h" -#import "RNCEKVTestSupport.h" - -@interface RNCEKVViewControllerExtensionTests : XCTestCase -@end - -@implementation RNCEKVViewControllerExtensionTests - -- (void)setUp { - [super setUp]; - RNCEKVResetRootCustomFocusView(); -} - -- (void)tearDown { - RNCEKVResetRootCustomFocusView(); - [super tearDown]; -} - -- (void)test_customFocusView_setGet_roundtrip { - UIViewController *vc = [UIViewController new]; - UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; - - vc.rncekvCustomFocusView = view; - XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); - - vc.rncekvCustomFocusView = nil; - XCTAssertNil(vc.rncekvCustomFocusView); -} - -- (void)test_customFocusView_notRetained_zeroesAfterDealloc { - UIViewController *vc = [UIViewController new]; - - __weak UIView *weakView; - @autoreleasepool { - UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; - weakView = view; - vc.rncekvCustomFocusView = view; - XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); - } - - XCTAssertNil(weakView); - XCTAssertNil(vc.rncekvCustomFocusView); -} - -- (void)test_preferredFocusEnvironments_noCustomView_passthrough { - UIViewController *vc = [UIViewController new]; - - NSArray> *first = vc.preferredFocusEnvironments; - NSArray> *second = vc.preferredFocusEnvironments; - - XCTAssertEqualObjects(first, second); - XCTAssertNil(vc.rncekvCustomFocusView); -} - -- (void)test_preferredFocusEnvironments_viewInWindow_insertedFirst { - UIViewController *vc = [UIViewController new]; - NSArray> *originalEnvironments = vc.preferredFocusEnvironments; - - UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)]; - UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; - [window addSubview:view]; - vc.rncekvCustomFocusView = view; - - NSArray> *result = vc.preferredFocusEnvironments; - - XCTAssertEqualObjects(result.firstObject, view); - XCTAssertEqualObjects([result subarrayWithRange:NSMakeRange(1, result.count - 1)], originalEnvironments); -} - -- (void)test_preferredFocusEnvironments_windowlessView_clearedAndPassthrough { - UIViewController *vc = [UIViewController new]; - UIView *detachedView = [[UIView alloc] initWithFrame:CGRectZero]; - vc.rncekvCustomFocusView = detachedView; - - NSArray> *result = vc.preferredFocusEnvironments; - - XCTAssertFalse([result containsObject:detachedView]); - XCTAssertNil(vc.rncekvCustomFocusView); -} - -- (void)test_rncekvFocusView_setsHolderSynchronously_schedulesFocusUpdate { - UIViewController *vc = [UIViewController new]; - UIView *view = [[UIView alloc] initWithFrame:CGRectZero]; - - [vc rncekvFocusView:view]; - - XCTAssertEqualObjects(vc.rncekvCustomFocusView, view); - - RNCEKVDrainMainQueue(1); -} - -@end diff --git a/example/ios/Podfile b/example/ios/Podfile index 3721e61..adefdd7 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -25,10 +25,6 @@ target 'ExternalKeyboardExample' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) - target 'ExternalKeyboardExampleTests' do - inherit! :search_paths - end - post_install do |installer| react_native_post_install( installer, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 4ef0b47..5b6ac12 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1892,7 +1892,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-external-keyboard (1.1.0): + - react-native-external-keyboard (1.0.0-beta.2): - boost - DoubleConversion - fast_float @@ -2915,83 +2915,83 @@ SPEC CHECKSUMS: FBLazyVector: 82d1d7996af4c5850242966eb81e73f9a6dfab1e fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 070be10ed4a7d129af6a8b353d192288f33e6778 - RCT-Folly: 59ec0ac1f2f39672a0c6e6cecdd39383b764646f + hermes-engine: ee62a2e033aea92a25a072d5964fcf22d52bea88 + RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 9da1d0cf93db23ca8b41e8efe9ae558fd9c0077f RCTRequired: 92a63c7041031a131fa5206eb082d53f95729b79 RCTSwiftUI: 395b65655229fa2006415207adcfcb6e35dc78ed - RCTSwiftUIWrapper: 0bef3bf5c2d757c95ab295bce340252cbd8b78db + RCTSwiftUIWrapper: 91351441a592e07e09a2f94d2cbdf088fde7e2e1 RCTTypeSafety: 091ec3b2994c00939652cbe91cfa9ee8a4ae75b5 React: 3e14066ac707b3e369d09e2e923d8bee7f8c33ff React-callinvoker: 2d95e8e26fbab01f06fbf006d2c370f834a3537b - React-Core: ec627cd25596e357550c6c1aecdd0a8ed6133511 - React-CoreModules: 3f00acadb1d5521469682279f8158e7f7a3a62a9 - React-cxxreact: a221d0dfbba5a7f2379e6b4dd66d71e8ab0b63ca + React-Core: 0e73cf940736e6d32683d1b9e427ca9e92f96e5a + React-CoreModules: a252c33b178381722498afe5fa475bb110cc2943 + React-cxxreact: 271c58e22ece5be60e9a6ee7d3d40474028833fc React-debug: 0081691903fcdbaa533500f83d358f1f3dbf6052 - React-defaultsnativemodule: 9f25b85274d9ea93beac442bf674f908969643cc - React-domnativemodule: 269edbf850b8d63243254e7e7e77181aa1622e18 - React-Fabric: 0f1b938e204322012d74f7b5acfe0fbf7e461551 - React-FabricComponents: e7887e24f53b016d3a677c3666f43ee9e7bb7f1d - React-FabricImage: c1bf10a24d67f06075e889d00eecc09518d236e6 - React-featureflags: d627d51b3ed1422cef102999fbb538c330fff217 - React-featureflagsnativemodule: f70983c8e3115f41994b26147c1bcc204d0452a4 - React-graphics: 4f3594197ef5f74d4090068c9789e0dfb11304c4 - React-hermes: 0d7350bea2662e7971d67fabef3511210bc10228 - React-idlecallbacksnativemodule: 78cfc6e6b33485d08a2cebb219742ded453d2f43 - React-ImageManager: 798a0140733dabf8d525b6fb094d7e596ed252f7 - React-intersectionobservernativemodule: 05bb55b5a8c53b56f0bf189c1f12f59e6665b5a0 - React-jserrorhandler: 6a1dbf8148dba195f51c79d9122550e1ab5a2b38 - React-jsi: a6f3e6a263e595d3e26dbeb0fc7efc3b04c8f207 - React-jsiexecutor: 2181494c9e4033feb6beb1886d47ccdf2bd04dd6 - React-jsinspector: 4b1a068673423943397f784f1868f3dda2f4728e - React-jsinspectorcdp: 17d408897b0a350205ef4bc4add5e5ae3bbac33a - React-jsinspectornetwork: 2c01f6a6264fdf91a6109277c0594c0994428484 - React-jsinspectortracing: c2e0ba315133d6b7037cd27d7ca768bead432b7c - React-jsitooling: 02024b1e482ff51d4eecd2289bb539ed24deb305 - React-jsitracing: 66975e51708f79678b7805e28f5de8a354535ddc - React-logger: 2a182a9d48eea1bc58834649d4b8436994e179ad - React-Mapbuffer: 486b7ebf69aa5cd9c2f0d4232d78ed8190e14004 - React-microtasksnativemodule: 6550ec51ff7ed24fe58830e5ef5d09629cf086c5 - react-native-external-keyboard: fa94237f46bec6ac415b605e77e825ea93faca28 - react-native-safe-area-context: 0f4986a88ec555aff660503b483d6e4bd6980a9a - React-NativeModulesApple: fd9c17d032baa5376f22615d3819f212a31d6386 - React-networking: 11c7a1a9830d4493ae07094a1460e8fc68793c6b + React-defaultsnativemodule: f7e7dafd3f5ebd8733ef0bb2f9b61bb0415136f6 + React-domnativemodule: 77e61307cd9ba1e2fa0f480d70db9bb8f1a79d52 + React-Fabric: 8b4d1e26350ff7eaae4ee81a90e8c936123e2018 + React-FabricComponents: 4e8a2981969664f6655e2532e52d225881c8929c + React-FabricImage: f57463e90686da3ba74339091e327bd99816175b + React-featureflags: be8c8414da416342a8cedb0a6b7512e7973f85ac + React-featureflagsnativemodule: d5b573eee59a8de006a948d3766b6a38f6d085b8 + React-graphics: 774bd8afdf9d8ef70faecddbffb53dce2ea7e5b5 + React-hermes: 26feaea19d95e73a794d6f84cfcbce63f85cb9ec + React-idlecallbacksnativemodule: 6ec2446be4a579d5aaed1af31519b679fc076329 + React-ImageManager: 1de64915c16b058d9e635c98cf5d786454ca48cf + React-intersectionobservernativemodule: 3e91ec7069afe60d5dace2e2960f7fd7abeb2f64 + React-jserrorhandler: 458ca75c0df7c8dd046a3c74c0dec719fd0aa863 + React-jsi: 8442310fcae4f17ed2c2df00cc8a53fb479bef1b + React-jsiexecutor: e73fa2e25be645f8f98f00893adcf24e449de8ce + React-jsinspector: 5f756f86c8263f3e0e462f4b12b8da3b677686a4 + React-jsinspectorcdp: d6bcfdb732d99f6240e3ed6b82da58f7391a4ce2 + React-jsinspectornetwork: 9e2a9df177614e7e4a058c37ae2d7cefe59a7d8d + React-jsinspectortracing: 106ef2423c9c90c88d01f7e9b86cc86668d06bb4 + React-jsitooling: 5c7a6e98c27452fa0043c112ae53a7b499d08d30 + React-jsitracing: d68eea24f3feea58726ae44fab02d571b9011f36 + React-logger: 993e4b9793768764e0fdd379ad1d6582f7905463 + React-Mapbuffer: 3a5f700ed673820ab4b1b35ba0cf8476400bc4c5 + React-microtasksnativemodule: 094677e625f12276a8f871844a5ee6a945a90221 + react-native-external-keyboard: 5a0ffde5b88e9df0c32ca81ac51c369f938e3676 + react-native-safe-area-context: befb5404eb8a16fdc07fa2bebab3568ecabcbb8a + React-NativeModulesApple: 29290351acc118784e158aa7b23c42719dc57617 + React-networking: d01f94f15d1a6fce689a8c57d2397a5a40b0b5aa React-oscompat: 854967d380ee2921c848790cdb942b42d22017d8 - React-perflogger: faa87892131b1712062b64b9f30100ab833a326c - React-performancecdpmetrics: e20e83d38700b3e8c3f8f64657adf13908528974 - React-performancetimeline: 7109ab7e26870fa42488e71a2c15156f7e0dd462 + React-perflogger: bb302310d56078ced79111225a74815465b5c9f9 + React-performancecdpmetrics: 7e14712c518d27e6f211040093f33d34eccc0361 + React-performancetimeline: 5a370c3e1370a80947806e67796683bc27477200 React-RCTActionSheet: 1182e251a2f93857ab7a4a13732c881449cc225f - React-RCTAnimation: 3c764b70693dd89e1bde74e96ab590f177c6009d - React-RCTAppDelegate: 01cdef423260048457165341e419b794fe92780a - React-RCTBlob: ff7a3fc166d8d928d6ca4f9005f79ac77a246f39 - React-RCTFabric: a784e4764a205c0a055c9e79b885f3ea8bc5246a - React-RCTFBReactNativeSpec: b7a176dbd9973048ed0e6fd4804b517e5ef4775e - React-RCTImage: 279514ec0dd6d58e86a93e0df41f71b34a5e22e5 - React-RCTLinking: 9d5d986fdddfdd209dc9803e2b319c9b59ee9ff2 - React-RCTNetwork: 95e428f78dbf156beee8f346e32ca00feb28bf60 - React-RCTRuntime: 3e7c5aeb03e6a101d1b925fe09e0031f15e496f0 - React-RCTSettings: b852c96a4fd297275e38c68beba38ac04fe56289 - React-RCTText: 930121a255447eb15ed20cd65a0681ddd004d82e - React-RCTVibration: 056fb120c5308a329c1413bd09be79eb2a5862e2 + React-RCTAnimation: 7fff267277af4af4abcec3b7d8dc4e3956aaf414 + React-RCTAppDelegate: 5e0010863f9a433d724f0811c9a4518a96cec535 + React-RCTBlob: 44ada012ff2dfa9a88f979d9631808138356b1f4 + React-RCTFabric: 23df68c60fd3af1a7dc893ab68f76d353fe51568 + React-RCTFBReactNativeSpec: 7f16922a8ce55cfb31a0ff161e212cd655cf68ee + React-RCTImage: e02f7772bbd165ef13c0051de1b9da6baefd11e6 + React-RCTLinking: 68ffd8feb4f0ea6fe3f10a264568901e17a7575c + React-RCTNetwork: 2f99990cb2ada2f2409b83174a96e6b901d254f8 + React-RCTRuntime: cc1ea7dc30d1e69ef2e6728e16e66dce9b65fabb + React-RCTSettings: a0ccf26bdca389ee6f6d897bf208293f86234814 + React-RCTText: d24b35c913a17b68b6207b0211967587e5c64c81 + React-RCTVibration: 3ab7eb971e4fa0774a3e0a376f3ea14dc6c7f963 React-rendererconsistency: 5a51c5d21f0131a9461c7a76809f96057c7f6a21 - React-renderercss: b7248f9b8ae48c3720ed9f2f0c98def7839c255c - React-rendererdebug: f5b25114a932d4ffcbb4bd2cd023b456b94170a1 - React-RuntimeApple: 443347984c005bde55f621ea03b697ddfe46269e - React-RuntimeCore: b4fb2cc929c81d7ed31c582ab6da2a48c5768934 - React-runtimeexecutor: 99fc61809f5ae8b42eebaf87a60b3df4277581ef - React-RuntimeHermes: d24cf1d92d22c426239ccf7b3eb0224f788534a7 - React-runtimescheduler: db87ef3574c2ba59be392a03d0e4372817c898bd - React-timing: caf22a459eafeba7c2d60bebefcfdfde587b61a4 - React-utils: 28aa8196560099cb5a4327df43310deabaca5e2f - React-webperformancenativemodule: 0f1b1eada5692af98b61002061dd1849bdda3cb0 - ReactAppDependencyProvider: c067d0b6558ad6ae392b96909de597a1b36f97e1 - ReactCodegen: 5f11b1e16f48dea0a9a858fe6834a6fbd0e93334 - ReactCommon: 0f7a365837f2dbec4342e458c1c1d3876683f492 - RNGestureHandler: 77eecab5fd636666ca73a55bb61e2f1a685b7e84 - RNScreens: 7179cc1ba31b4e18ed29f10abf20c24a7961cf4c + React-renderercss: 9d27964853430a8823d448be4b1579f99714c8ed + React-rendererdebug: 1537ac6507182a3c9277922528e280b07181644f + React-RuntimeApple: 7a1f5c9fcfea8c7640e0c7e2893b30b2de117d3c + React-RuntimeCore: ac6333333f8cf86a3373ddc84611c3716ca8e1e9 + React-runtimeexecutor: 000669b14a58e1fe8a816e7057c6f24f58d514ad + React-RuntimeHermes: 2cfc0d3621dbe0674cce922e23aec31e02d3c809 + React-runtimescheduler: f232a0ed6911f641117933dd0ad4660b0cef5a04 + React-timing: 2b03ad9baf91c453e1ef28c37c8ec8bc1e8edc55 + React-utils: 2867547ccbc03b50de3ed04f1d9ca23efcf8651a + React-webperformancenativemodule: 39b4be54aa0174429654e84570dd7d4704da9def + ReactAppDependencyProvider: 2b19d66e5ddfe8dc7afb6338a4626156cbf2bab1 + ReactCodegen: 53a01767e04b3a23f128b5a3c6542b4ab24fb921 + ReactCommon: 5901ef412ae35cc727b9584d4f7e3e1f7f17c251 + RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 + RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: 19371ad8ad69b080bfe3bd28bb8ddf6aa0aa0eac + Yoga: b669e79fa0f8d3f6f5e35372345f54b99e06b13c -PODFILE CHECKSUM: b437bac6e1ba7c5722ddc6024410f8a07b920d46 +PODFILE CHECKSUM: 8d1304d1eddbaf9ff796c80223c1961a265745d7 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/scripts/setup_unit_tests.rb b/example/ios/scripts/setup_unit_tests.rb deleted file mode 100644 index ba48f9a..0000000 --- a/example/ios/scripts/setup_unit_tests.rb +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env ruby -require 'xcodeproj' -project_path = File.expand_path('../ExternalKeyboardExample.xcodeproj', __dir__) -project = Xcodeproj::Project.open(project_path) -target = project.targets.find { |t| t.name == 'ExternalKeyboardExampleTests' } -abort('test target missing') unless target -group = project.main_group.find_subpath('ExternalKeyboardExampleTests', true) -group.set_source_tree('') -group.set_path('ExternalKeyboardExampleTests') -existing = target.source_build_phase.files_references.map(&:path).compact -Dir[File.expand_path('../ExternalKeyboardExampleTests/*.mm', __dir__)].sort.each do |f| - base = File.basename(f) - next if existing.include?(base) - ref = group.find_file_by_path(base) || group.new_reference(base) - target.add_file_references([ref]) -end -plist = group.find_file_by_path('Info.plist') || group.new_reference('Info.plist') -target.build_configurations.each do |config| - bs = config.build_settings - bs['PRODUCT_BUNDLE_IDENTIFIER'] = 'externalkeyboard.example.tests' - defs = Array(bs['GCC_PREPROCESSOR_DEFINITIONS'] || ['$(inherited)']) - defs << '$(inherited)' unless defs.include?('$(inherited)') - defs << 'RCT_NEW_ARCH_ENABLED=1' unless defs.include?('RCT_NEW_ARCH_ENABLED=1') - bs['GCC_PREPROCESSOR_DEFINITIONS'] = defs - bs['CLANG_ENABLE_MODULES'] = 'YES' -end -project.save -puts 'ExternalKeyboardExampleTests hydrated.' From f0cb8121e852290ec3ac1a21681df8f1ee5ae283 Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Wed, 26 Aug 2026 15:06:58 +1000 Subject: [PATCH 8/9] docs(ios): clarify focus-path comments --- .../RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm | 13 ++++++------- ios/Services/RNCEKVKeyboardFocusService.h | 10 ++++------ .../FocusOrderGroup/RNCEKVViewOrderGroupBase.mm | 5 ++--- .../Base/FocusRequest/RNCEKVViewFocusRequestBase.mm | 12 +++++------- .../RNCEKVExternalKeyboardLockView.mm | 10 ++++------ .../RNCEKVTextInputFocusWrapper.mm | 5 ++--- 6 files changed, 23 insertions(+), 32 deletions(-) diff --git a/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm b/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm index 50343c1..c9e6557 100644 --- a/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm +++ b/ios/Delegates/RNCEKVFocusDelegate/RNCEKVFocusDelegate.mm @@ -16,8 +16,8 @@ @implementation RNCEKVFocusDelegate{ // The view UIKit actually focused inside our subtree (set from the focus engine, // not guessed). Weak so a removed/recycled view can't be retained or go stale. __weak UIView* _focusedTarget; - // Survives _focusedTarget zeroing (target deallocated while focused) so the blur - // can still be reported when focus moves on. + // Tracks wrapper focus after the weak target is deallocated, allowing the + // next focus update to report blur. BOOL _isTrackingFocus; } @@ -123,9 +123,8 @@ - (NSNumber*)isFocusChanged:(UIFocusUpdateContext *)context { UIView *next = context.nextFocusedView; UIView *prev = context.previouslyFocusedView; - // Focus entered our subtree: remember the *actual* focused view. A move between - // two of our own descendants keeps the wrapper focused — retarget without - // reporting a change, so JS never sees focus=true twice with no blur between. + // Track the actual focused view. Moving between this wrapper's descendants + // updates the target without reporting another focus event. if (next && [self ownsFocusedView:next]) { BOOL alreadyFocused = _isTrackingFocus; _focusedTarget = next; @@ -133,8 +132,8 @@ - (NSNumber*)isFocusChanged:(UIFocusUpdateContext *)context { return alreadyFocused ? nil : @YES; } - // Focus left the view we were tracking — or the tracked view deallocated - // (_focusedTarget zeroed) and focus moved elsewhere. + // Report blur when focus leaves the tracked view, including after the weak + // target has been deallocated. if (_isTrackingFocus && (_focusedTarget == nil || prev == _focusedTarget)) { _focusedTarget = nil; _isTrackingFocus = NO; diff --git a/ios/Services/RNCEKVKeyboardFocusService.h b/ios/Services/RNCEKVKeyboardFocusService.h index d9e7019..7b233ef 100644 --- a/ios/Services/RNCEKVKeyboardFocusService.h +++ b/ios/Services/RNCEKVKeyboardFocusService.h @@ -23,12 +23,10 @@ /// Moves keyboard focus to the given view on the next focus update. + (void)focus:(UIView *)view; -/// Like `focus:`, but resolves the routing controller as: the target view's own -/// window root first (UIKit honors a focus update only when the environment it is -/// requested on contains the currently focused item, and only the target's own -/// scene is guaranteed to contain the target), then the key-window root for -/// not-yet-attached targets, then the supplied fallback. Returns the controller -/// the request was routed to, or nil when nothing was routed. +/// Routes focus through the target's window root when available, keeping the +/// request in the target's scene. For an unattached target, falls back to the +/// key-window root and then `controller`. Returns the controller that received +/// the request, or nil if the request could not be routed. + (UIViewController *)focus:(UIView *)view withFallback:(UIViewController *)controller; @end diff --git a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm index 0e1cad8..76a6997 100644 --- a/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm +++ b/ios/Views/Base/FocusOrderGroup/RNCEKVViewOrderGroupBase.mm @@ -41,9 +41,8 @@ - (BOOL)getIsViewFocused:(UIFocusUpdateContext *)context { if (next == nil || ![next isDescendantOfView:self]) { return NO; } - // Nearest-wrapper ownership: when the focused view sits inside a nested - // order-group wrapper (or is one itself), that nested wrapper owns the - // focus and this view's directional guides must stay off. + // The nearest order wrapper owns the focused view. Keep this wrapper's + // directional guides disabled when a nested wrapper owns focus. for (UIView *view = next; view != nil && view != self; view = view.superview) { if ([view isKindOfClass:[RNCEKVViewOrderGroupBase class]]) { return NO; diff --git a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm index e802422..bacf9ad 100644 --- a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm +++ b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm @@ -34,9 +34,8 @@ - (void)cleanReferences { _autoFocusGeneration++; } -// Clears the controller preference this view installed via the focus service, -// but only while it still points at this view — a later request routed by -// another view must not be discarded. +// Clears this view's preferred-focus entry without removing a newer request +// from another view. - (void)clearRoutedFocusTarget { UIViewController *routedController = _focusRoutedController; if (routedController != nil && routedController.rncekvCustomFocusView == self) { @@ -104,8 +103,8 @@ - (void)focusOnMount { return; } if (strongSelf.window == nil) { - // Detached during the dispatch hop: return the consumed attempt so the - // next attach can retry instead of losing autofocus permanently. + // The view detached before autofocus ran. Let the next attachment + // try again. strongSelf->_autoFocusRequested = NO; return; } @@ -133,8 +132,7 @@ - (void)didMoveToWindow { } [self onAttached]; } else { - // Detach invalidates the preference this view installed; a recycled or - // navigated-away view must not remain the controller's preferred target. + // A detached view must no longer be the controller's preferred target. [self clearRoutedFocusTarget]; } } diff --git a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm index bd4b5f7..dc8084d 100644 --- a/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm +++ b/ios/Views/RNCEKVExternalKeyboardLockView/RNCEKVExternalKeyboardLockView.mm @@ -148,9 +148,8 @@ - (void)updateProps:(Props::Shared const &)props *std::static_pointer_cast(props); [super updateProps:props oldProps:oldProps]; - // lockDisabled must be applied before forceLock: a compound - // { forceLock: true, lockDisabled: true } commit from defaults must never - // pass through a momentarily-active state that steals focus. + // Apply lockDisabled first so { forceLock: true, lockDisabled: true } never + // activates the trap between property updates. if (_lockDisabled != newViewProps.lockDisabled) { self.lockDisabled = newViewProps.lockDisabled; } @@ -169,9 +168,8 @@ - (void)updateProps:(Props::Shared const &)props - (void)didMoveToWindow { [super didMoveToWindow]; - // Doubles as the attach replay: an active trap whose setter-time request was - // dropped for lack of a controller re-requests here, while the guards above - // keep an inactive or disabled trap from stealing focus on mount. + // An active trap may request focus before it has a controller. Retry after + // attachment; the request guards exclude inactive and disabled traps. if (self.window) { [self requestFocus]; [self requestScreenReaderFocus]; diff --git a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm index 7f1e930..4298f95 100644 --- a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm +++ b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm @@ -186,9 +186,8 @@ - (void)updateFocus:(UIViewController *)controller { } } -// Clears the controller preference this wrapper installed for its child, but -// only while it still points at that child — a later request routed by -// another view must not be discarded. +// Clears this child's preferred-focus entry without removing a newer request +// from another view. - (void)clearRoutedFocusTarget { UIViewController *routedController = _focusRoutedController; UIView *routedTarget = _focusRoutedTarget; From af290adce1f808e8d6a3bf585df15500f865b69c Mon Sep 17 00:00:00 2001 From: Synur Developer Date: Thu, 27 Aug 2026 10:15:29 +1000 Subject: [PATCH 9/9] fix: clear stale pending focus request after successful focus [self-reviewed] A focus request parked while the view was partially ready was never cleared by a later successful focus, causing a stale replay on the next window attach. --- ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm | 1 + .../RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm | 1 + 2 files changed, 2 insertions(+) diff --git a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm index bacf9ad..98d5057 100644 --- a/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm +++ b/ios/Views/Base/FocusRequest/RNCEKVViewFocusRequestBase.mm @@ -58,6 +58,7 @@ - (void)focus { _pendingFocusRequest = YES; return; } + _pendingFocusRequest = NO; _focusRoutedController = [RNCEKVKeyboardFocusService focus:self withFallback:controller]; } diff --git a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm index 4298f95..4f29dd2 100644 --- a/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm +++ b/ios/Views/RNCEKVTextInputFocusWrapper/RNCEKVTextInputFocusWrapper.mm @@ -175,6 +175,7 @@ - (void)focus { _pendingFocusRequest = YES; return; } + _pendingFocusRequest = NO; [self updateFocus:viewController]; }