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
435 lines
17 KiB
Objective-C
435 lines
17 KiB
Objective-C
/*
|
||
* 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
|