Files
flipper/iOS/Plugins/FlipperKitUIDebuggerPlugin/FlipperKitUIDebuggerPlugin/Traversal/UIDAllyTraversal.m
Sash Zats d46a301929 Add custom actions to flipper inspector
Summary:
Now when we are simulating device running in VoiceOver mode while showing accessiblity hierarchy in flipper, we are getting correct hierarchy + getting production-like custom actions,

Instagram + Facebook feed is a good example of using accessiblity actions to avoid exposing individual buttons

Reviewed By: lblasa

Differential Revision: D49641875

fbshipit-source-id: 1153ec3bffc7110c4bfe702cbb5a6b729d91b9a3
2023-09-27 07:38:38 -07:00

435 lines
17 KiB
Objective-C
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#if FB_SONARKIT_ENABLED
#import "UIDAllyTraversal.h"
#import <dlfcn.h>
#import "UIDDescriptorRegister.h"
#import "UIDMetadataRegister.h"
#import "UIDNode.h"
@interface UIAccessibilityElementTraversalOptions : NSObject
+ (instancetype)defaultVoiceOverOptions;
+ (instancetype)voiceOverOptionsIncludingElementsFromOpaqueProviders:(BOOL)arg1
honorsGroups:(BOOL)arg2;
@end
@interface UIApplication (Ally)
- (NSArray*)_accessibilityLeafDescendantsWithOptions:
(UIAccessibilityElementTraversalOptions*)option;
@end
@implementation UIDAllyTraversal {
UIDDescriptorRegister* _descriptorRegister;
}
+ (BOOL)isSupported {
return _loadAccessibilityFramework() &&
[UIApplication.sharedApplication
respondsToSelector:@selector
(_accessibilityLeafDescendantsWithOptions:)];
}
+ (void)setVoiceOverServiceEnabled:(BOOL)enabled {
if (self.isSupported) {
_setVoiceOver(enabled);
}
}
- (instancetype)initWithDescriptorRegister:
(UIDDescriptorRegister*)descriptorRegister {
self = [super init];
if (self) {
_descriptorRegister = descriptorRegister;
}
return self;
}
- (NSArray<UIDNode*>*)traverse:(UIApplication*)application root:(id)root {
if (!root) {
return @[];
}
if (!_loadAccessibilityFramework()) {
return @[];
}
// create voice over representation of the app
id options = [NSClassFromString(@"UIAccessibilityElementTraversalOptions")
voiceOverOptionsIncludingElementsFromOpaqueProviders:YES
honorsGroups:NO];
if (![application respondsToSelector:@selector
(_accessibilityLeafDescendantsWithOptions:)]) {
return @[];
}
NSArray<NSObject*>* const allyNodes = [[application
_accessibilityLeafDescendantsWithOptions:options] mutableCopy];
UIDNode* rootNode = [self _uidNodeForNode:root];
NSInteger rootIdentifier = rootNode.identifier;
NSMutableArray<UIDNode*>* nodes = [NSMutableArray new];
NSMutableArray* childrenIds = [NSMutableArray new];
for (NSObject* node in allyNodes) {
UIDNode* uidNode = [self _uidNodeForNode:node];
uidNode.parent = @(rootIdentifier);
[nodes addObject:uidNode];
[childrenIds addObject:[NSNumber numberWithUnsignedInt:uidNode.identifier]];
}
rootNode.children = childrenIds;
[nodes insertObject:rootNode atIndex:0];
return nodes;
}
- (UIDNode*)_uidNodeForNode:(NSObject*)node {
UIDNodeDescriptor* descriptor =
[_descriptorRegister descriptorForClass:[node class]];
NSUInteger nodeIdentifier = [descriptor identifierForNode:node];
UIDNode* uidNode = [[UIDNode alloc]
initWithIdentifier:nodeIdentifier
qualifiedName:[descriptor nameForNode:node]
name:_nameForNode(node)
bounds:[UIDBounds fromRect:node.accessibilityFrame]
tags:[descriptor tagsForNode:node]];
uidNode.attributes = _atrtibutesForNode(node);
return uidNode;
}
static BOOL _loadAccessibilityFramework(void) {
static BOOL isAccessibilityFrameworkLoaded;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURL* const knownFrameworkUrl =
[NSBundle bundleForClass:UIApplication.class].bundleURL;
if (!knownFrameworkUrl) {
isAccessibilityFrameworkLoaded = NO;
} else {
NSURL* const accessibilityFrameworkUrl =
[knownFrameworkUrl.URLByDeletingLastPathComponent
.URLByDeletingLastPathComponent
URLByAppendingPathComponent:
@"PrivateFrameworks/UIAccessibility.framework"];
isAccessibilityFrameworkLoaded =
[[NSBundle bundleWithURL:accessibilityFrameworkUrl] load];
}
});
return isAccessibilityFrameworkLoaded;
}
static void _setVoiceOver(BOOL enabled) {
NSString* const accessibilityUtilitiesPath =
[[NSBundle bundleForClass:UIApplication.class]
.bundleURL.URLByDeletingLastPathComponent
.URLByDeletingLastPathComponent
URLByAppendingPathComponent:
@"PrivateFrameworks/AccessibilityUtilities.framework/AccessibilityUtilities"]
.relativePath;
void* handler = dlopen(
[accessibilityUtilitiesPath cStringUsingEncoding:NSUTF8StringEncoding],
RTLD_NOW);
if (!handler) {
return;
}
void (*_AXSVoiceOverTouchSetEnabled)(BOOL) =
dlsym(handler, "_AXSVoiceOverTouchSetEnabled");
_AXSVoiceOverTouchSetEnabled(enabled);
}
static NSString* _nameForNode(NSObject* node) {
NSMutableArray* const parts = [NSMutableArray new];
if (node.accessibilityLabel.length > 0) {
[parts addObject:node.accessibilityLabel];
}
if (node.accessibilityValue.length > 0) {
[parts addObject:node.accessibilityValue];
}
if (parts.count == 0) {
return @"[No accessibility label]";
}
NSString* const emojiTraits =
_descriptionFromTraits(node.accessibilityTraits, YES);
NSString* const fullNodeName = [parts componentsJoinedByString:@", "];
return emojiTraits
? [NSString stringWithFormat:@"%@: %@", emojiTraits, fullNodeName]
: fullNodeName;
}
static UIDAttributes* _atrtibutesForNode(NSObject* node) {
static UIDMetadataId ClassAttributeId;
static UIDMetadataId AddressAttributeId;
static UIDMetadataId AccessibilityAttributeId;
static UIDMetadataId IsAccessibilityElementAttributeId;
static UIDMetadataId AccessibilityLabelAttributeId;
static UIDMetadataId AccessibilityIdentifierAttributeId;
static UIDMetadataId AccessibilityValueAttributeId;
static UIDMetadataId AccessibilityHintAttributeId;
static UIDMetadataId AccessibilityTraitsAttributeId;
static UIDMetadataId AccessibilityViewIsModalAttributeId;
static UIDMetadataId ShouldGroupAccessibilityChildrenAttributeId;
static UIDMetadataId AccessibilityCustomActions;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
UIDMetadataRegister* const metadataRegister = [UIDMetadataRegister shared];
AccessibilityAttributeId = [metadataRegister
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"Accessibility"];
AddressAttributeId = [metadataRegister
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"Address"
isMutable:NO
definedBy:AccessibilityAttributeId];
ClassAttributeId = [metadataRegister
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"Class"
isMutable:NO
definedBy:AccessibilityAttributeId];
IsAccessibilityElementAttributeId = [metadataRegister
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"isAccessibilityElement"
isMutable:NO
definedBy:AccessibilityAttributeId];
AccessibilityLabelAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityLabel"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityIdentifierAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityIdentifier"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityValueAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityValue"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityHintAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityHint"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityTraitsAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityTraits"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityViewIsModalAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityViewIsModal"
isMutable:false
definedBy:AccessibilityAttributeId];
ShouldGroupAccessibilityChildrenAttributeId = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"shouldGroupAccessibilityChildren"
isMutable:false
definedBy:AccessibilityAttributeId];
AccessibilityCustomActions = [[UIDMetadataRegister shared]
registerMetadataWithType:UIDEBUGGER_METADATA_TYPE_ATTRIBUTE
name:@"accessibilityCustomActions"
isMutable:false
definedBy:AccessibilityAttributeId];
});
NSMutableDictionary* const accessibilityAttributes =
[NSMutableDictionary new];
accessibilityAttributes[ClassAttributeId] =
[UIDInspectableText fromText:NSStringFromClass(node.class)];
accessibilityAttributes[AddressAttributeId] =
[UIDInspectableText fromText:[NSString stringWithFormat:@"%p", node]];
accessibilityAttributes[IsAccessibilityElementAttributeId] =
[UIDInspectableBoolean fromBoolean:node.isAccessibilityElement];
accessibilityAttributes[IsAccessibilityElementAttributeId] =
[UIDInspectableBoolean fromBoolean:node.isAccessibilityElement];
accessibilityAttributes[AccessibilityLabelAttributeId] =
[UIDInspectableText fromText:node.accessibilityLabel];
if ([node conformsToProtocol:@protocol(UIAccessibilityIdentification)]) {
accessibilityAttributes[AccessibilityIdentifierAttributeId] =
[UIDInspectableText fromText:((id<UIAccessibilityIdentification>)node)
.accessibilityIdentifier];
}
accessibilityAttributes[AccessibilityValueAttributeId] =
[UIDInspectableText fromText:node.accessibilityValue];
if (node.accessibilityHint != nil) {
accessibilityAttributes[AccessibilityHintAttributeId] =
[UIDInspectableText fromText:node.accessibilityHint];
}
accessibilityAttributes[AccessibilityViewIsModalAttributeId] =
[UIDInspectableBoolean fromBoolean:node.accessibilityViewIsModal];
accessibilityAttributes[ShouldGroupAccessibilityChildrenAttributeId] =
[UIDInspectableBoolean fromBoolean:node.shouldGroupAccessibilityChildren];
accessibilityAttributes[AccessibilityTraitsAttributeId] = [UIDInspectableText
fromText:_descriptionFromTraits(node.accessibilityTraits, NO)];
accessibilityAttributes[AccessibilityCustomActions] =
_accessibilityCustomActionsDescription(node.accessibilityCustomActions);
return @{
AccessibilityAttributeId :
[UIDInspectableObject fromFields:accessibilityAttributes]
};
}
static UIDInspectable* _accessibilityCustomActionsDescription(
NSArray<UIAccessibilityCustomAction*>* actions) {
NSMutableArray<UIDInspectable*>* const descriptions = [NSMutableArray new];
for (UIAccessibilityCustomAction* action in actions) {
[descriptions
addObject:[UIDInspectableText fromText:_descriptionFromAction(action)]];
}
if (descriptions.count == 0) {
return [UIDInspectableUnknown null];
} else {
return [UIDInspectableArray fromItems:descriptions];
}
}
static NSString* _descriptionFromAction(UIAccessibilityCustomAction* action) {
if (@available(iOS 13.0, *)) {
if (action.actionHandler) {
return @"(handled in block)";
}
}
Class cls = [action.target class];
return [NSString
stringWithFormat:@"%@ -[%@ (%p) %@]",
(action.attributedName.string ?: action.name),
cls ? NSStringFromClass(cls) : @"UNKNOWN_CLASS",
action.target ? action.target : @"NO_TARGET",
action.selector ? NSStringFromSelector(action.selector)
: @"NO_SELECTOR"];
}
static NSString* _Nullable _descriptionFromTraits(
UIAccessibilityTraits traits,
BOOL emojiOnly) {
if (traits == UIAccessibilityTraitNone) {
return emojiOnly ? nil : @"None";
}
static NSArray* allTraits;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
allTraits = @[
@(UIAccessibilityTraitButton),
@(UIAccessibilityTraitLink),
@(UIAccessibilityTraitHeader),
@(UIAccessibilityTraitSearchField),
@(UIAccessibilityTraitImage),
@(UIAccessibilityTraitSelected),
@(UIAccessibilityTraitPlaysSound),
@(UIAccessibilityTraitKeyboardKey),
@(UIAccessibilityTraitStaticText),
@(UIAccessibilityTraitSummaryElement),
@(UIAccessibilityTraitNotEnabled),
@(UIAccessibilityTraitUpdatesFrequently),
@(UIAccessibilityTraitStartsMediaSession),
@(UIAccessibilityTraitAdjustable),
@(UIAccessibilityTraitAllowsDirectInteraction),
@(UIAccessibilityTraitCausesPageTurn),
@(UIAccessibilityTraitTabBar),
];
});
NSMutableArray* descriptionComponents = [NSMutableArray new];
for (NSNumber* wrappedTrait in allTraits) {
UIAccessibilityTraits trait = wrappedTrait.unsignedIntegerValue;
if ((traits & trait) == trait) {
NSString* traitDescription = emojiOnly
? _emojiFromTrait(trait)
: [NSString stringWithFormat:@"%@ - %@",
_emojiFromTrait(trait),
_descriptionFromTrait(trait)];
if (traitDescription) {
[descriptionComponents addObject:traitDescription];
}
}
}
return descriptionComponents.count > 0
? [descriptionComponents componentsJoinedByString:@", "]
: (emojiOnly ? nil : @"None");
}
static NSString* _Nullable _descriptionFromTrait(UIAccessibilityTraits trait) {
if (trait == UIAccessibilityTraitButton) {
return @"button";
} else if (trait == UIAccessibilityTraitLink) {
return @"link";
} else if (trait == UIAccessibilityTraitHeader) {
return @"header";
} else if (trait == UIAccessibilityTraitSearchField) {
return @"search field";
} else if (trait == UIAccessibilityTraitImage) {
return @"image";
} else if (trait == UIAccessibilityTraitSelected) {
return @"selected";
} else if (trait == UIAccessibilityTraitPlaysSound) {
return @"plays sound";
} else if (trait == UIAccessibilityTraitKeyboardKey) {
return @"keyboard key";
} else if (trait == UIAccessibilityTraitStaticText) {
return @"static text";
} else if (trait == UIAccessibilityTraitSummaryElement) {
return @"summary element";
} else if (trait == UIAccessibilityTraitNotEnabled) {
return @"not enabled";
} else if (trait == UIAccessibilityTraitUpdatesFrequently) {
return @"updates frequently";
} else if (trait == UIAccessibilityTraitStartsMediaSession) {
return @"starts media session";
} else if (trait == UIAccessibilityTraitAdjustable) {
return @"adjustable";
} else if (trait == UIAccessibilityTraitAllowsDirectInteraction) {
return @"allows direct interaction";
} else if (trait == UIAccessibilityTraitCausesPageTurn) {
return @"causes page turn";
} else if (trait == UIAccessibilityTraitTabBar) {
return @"tab tar";
}
return nil;
}
static NSString* _Nullable _emojiFromTrait(UIAccessibilityTraits trait) {
if (trait == UIAccessibilityTraitButton) {
return @"🆗";
} else if (trait == UIAccessibilityTraitLink) {
return @"🔗";
} else if (trait == UIAccessibilityTraitHeader) {
return @"🏷️";
} else if (trait == UIAccessibilityTraitSearchField) {
return @"🔍";
} else if (trait == UIAccessibilityTraitImage) {
return @"🖼️";
} else if (trait == UIAccessibilityTraitSelected) {
return @"";
} else if (trait == UIAccessibilityTraitPlaysSound) {
return @"🔊";
} else if (trait == UIAccessibilityTraitKeyboardKey) {
return @"⌨️";
} else if (trait == UIAccessibilityTraitStaticText) {
return @"📄";
} else if (trait == UIAccessibilityTraitSummaryElement) {
return @"";
} else if (trait == UIAccessibilityTraitNotEnabled) {
return @"";
} else if (trait == UIAccessibilityTraitUpdatesFrequently) {
return @"🔄";
} else if (trait == UIAccessibilityTraitStartsMediaSession) {
return @"🎬";
} else if (trait == UIAccessibilityTraitAdjustable) {
return @"🔧";
} else if (trait == UIAccessibilityTraitAllowsDirectInteraction) {
return @"👆";
} else if (trait == UIAccessibilityTraitCausesPageTurn) {
return @"📖";
} else if (trait == UIAccessibilityTraitTabBar) {
return @"🗂️";
}
return nil;
}
@end
#endif