blob: ace6b12c7e5536ffb8e107c69379399310e93552 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'finders.dart';
import 'widget_tester.dart';
/// The result of evaluating a semantics node by a [AccessibilityGuideline].
class Evaluation {
/// Create a passing evaluation.
const Evaluation.pass()
: passed = true,
reason = null;
/// Create a failing evaluation, with an optional [reason] explaining the
/// result.
const Evaluation.fail([this.reason]) : passed = false;
// private constructor for adding cases together.
const Evaluation._(this.passed, this.reason);
/// Whether the given tree or node passed the policy evaluation.
final bool passed;
/// If [passed] is false, contains the reason for failure.
final String? reason;
/// Combines two evaluation results.
///
/// The [reason] will be concatenated with a newline, and [passed] will be
/// combined with an `&&` operator.
Evaluation operator +(Evaluation? other) {
if (other == null)
return this;
final StringBuffer buffer = StringBuffer();
if (reason != null) {
buffer.write(reason);
buffer.write(' ');
}
if (other.reason != null)
buffer.write(other.reason);
return Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString());
}
}
/// An accessibility guideline describes a recommendation an application should
/// meet to be considered accessible.
abstract class AccessibilityGuideline {
/// A const constructor allows subclasses to be const.
const AccessibilityGuideline();
/// Evaluate whether the current state of the `tester` conforms to the rule.
FutureOr<Evaluation> evaluate(WidgetTester tester);
/// A description of the policy restrictions and criteria.
String get description;
}
/// A guideline which enforces that all tappable semantics nodes have a minimum
/// size.
///
/// Each platform defines its own guidelines for minimum tap areas.
@visibleForTesting
class MinimumTapTargetGuideline extends AccessibilityGuideline {
const MinimumTapTargetGuideline._(this.size, this.link);
/// The minimum allowed size of a tappable node.
final Size size;
/// A link describing the tap target guidelines for a platform.
final String link;
@override
FutureOr<Evaluation> evaluate(WidgetTester tester) {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
Evaluation traverse(SemanticsNode node) {
Evaluation result = const Evaluation.pass();
node.visitChildren((SemanticsNode child) {
result += traverse(child);
return true;
});
if (node.isMergedIntoParent)
return result;
final SemanticsData data = node.getSemanticsData();
// Skip node if it has no actions, or is marked as hidden.
if ((!data.hasAction(ui.SemanticsAction.longPress)
&& !data.hasAction(ui.SemanticsAction.tap))
|| data.hasFlag(ui.SemanticsFlag.isHidden))
return result;
Rect paintBounds = node.rect;
SemanticsNode? current = node;
while (current != null) {
if (current.transform != null)
paintBounds = MatrixUtils.transformRect(current.transform!, paintBounds);
current = current.parent;
}
// skip node if it is touching the edge of the screen, since it might
// be partially scrolled offscreen.
const double delta = 0.001;
if (paintBounds.left <= delta
|| paintBounds.top <= delta
|| (paintBounds.bottom - tester.binding.window.physicalSize.height).abs() <= delta
|| (paintBounds.right - tester.binding.window.physicalSize.width).abs() <= delta)
return result;
// shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / tester.binding.window.devicePixelRatio;
if (candidateSize.width < size.width - delta || candidateSize.height < size.height - delta) {
result += Evaluation.fail(
'$node: expected tap target size of at least $size, but found $candidateSize\n'
'See also: $link');
}
return result;
}
return traverse(root);
}
@override
String get description => 'Tappable objects should be at least $size';
}
/// A guideline which enforces that all nodes with a tap or long press action
/// also have a label.
@visibleForTesting
class LabeledTapTargetGuideline extends AccessibilityGuideline {
const LabeledTapTargetGuideline._();
@override
String get description => 'Tappable widgets should have a semantic label';
@override
FutureOr<Evaluation> evaluate(WidgetTester tester) {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
Evaluation traverse(SemanticsNode node) {
Evaluation result = const Evaluation.pass();
node.visitChildren((SemanticsNode child) {
result += traverse(child);
return true;
});
if (node.isMergedIntoParent || node.isInvisible || node.hasFlag(ui.SemanticsFlag.isHidden))
return result;
final SemanticsData data = node.getSemanticsData();
// Skip node if it has no actions, or is marked as hidden.
if (!data.hasAction(ui.SemanticsAction.longPress) && !data.hasAction(ui.SemanticsAction.tap))
return result;
if (data.label == null || data.label.isEmpty) {
result += Evaluation.fail(
'$node: expected tappable node to have semantic label, but none was found\n',
);
}
return result;
}
return traverse(root);
}
}
/// A guideline which verifies that all nodes that contribute semantics via text
/// meet minimum contrast levels.
///
/// The guidelines are defined by the Web Content Accessibility Guidelines,
/// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
@visibleForTesting
class MinimumTextContrastGuideline extends AccessibilityGuideline {
const MinimumTextContrastGuideline._();
/// The minimum text size considered large for contrast checking.
///
/// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
static const int kLargeTextMinimumSize = 18;
/// The minimum text size for bold text to be considered large for contrast
/// checking.
///
/// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
static const int kBoldTextMinimumSize = 14;
/// The minimum contrast ratio for normal text.
///
/// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
static const double kMinimumRatioNormalText = 4.5;
/// The minimum contrast ratio for large text.
///
/// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
static const double kMinimumRatioLargeText = 3.0;
static const double _kDefaultFontSize = 12.0;
@override
Future<Evaluation> evaluate(WidgetTester tester) async {
final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!;
final RenderView renderView = tester.binding.renderView;
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
ui.Image? image;
final ByteData byteData = (await tester.binding.runAsync<ByteData?>(() async {
// Needs to be the same pixel ratio otherwise our dimensions won't match the
// last transform layer.
image = await layer.toImage(renderView.paintBounds, pixelRatio: 1 / tester.binding.window.devicePixelRatio);
return image!.toByteData();
}))!;
assert(image != null);
Future<Evaluation> evaluateNode(SemanticsNode node) async {
Evaluation result = const Evaluation.pass();
if (node.isInvisible || node.isMergedIntoParent || node.hasFlag(ui.SemanticsFlag.isHidden))
return result;
final SemanticsData data = node.getSemanticsData();
final List<SemanticsNode> children = <SemanticsNode>[];
node.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
for (final SemanticsNode child in children) {
result += await evaluateNode(child);
}
if (_shouldSkipNode(data)) {
return result;
}
// We need to look up the inherited text properties to determine the
// contrast ratio based on text size/weight.
double? fontSize;
bool isBold;
final String text = data.label.isEmpty ? data.value : data.label;
final List<Element> elements = find.text(text).hitTestable().evaluate().toList();
Rect paintBounds;
if (elements.length == 1) {
final Element element = elements.single;
assert(element.renderObject != null && element.renderObject is RenderBox);
final RenderBox renderObject = element.renderObject! as RenderBox;
paintBounds = Rect.fromPoints(
renderObject.localToGlobal(renderObject.paintBounds.topLeft - const Offset(4.0, 4.0)),
renderObject.localToGlobal(renderObject.paintBounds.bottomRight + const Offset(4.0, 4.0)),
);
final Widget widget = element.widget;
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element);
if (widget is Text) {
final TextStyle effectiveTextStyle = widget.style == null || widget.style!.inherit
? defaultTextStyle.style.merge(widget.style)
: widget.style!;
fontSize = effectiveTextStyle.fontSize;
isBold = effectiveTextStyle.fontWeight == FontWeight.bold;
} else if (widget is EditableText) {
isBold = widget.style.fontWeight == FontWeight.bold;
fontSize = widget.style.fontSize;
} else {
throw StateError('Unexpected widget type: ${widget.runtimeType}');
}
} else if (elements.length > 1) {
return Evaluation.fail('Multiple nodes with the same label: ${data.label}\n');
} else {
// If we can't find the text node then assume the label does not
// correspond to actual text.
return result;
}
if (_isNodeOffScreen(paintBounds, tester.binding.window)) {
return result;
}
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image!.width, image!.height);
// Node was too far off screen.
if (subset.isEmpty) {
return result;
}
final _ContrastReport report = _ContrastReport(subset);
// If rectangle is empty, pass the test.
if (report.isEmptyRect) {
return result;
}
final double contrastRatio = report.contrastRatio();
const double delta = -0.01;
double targetContrastRatio;
if ((isBold && (fontSize ?? _kDefaultFontSize) > kBoldTextMinimumSize) || (fontSize ?? _kDefaultFontSize) > kLargeTextMinimumSize) {
targetContrastRatio = kMinimumRatioLargeText;
} else {
targetContrastRatio = kMinimumRatioNormalText;
}
if (contrastRatio - targetContrastRatio >= delta) {
return result + const Evaluation.pass();
}
return result + Evaluation.fail(
'$node:\nExpected contrast ratio of at least '
'$targetContrastRatio but found ${contrastRatio.toStringAsFixed(2)} for a font size of $fontSize. '
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'See also: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html'
);
}
return evaluateNode(root);
}
// Skip routes which might have labels, and nodes without any text.
bool _shouldSkipNode(SemanticsData data) {
if (data.hasFlag(ui.SemanticsFlag.scopesRoute))
return true;
if (data.label.trim().isEmpty && data.value.trim().isEmpty)
return true;
return false;
}
// Returns a rect that is entirely on screen, or null if it is too far off.
//
// Given a pixel buffer based on the physical window size, can we actually
// get all the data from this node? allow a small delta overlap before
// culling the node.
bool _isNodeOffScreen(Rect paintBounds, ui.FlutterView window) {
return paintBounds.top < -50.0
|| paintBounds.left < -50.0
|| paintBounds.bottom > (window.physicalSize.height * window.devicePixelRatio) + 50.0
|| paintBounds.right > (window.physicalSize.width * window.devicePixelRatio) + 50.0;
}
@override
String get description => 'Text contrast should follow WCAG guidelines';
}
/// A guideline which verifies that all elements specified by [finder]
/// meet minimum contrast levels.
class CustomMinimumContrastGuideline extends AccessibilityGuideline {
/// Creates a custom guideline which verifies that all elements specified
/// by [finder] meet minimum contrast levels.
///
/// An optional description string can be given using the [description] parameter.
const CustomMinimumContrastGuideline({
required this.finder,
this.minimumRatio = 4.5,
this.tolerance = 0.01,
String description = 'Contrast should follow custom guidelines',
}) : _description = description;
/// The minimum contrast ratio allowed.
///
/// Defaults to 4.5, the minimum contrast
/// ratio for normal text, defined by WCAG.
/// See http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
final double minimumRatio;
/// Tolerance for minimum contrast ratio.
///
/// Any contrast ratio greater than [minimumRatio] or within a distance of [tolerance]
/// from [minimumRatio] passes the test.
/// Defaults to 0.01.
final double tolerance;
/// The [Finder] used to find a subset of elements.
///
/// [finder] determines which subset of elements will be tested for
/// contrast ratio.
final Finder finder;
final String _description;
@override
String get description => _description;
@override
Future<Evaluation> evaluate(WidgetTester tester) async {
// Compute elements to be evaluated.
final List<Element> elements = finder.evaluate().toList();
// Obtain rendered image.
final RenderView renderView = tester.binding.renderView;
final OffsetLayer layer = renderView.debugLayer! as OffsetLayer;
ui.Image? image;
final ByteData byteData = (await tester.binding.runAsync<ByteData?>(() async {
// Needs to be the same pixel ratio otherwise our dimensions won't match the
// last transform layer.
image = await layer.toImage(renderView.paintBounds, pixelRatio: 1 / tester.binding.window.devicePixelRatio);
return image!.toByteData();
}))!;
assert(image != null);
// How to evaluate a single element.
Evaluation evaluateElement(Element element) {
final RenderBox renderObject = element.renderObject! as RenderBox;
final Rect originalPaintBounds = renderObject.paintBounds;
final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
final Rect paintBounds = Rect.fromPoints(
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
);
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image!.width, image!.height);
if (subset.isEmpty) {
return const Evaluation.pass();
}
final _ContrastReport report = _ContrastReport(subset);
final double contrastRatio = report.contrastRatio();
if (report.isEmptyRect || contrastRatio >= minimumRatio - tolerance) {
return const Evaluation.pass();
} else {
return Evaluation.fail(
'$element:\nExpected contrast ratio of at least '
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'$description'
);
}
}
// Collate all evaluations into a final evaluation, then return.
Evaluation result = const Evaluation.pass();
for (final Element element in elements) {
result = result + evaluateElement(element);
}
return result;
}
}
/// A class that reports the contrast ratio of a part of the screen.
///
/// Commonly used in accessibility testing to obtain the contrast ratio of
/// text widgets and other types of widgets.
class _ContrastReport {
/// Generates a contrast report given a list of colors.
///
/// Given a list of integers [colors], each representing the color of a pixel
/// on a part of the screen, generates a contrast ratio report.
/// Each colors is given in in ARGB format, as is the parameter for the
/// constructor [Color].
///
/// The contrast ratio of the most frequent light color and the most
/// frequent dark color is calculated. Colors are divided into light and
/// dark colors based on their lightness as an [HSLColor].
factory _ContrastReport(List<int> colors) {
final Map<int, int> colorHistogram = <int, int>{};
for (final int color in colors) {
colorHistogram[color] = (colorHistogram[color] ?? 0) + 1;
}
if (colorHistogram.length == 1) {
final Color hslColor = Color(colorHistogram.keys.first);
return _ContrastReport._(hslColor, hslColor);
}
// to determine the lighter and darker color, partition the colors
// by lightness and then choose the mode from each group.
double averageLightness = 0.0;
for (final int color in colorHistogram.keys) {
final HSLColor hslColor = HSLColor.fromColor(Color(color));
averageLightness += hslColor.lightness * colorHistogram[color]!;
}
averageLightness /= colors.length;
assert(averageLightness != double.nan);
int lightColor = 0;
int darkColor = 0;
int lightCount = 0;
int darkCount = 0;
// Find the most frequently occurring light and dark color.
for (final MapEntry<int, int> entry in colorHistogram.entries) {
final HSLColor color = HSLColor.fromColor(Color(entry.key));
final int count = entry.value;
if (color.lightness <= averageLightness && count > darkCount) {
darkColor = entry.key;
darkCount = count;
} else if (color.lightness > averageLightness && count > lightCount) {
lightColor = entry.key;
lightCount = count;
}
}
// Depending on the number of colors present, return the correct contrast
// report.
if (lightCount > 0 && darkCount > 0) {
return _ContrastReport._(Color(lightColor), Color(darkColor));
} else if (lightCount > 0) {
return _ContrastReport.singleColor(Color(lightColor));
} else if (darkCount > 0) {
return _ContrastReport.singleColor(Color(darkColor));
} else {
return const _ContrastReport.emptyRect();
}
}
const _ContrastReport._(this.lightColor, this.darkColor)
: isSingleColor = false,
isEmptyRect = false;
const _ContrastReport.singleColor(Color color)
: lightColor = color,
darkColor = color,
isSingleColor = true,
isEmptyRect = false;
const _ContrastReport.emptyRect()
: lightColor = _transparent,
darkColor = _transparent,
isSingleColor = false,
isEmptyRect = true;
static const Color _transparent = Color(0x00000000);
/// The most frequently occurring light color. Uses [Colors.transparent] if
/// the rectangle is empty.
final Color lightColor;
/// The most frequently occurring dark color. Uses [Colors.transparent] if
/// the rectangle is empty.
final Color darkColor;
/// Whether the rectangle contains only one color.
final bool isSingleColor;
/// Whether the rectangle contains 0 pixels.
final bool isEmptyRect;
/// Computes the contrast ratio as defined by the WCAG.
///
/// source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
double contrastRatio() {
return (_luminance(lightColor) + 0.05) / (_luminance(darkColor) + 0.05);
}
/// Relative luminance calculation.
///
/// Based on https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
static double _luminance(Color color) {
double r = color.red / 255.0;
double g = color.green / 255.0;
double b = color.blue / 255.0;
if (r <= 0.03928)
r /= 12.92;
else
r = math.pow((r + 0.055)/ 1.055, 2.4).toDouble();
if (g <= 0.03928)
g /= 12.92;
else
g = math.pow((g + 0.055)/ 1.055, 2.4).toDouble();
if (b <= 0.03928)
b /= 12.92;
else
b = math.pow((b + 0.055)/ 1.055, 2.4).toDouble();
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
}
/// Gives the colors of all pixels inside a given rectangle on the screen.
///
/// Given a [ByteData] object [data], which stores the color of each pixel
/// in row-first order, where each pixel is given in 4 bytes in RGBA order,
/// and [paintBounds], the rectangle,
/// and [width] and [height], the dimensions of the [ByteData],
/// returns a list of the colors of all pixels within the rectangle in
/// row-first order.
///
/// In the returned list, each color is represented as a 32-bit integer
/// in ARGB format, similar to the parameter for the [Color] constructor.
List<int> _colorsWithinRect(ByteData data, Rect paintBounds, int width, int height) {
final Rect truePaintBounds = paintBounds.intersect(
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
);
final int leftX = truePaintBounds.left.floor();
final int rightX = truePaintBounds.right.ceil();
final int topY = truePaintBounds.top.floor();
final int bottomY = truePaintBounds.bottom.ceil();
final List<int> buffer = <int>[];
int _getPixel(ByteData data, int x, int y) {
final int offset = (y * width + x) * 4;
final int r = data.getUint8(offset);
final int g = data.getUint8(offset + 1);
final int b = data.getUint8(offset + 2);
final int a = data.getUint8(offset + 3);
final int color = (((a & 0xff) << 24) |
((r & 0xff) << 16) |
((g & 0xff) << 8) |
((b & 0xff) << 0)) & 0xFFFFFFFF;
return color;
}
for (int x = leftX; x < rightX; x ++) {
for (int y = topY; y < bottomY; y ++) {
buffer.add(_getPixel(data, x, y));
}
}
return buffer;
}
/// A guideline which requires tappable semantic nodes a minimum size of 48 by 48.
///
/// See also:
///
/// * [Android tap target guidelines](https://support.google.com/accessibility/android/answer/7101858?hl=en).
const AccessibilityGuideline androidTapTargetGuideline = MinimumTapTargetGuideline._(
Size(48.0, 48.0),
'https://support.google.com/accessibility/android/answer/7101858?hl=en',
);
/// A guideline which requires tappable semantic nodes a minimum size of 44 by 44.
///
/// See also:
///
/// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/).
const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline._(
Size(44.0, 44.0),
'https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/',
);
/// A guideline which requires text contrast to meet minimum values.
///
/// This guideline traverses the semantics tree looking for nodes with values or
/// labels that corresponds to a Text or Editable text widget. Given the
/// background pixels for the area around this widget, it performs a very naive
/// partitioning of the colors into "light" and "dark" and then chooses the most
/// frequently occurring color in each partition as a representative of the
/// foreground and background colors. The contrast ratio is calculated from
/// these colors according to the [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef)
const AccessibilityGuideline textContrastGuideline = MinimumTextContrastGuideline._();
/// A guideline which enforces that all nodes with a tap or long press action
/// also have a label.
const AccessibilityGuideline labeledTapTargetGuideline = LabeledTapTargetGuideline._();