/
AccessibilityBridge.java
2955 lines (2718 loc) · 121 KB
/
AccessibilityBridge.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright 2013 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.
package io.flutter.view;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Configuration;
import android.database.ContentObserver;
import android.graphics.Rect;
import android.net.Uri;
import android.opengl.Matrix;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.provider.Settings;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.LocaleSpan;
import android.text.style.TtsSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeProvider;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import io.flutter.BuildConfig;
import io.flutter.Log;
import io.flutter.embedding.engine.systemchannels.AccessibilityChannel;
import io.flutter.plugin.platform.PlatformViewsAccessibilityDelegate;
import io.flutter.util.Predicate;
import io.flutter.util.ViewUtils;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Bridge between Android's OS accessibility system and Flutter's accessibility system.
*
* <p>An {@code AccessibilityBridge} requires:
*
* <ul>
* <li>A real Android {@link View}, called the {@link #rootAccessibilityView}, which contains a
* Flutter UI. The {@link #rootAccessibilityView} is required at the time of {@code
* AccessibilityBridge}'s instantiation and is held for the duration of {@code
* AccessibilityBridge}'s lifespan. {@code AccessibilityBridge} invokes various accessibility
* methods on the {@link #rootAccessibilityView}, e.g., {@link
* View#onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo)}. The {@link
* #rootAccessibilityView} is expected to notify the {@code AccessibilityBridge} of relevant
* interactions: {@link #onAccessibilityHoverEvent(MotionEvent)}, {@link #reset()}, {@link
* #updateSemantics(ByteBuffer, String[], ByteBuffer[])}, and {@link
* #updateCustomAccessibilityActions(ByteBuffer, String[])}
* <li>An {@link AccessibilityChannel} that is connected to the running Flutter app.
* <li>Android's {@link AccessibilityManager} to query and listen for accessibility settings.
* <li>Android's {@link ContentResolver} to listen for changes to system animation settings.
* </ul>
*
* The {@code AccessibilityBridge} causes Android to treat Flutter {@code SemanticsNode}s as if they
* were accessible Android {@link View}s. Accessibility requests may be sent from a Flutter widget
* to the Android OS, as if it were an Android {@link View}, and accessibility events may be
* consumed by a Flutter widget, as if it were an Android {@link View}. {@code AccessibilityBridge}
* refers to Flutter's accessible widgets as "virtual views" and identifies them with "virtual view
* IDs".
*/
public class AccessibilityBridge extends AccessibilityNodeProvider {
private static final String TAG = "AccessibilityBridge";
// Constants from higher API levels.
// TODO(goderbauer): Get these from Android Support Library when
// https://github.com/flutter/flutter/issues/11099 is resolved.
private static final int ACTION_SHOW_ON_SCREEN = 16908342; // API level 23
private static final float SCROLL_EXTENT_FOR_INFINITY = 100000.0f;
private static final float SCROLL_POSITION_CAP_FOR_INFINITY = 70000.0f;
private static final int ROOT_NODE_ID = 0;
private static final int SCROLLABLE_ACTIONS =
Action.SCROLL_RIGHT.value
| Action.SCROLL_LEFT.value
| Action.SCROLL_UP.value
| Action.SCROLL_DOWN.value;
// Flags that make a node accessibilty focusable.
private static final int FOCUSABLE_FLAGS =
Flag.HAS_CHECKED_STATE.value
| Flag.IS_CHECKED.value
| Flag.IS_SELECTED.value
| Flag.IS_TEXT_FIELD.value
| Flag.IS_FOCUSED.value
| Flag.HAS_ENABLED_STATE.value
| Flag.IS_ENABLED.value
| Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP.value
| Flag.HAS_TOGGLED_STATE.value
| Flag.IS_TOGGLED.value
| Flag.IS_FOCUSABLE.value
| Flag.IS_SLIDER.value;
// The minimal ID for an engine generated AccessibilityNodeInfo.
//
// The AccessibilityNodeInfo node IDs are generated by the framework for most Flutter semantic
// nodes.
// When embedding platform views, the framework does not have the accessibility information for
// the embedded view;
// in this case the engine generates AccessibilityNodeInfo that mirrors the a11y information
// exposed by the platform
// view. To avoid the need of synchronizing the framework and engine mechanisms for generating the
// next ID, we split
// the 32bit range of virtual node IDs into 2. The least significant 16 bits are used for
// framework generated IDs
// and the most significant 16 bits are used for engine generated IDs.
private static final int MIN_ENGINE_GENERATED_NODE_ID = 1 << 16;
// Font weight adjustment for bold text. FontWeight.Bold - FontWeight.Normal = w700 - w400 = 300.
private static final int BOLD_TEXT_WEIGHT_ADJUSTMENT = 300;
/// Value is derived from ACTION_TYPE_MASK in AccessibilityNodeInfo.java
private static int FIRST_RESOURCE_ID = 267386881;
// Real Android View, which internally holds a Flutter UI.
@NonNull private final View rootAccessibilityView;
// The accessibility communication API between Flutter's Android embedding and
// the Flutter framework.
@NonNull private final AccessibilityChannel accessibilityChannel;
// Android's {@link AccessibilityManager}, which we can query to see if accessibility is
// turned on, as well as listen for changes to accessibility's activation.
@NonNull private final AccessibilityManager accessibilityManager;
@NonNull private final AccessibilityViewEmbedder accessibilityViewEmbedder;
// The delegate for interacting with embedded platform views. Used to embed accessibility data for
// an embedded view in the accessibility tree.
@NonNull private final PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate;
// Android's {@link ContentResolver}, which is used to observe the global
// TRANSITION_ANIMATION_SCALE,
// which determines whether Flutter's animations should be enabled or disabled for accessibility
// purposes.
@NonNull private final ContentResolver contentResolver;
// The entire Flutter semantics tree of the running Flutter app, stored as a Map
// from each SemanticsNode's ID to a Java representation of a Flutter SemanticsNode.
//
// Flutter's semantics tree is cached here because Android might ask for information about
// a given SemanticsNode at any moment in time. Caching the tree allows for immediate
// response to Android's request.
//
// The structure of flutterSemanticsTree may be 1 or 2 frames behind the Flutter app
// due to the time required to communicate tree changes from Flutter to Android.
//
// See the Flutter docs on SemanticsNode:
// https://api.flutter.dev/flutter/semantics/SemanticsNode-class.html
@NonNull private final Map<Integer, SemanticsNode> flutterSemanticsTree = new HashMap<>();
// The set of all custom Flutter accessibility actions that are present in the running
// Flutter app, stored as a Map from each action's ID to the definition of the custom
// accessibility
// action.
//
// Flutter and Android support a number of built-in accessibility actions. However, these
// predefined actions are not always sufficient for a desired interaction. Android facilitates
// custom accessibility actions,
// https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction.
// Flutter supports custom accessibility actions via {@code customSemanticsActions} within
// a {@code Semantics} widget, https://api.flutter.dev/flutter/widgets/Semantics-class.html.
// {@code customAccessibilityActions} are an Android-side cache of all custom accessibility
// types declared within the running Flutter app.
//
// Custom accessibility actions are comprised of only a few fields, and therefore it is likely
// that a given app may define the same custom accessibility action many times. Identical
// custom accessibility actions are de-duped such that {@code customAccessibilityActions} only
// caches unique custom accessibility actions.
//
// See the Android documentation for custom accessibility actions:
// https://developer.android.com/reference/android/view/accessibility/AccessibilityNodeInfo.AccessibilityAction
//
// See the Flutter documentation for the Semantics widget:
// https://api.flutter.dev/flutter/widgets/Semantics-class.html
@NonNull
private final Map<Integer, CustomAccessibilityAction> customAccessibilityActions =
new HashMap<>();
// The {@code SemanticsNode} within Flutter that currently has the focus of Android's
// accessibility system.
//
// This is null when a node embedded by the AccessibilityViewEmbedder has the focus.
@Nullable private SemanticsNode accessibilityFocusedSemanticsNode;
// The virtual ID of the currently embedded node with accessibility focus.
//
// This is the ID of a node generated by the AccessibilityViewEmbedder if an embedded node is
// focused,
// null otherwise.
private Integer embeddedAccessibilityFocusedNodeId;
// The virtual ID of the currently embedded node with input focus.
//
// This is the ID of a node generated by the AccessibilityViewEmbedder if an embedded node is
// focused,
// null otherwise.
private Integer embeddedInputFocusedNodeId;
// The accessibility features that should currently be active within Flutter, represented as
// a bitmask whose values comes from {@link AccessibilityFeature}.
private int accessibilityFeatureFlags = 0;
// The {@code SemanticsNode} within Flutter that currently has the focus of Android's input
// system.
//
// Input focus is independent of accessibility focus. It is possible that accessibility focus
// and input focus target the same {@code SemanticsNode}, but it is also possible that one
// {@code SemanticsNode} has input focus while a different {@code SemanticsNode} has
// accessibility focus. For example, a user may use a D-Pad to navigate to a text field, giving
// it accessibility focus, and then enable input on that text field, giving it input focus. Then
// the user moves the accessibility focus to a nearby label to get info about the label, while
// maintaining input focus on the original text field.
@Nullable private SemanticsNode inputFocusedSemanticsNode;
// Keeps track of the last semantics node that had the input focus.
//
// This is used to determine if the input focus has changed since the last time the
// {@code inputFocusSemanticsNode} has been set, so that we can send a {@code TYPE_VIEW_FOCUSED}
// event when it changes.
@Nullable private SemanticsNode lastInputFocusedSemanticsNode;
// The widget within Flutter that currently sits beneath a cursor, e.g,
// beneath a stylus or mouse cursor.
@Nullable private SemanticsNode hoveredObject;
@VisibleForTesting
public int getHoveredObjectId() {
return hoveredObject.id;
}
// A Java/Android cached representation of the Flutter app's navigation stack. The Flutter
// navigation stack is tracked so that accessibility announcements can be made during Flutter's
// navigation changes.
// TODO(mattcarroll): take this cache into account for new routing solution so accessibility does
// not get left behind.
@NonNull private final List<Integer> flutterNavigationStack = new ArrayList<>();
// TODO(mattcarroll): why do we need previouseRouteId if we have flutterNavigationStack
private int previousRouteId = ROOT_NODE_ID;
// Tracks the left system inset of the screen because Flutter needs to manually adjust
// accessibility positioning when in reverse-landscape. This is an Android bug that Flutter
// is solving for itself.
@NonNull private Integer lastLeftFrameInset = 0;
@Nullable private OnAccessibilityChangeListener onAccessibilityChangeListener;
// Whether the users are using assistive technologies to interact with the devices.
//
// The getter returns true when at least one of the assistive technologies is running:
// TalkBack, SwitchAccess, or VoiceAccess.
@VisibleForTesting
public boolean getAccessibleNavigation() {
return accessibleNavigation;
}
private boolean accessibleNavigation = false;
private void setAccessibleNavigation(boolean value) {
if (accessibleNavigation == value) {
return;
}
accessibleNavigation = value;
if (accessibleNavigation) {
accessibilityFeatureFlags |= AccessibilityFeature.ACCESSIBLE_NAVIGATION.value;
} else {
accessibilityFeatureFlags &= ~AccessibilityFeature.ACCESSIBLE_NAVIGATION.value;
}
sendLatestAccessibilityFlagsToFlutter();
}
// Set to true after {@code release} has been invoked.
private boolean isReleased = false;
// Handler for all messages received from Flutter via the {@code accessibilityChannel}
private final AccessibilityChannel.AccessibilityMessageHandler accessibilityMessageHandler =
new AccessibilityChannel.AccessibilityMessageHandler() {
/** The Dart application would like the given {@code message} to be announced. */
@Override
public void announce(@NonNull String message) {
rootAccessibilityView.announceForAccessibility(message);
}
/** The user has tapped on the widget with the given {@code nodeId}. */
@Override
public void onTap(int nodeId) {
sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_CLICKED);
}
/** The user has long pressed on the widget with the given {@code nodeId}. */
@Override
public void onLongPress(int nodeId) {
sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
}
/** The framework has requested focus on the given {@code nodeId}. */
@Override
public void onFocus(int nodeId) {
sendAccessibilityEvent(nodeId, AccessibilityEvent.TYPE_VIEW_FOCUSED);
}
/** The user has opened a tooltip. */
@Override
public void onTooltip(@NonNull String message) {
// Native Android tooltip is no longer announced when it pops up after API 28 and is
// handled by
// AccessibilityNodeInfo.setTooltipText instead.
//
// To reproduce native behavior, see
// https://developer.android.com/guide/topics/ui/tooltips.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return;
}
AccessibilityEvent e =
obtainAccessibilityEvent(ROOT_NODE_ID, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
e.getText().add(message);
sendAccessibilityEvent(e);
}
/** New custom accessibility actions exist in Flutter. Update our Android-side cache. */
@Override
public void updateCustomAccessibilityActions(ByteBuffer buffer, String[] strings) {
buffer.order(ByteOrder.LITTLE_ENDIAN);
AccessibilityBridge.this.updateCustomAccessibilityActions(buffer, strings);
}
/** Flutter's semantics tree has changed. Update our Android-side cache. */
@Override
public void updateSemantics(
ByteBuffer buffer, String[] strings, ByteBuffer[] stringAttributeArgs) {
buffer.order(ByteOrder.LITTLE_ENDIAN);
for (ByteBuffer args : stringAttributeArgs) {
args.order(ByteOrder.LITTLE_ENDIAN);
}
AccessibilityBridge.this.updateSemantics(buffer, strings, stringAttributeArgs);
}
};
// Listener that is notified when accessibility is turned on/off.
private final AccessibilityManager.AccessibilityStateChangeListener
accessibilityStateChangeListener =
new AccessibilityManager.AccessibilityStateChangeListener() {
@Override
public void onAccessibilityStateChanged(boolean accessibilityEnabled) {
if (isReleased) {
return;
}
if (accessibilityEnabled) {
accessibilityChannel.setAccessibilityMessageHandler(accessibilityMessageHandler);
accessibilityChannel.onAndroidAccessibilityEnabled();
} else {
setAccessibleNavigation(false);
accessibilityChannel.setAccessibilityMessageHandler(null);
accessibilityChannel.onAndroidAccessibilityDisabled();
}
if (onAccessibilityChangeListener != null) {
onAccessibilityChangeListener.onAccessibilityChanged(
accessibilityEnabled, accessibilityManager.isTouchExplorationEnabled());
}
}
};
// Listener that is notified when accessibility touch exploration is turned on/off.
// This is guarded at instantiation time.
@TargetApi(19)
@RequiresApi(19)
private final AccessibilityManager.TouchExplorationStateChangeListener
touchExplorationStateChangeListener;
// Listener that is notified when the global TRANSITION_ANIMATION_SCALE. When this scale goes
// to zero, we instruct Flutter to disable animations.
private final ContentObserver animationScaleObserver =
new ContentObserver(new Handler()) {
@Override
public void onChange(boolean selfChange) {
this.onChange(selfChange, null);
}
@Override
public void onChange(boolean selfChange, Uri uri) {
if (isReleased) {
return;
}
// Retrieve the current value of TRANSITION_ANIMATION_SCALE from the OS.
String value =
Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1
? null
: Settings.Global.getString(
contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE);
boolean shouldAnimationsBeDisabled = value != null && value.equals("0");
if (shouldAnimationsBeDisabled) {
accessibilityFeatureFlags |= AccessibilityFeature.DISABLE_ANIMATIONS.value;
} else {
accessibilityFeatureFlags &= ~AccessibilityFeature.DISABLE_ANIMATIONS.value;
}
sendLatestAccessibilityFlagsToFlutter();
}
};
public AccessibilityBridge(
@NonNull View rootAccessibilityView,
@NonNull AccessibilityChannel accessibilityChannel,
@NonNull AccessibilityManager accessibilityManager,
@NonNull ContentResolver contentResolver,
@NonNull PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) {
this(
rootAccessibilityView,
accessibilityChannel,
accessibilityManager,
contentResolver,
new AccessibilityViewEmbedder(rootAccessibilityView, MIN_ENGINE_GENERATED_NODE_ID),
platformViewsAccessibilityDelegate);
}
@VisibleForTesting
public AccessibilityBridge(
@NonNull View rootAccessibilityView,
@NonNull AccessibilityChannel accessibilityChannel,
@NonNull AccessibilityManager accessibilityManager,
@NonNull ContentResolver contentResolver,
@NonNull AccessibilityViewEmbedder accessibilityViewEmbedder,
@NonNull PlatformViewsAccessibilityDelegate platformViewsAccessibilityDelegate) {
this.rootAccessibilityView = rootAccessibilityView;
this.accessibilityChannel = accessibilityChannel;
this.accessibilityManager = accessibilityManager;
this.contentResolver = contentResolver;
this.accessibilityViewEmbedder = accessibilityViewEmbedder;
this.platformViewsAccessibilityDelegate = platformViewsAccessibilityDelegate;
// Tell Flutter whether accessibility is initially active or not. Then register a listener
// to be notified of changes in the future.
accessibilityStateChangeListener.onAccessibilityStateChanged(accessibilityManager.isEnabled());
this.accessibilityManager.addAccessibilityStateChangeListener(accessibilityStateChangeListener);
// Tell Flutter whether touch exploration is initially active or not. Then register a listener
// to be notified of changes in the future.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
touchExplorationStateChangeListener =
new AccessibilityManager.TouchExplorationStateChangeListener() {
@Override
public void onTouchExplorationStateChanged(boolean isTouchExplorationEnabled) {
if (isReleased) {
return;
}
if (!isTouchExplorationEnabled) {
setAccessibleNavigation(false);
onTouchExplorationExit();
}
if (onAccessibilityChangeListener != null) {
onAccessibilityChangeListener.onAccessibilityChanged(
accessibilityManager.isEnabled(), isTouchExplorationEnabled);
}
}
};
touchExplorationStateChangeListener.onTouchExplorationStateChanged(
accessibilityManager.isTouchExplorationEnabled());
this.accessibilityManager.addTouchExplorationStateChangeListener(
touchExplorationStateChangeListener);
} else {
touchExplorationStateChangeListener = null;
}
// Tell Flutter whether animations should initially be enabled or disabled. Then register a
// listener to be notified of changes in the future.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
animationScaleObserver.onChange(false);
Uri transitionUri = Settings.Global.getUriFor(Settings.Global.TRANSITION_ANIMATION_SCALE);
this.contentResolver.registerContentObserver(transitionUri, false, animationScaleObserver);
}
// Tells Flutter whether the text should be bolded or not. If the user changes bold text
// setting, the configuration will change and trigger a re-build of the accesibiltyBridge.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setBoldTextFlag();
}
platformViewsAccessibilityDelegate.attachAccessibilityBridge(this);
}
/**
* Disconnects any listeners and/or delegates that were initialized in {@code
* AccessibilityBridge}'s constructor, or added after.
*
* <p>Do not use this instance after invoking {@code release}. The behavior of any method invoked
* on this {@code AccessibilityBridge} after invoking {@code release()} is undefined.
*/
public void release() {
isReleased = true;
platformViewsAccessibilityDelegate.detachAccessibilityBridge();
setOnAccessibilityChangeListener(null);
accessibilityManager.removeAccessibilityStateChangeListener(accessibilityStateChangeListener);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
accessibilityManager.removeTouchExplorationStateChangeListener(
touchExplorationStateChangeListener);
}
contentResolver.unregisterContentObserver(animationScaleObserver);
accessibilityChannel.setAccessibilityMessageHandler(null);
}
/** Returns true if the Android OS currently has accessibility enabled, false otherwise. */
public boolean isAccessibilityEnabled() {
return accessibilityManager.isEnabled();
}
/** Returns true if the Android OS currently has touch exploration enabled, false otherwise. */
public boolean isTouchExplorationEnabled() {
return accessibilityManager.isTouchExplorationEnabled();
}
/**
* Sets a listener on this {@code AccessibilityBridge}, which is notified whenever accessibility
* activation, or touch exploration activation changes.
*/
public void setOnAccessibilityChangeListener(@Nullable OnAccessibilityChangeListener listener) {
this.onAccessibilityChangeListener = listener;
}
/** Sends the current value of {@link #accessibilityFeatureFlags} to Flutter. */
private void sendLatestAccessibilityFlagsToFlutter() {
accessibilityChannel.setAccessibilityFeatures(accessibilityFeatureFlags);
}
private boolean shouldSetCollectionInfo(final SemanticsNode semanticsNode) {
// TalkBack expects a number of rows and/or columns greater than 0 to announce
// in list and out of list. For an infinite or growing list, you have to
// specify something > 0 to get "in list" announcements.
// TalkBack will also only track one list at a time, so we only want to set this
// for a list that contains the current a11y focused semanticsNode - otherwise, if there
// are two lists or nested lists, we may end up with announcements for only the last
// one that is currently available in the semantics tree. However, we also want
// to set it if we're exiting a list to a non-list, so that we can get the "out of list"
// announcement when A11y focus moves out of a list and not into another list.
return semanticsNode.scrollChildren > 0
&& (SemanticsNode.nullableHasAncestor(
accessibilityFocusedSemanticsNode, o -> o == semanticsNode)
|| !SemanticsNode.nullableHasAncestor(
accessibilityFocusedSemanticsNode, o -> o.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)));
}
@TargetApi(31)
@RequiresApi(31)
private void setBoldTextFlag() {
if (rootAccessibilityView == null || rootAccessibilityView.getResources() == null) {
return;
}
int fontWeightAdjustment =
rootAccessibilityView.getResources().getConfiguration().fontWeightAdjustment;
boolean shouldBold =
fontWeightAdjustment != Configuration.FONT_WEIGHT_ADJUSTMENT_UNDEFINED
&& fontWeightAdjustment >= BOLD_TEXT_WEIGHT_ADJUSTMENT;
if (shouldBold) {
accessibilityFeatureFlags |= AccessibilityFeature.BOLD_TEXT.value;
} else {
accessibilityFeatureFlags &= AccessibilityFeature.BOLD_TEXT.value;
}
sendLatestAccessibilityFlagsToFlutter();
}
@VisibleForTesting
public AccessibilityNodeInfo obtainAccessibilityNodeInfo(View rootView, int virtualViewId) {
return AccessibilityNodeInfo.obtain(rootView, virtualViewId);
}
/**
* Returns {@link AccessibilityNodeInfo} for the view corresponding to the given {@code
* virtualViewId}.
*
* <p>This method is invoked by Android's accessibility system when Android needs accessibility
* info for a given view.
*
* <p>When a {@code virtualViewId} of {@link View#NO_ID} is requested, accessibility node info is
* returned for our {@link #rootAccessibilityView}. Otherwise, Flutter's semantics tree,
* represented by {@link #flutterSemanticsTree}, is searched for a {@link SemanticsNode} with the
* given {@code virtualViewId}. If no such {@link SemanticsNode} is found, then this method
* returns null. If the desired {@link SemanticsNode} is found, then an {@link
* AccessibilityNodeInfo} is obtained from the {@link #rootAccessibilityView}, filled with
* appropriate info, and then returned.
*
* <p>Depending on the type of Flutter {@code SemanticsNode} that is requested, the returned
* {@link AccessibilityNodeInfo} pretends that the {@code SemanticsNode} in question comes from a
* specialize Android view, e.g., {@link Flag#IS_TEXT_FIELD} maps to {@code
* android.widget.EditText}, {@link Flag#IS_BUTTON} maps to {@code android.widget.Button}, and
* {@link Flag#IS_IMAGE} maps to {@code android.widget.ImageView}. In the case that no specialized
* view applies, the returned {@link AccessibilityNodeInfo} pretends that it represents a {@code
* android.view.View}.
*/
@Override
@SuppressWarnings("deprecation")
// Suppressing Lint warning for new API, as we are version guarding all calls to newer APIs
@SuppressLint("NewApi")
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
setAccessibleNavigation(true);
if (virtualViewId >= MIN_ENGINE_GENERATED_NODE_ID) {
// The node is in the engine generated range, and is provided by the accessibility view
// embedder.
return accessibilityViewEmbedder.createAccessibilityNodeInfo(virtualViewId);
}
if (virtualViewId == View.NO_ID) {
AccessibilityNodeInfo result = AccessibilityNodeInfo.obtain(rootAccessibilityView);
rootAccessibilityView.onInitializeAccessibilityNodeInfo(result);
// TODO(mattcarroll): what does it mean for the semantics tree to contain or not contain
// the root node ID?
if (flutterSemanticsTree.containsKey(ROOT_NODE_ID)) {
result.addChild(rootAccessibilityView, ROOT_NODE_ID);
}
return result;
}
SemanticsNode semanticsNode = flutterSemanticsTree.get(virtualViewId);
if (semanticsNode == null) {
return null;
}
// Generate accessibility node for platform views using a virtual display.
//
// In this case, register the accessibility node in the view embedder,
// so the accessibility tree can be mirrored as a subtree of the Flutter accessibility tree.
// This is in constrast to hybrid composition where the embedded view is in the view hiearchy,
// so it doesn't need to be mirrored.
//
// See the case down below for how hybrid composition is handled.
if (semanticsNode.platformViewId != -1) {
if (platformViewsAccessibilityDelegate.usesVirtualDisplay(semanticsNode.platformViewId)) {
View embeddedView =
platformViewsAccessibilityDelegate.getPlatformViewById(semanticsNode.platformViewId);
if (embeddedView == null) {
return null;
}
Rect bounds = semanticsNode.getGlobalRect();
return accessibilityViewEmbedder.getRootNode(embeddedView, semanticsNode.id, bounds);
}
}
AccessibilityNodeInfo result =
obtainAccessibilityNodeInfo(rootAccessibilityView, virtualViewId);
// Work around for https://github.com/flutter/flutter/issues/2101
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setViewIdResourceName("");
}
result.setPackageName(rootAccessibilityView.getContext().getPackageName());
result.setClassName("android.view.View");
result.setSource(rootAccessibilityView, virtualViewId);
result.setFocusable(semanticsNode.isFocusable());
if (inputFocusedSemanticsNode != null) {
result.setFocused(inputFocusedSemanticsNode.id == virtualViewId);
}
if (accessibilityFocusedSemanticsNode != null) {
result.setAccessibilityFocused(accessibilityFocusedSemanticsNode.id == virtualViewId);
}
if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) {
result.setPassword(semanticsNode.hasFlag(Flag.IS_OBSCURED));
if (!semanticsNode.hasFlag(Flag.IS_READ_ONLY)) {
result.setClassName("android.widget.EditText");
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setEditable(!semanticsNode.hasFlag(Flag.IS_READ_ONLY));
if (semanticsNode.textSelectionBase != -1 && semanticsNode.textSelectionExtent != -1) {
result.setTextSelection(
semanticsNode.textSelectionBase, semanticsNode.textSelectionExtent);
}
// Text fields will always be created as a live region when they have input focus,
// so that updates to the label trigger polite announcements. This makes it easy to
// follow a11y guidelines for text fields on Android.
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& accessibilityFocusedSemanticsNode != null
&& accessibilityFocusedSemanticsNode.id == virtualViewId) {
result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
}
}
// Cursor movements
int granularities = 0;
if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_CHARACTER)) {
result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_CHARACTER)) {
result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_FORWARD_BY_WORD)) {
result.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD;
}
if (semanticsNode.hasAction(Action.MOVE_CURSOR_BACKWARD_BY_WORD)) {
result.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
granularities |= AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD;
}
result.setMovementGranularities(granularities);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& semanticsNode.maxValueLength >= 0) {
// Account for the fact that Flutter is counting Unicode scalar values and Android
// is counting UTF16 words.
final int length = semanticsNode.value == null ? 0 : semanticsNode.value.length();
int a = length - semanticsNode.currentValueLength + semanticsNode.maxValueLength;
result.setMaxTextLength(
length - semanticsNode.currentValueLength + semanticsNode.maxValueLength);
}
}
// These are non-ops on older devices. Attempting to interact with the text will cause Talkback
// to read the contents of the text box instead.
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) {
if (semanticsNode.hasAction(Action.SET_SELECTION)) {
result.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
}
if (semanticsNode.hasAction(Action.COPY)) {
result.addAction(AccessibilityNodeInfo.ACTION_COPY);
}
if (semanticsNode.hasAction(Action.CUT)) {
result.addAction(AccessibilityNodeInfo.ACTION_CUT);
}
if (semanticsNode.hasAction(Action.PASTE)) {
result.addAction(AccessibilityNodeInfo.ACTION_PASTE);
}
}
// Set text API isn't available until API 21.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (semanticsNode.hasAction(Action.SET_TEXT)) {
result.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT);
}
}
if (semanticsNode.hasFlag(Flag.IS_BUTTON) || semanticsNode.hasFlag(Flag.IS_LINK)) {
result.setClassName("android.widget.Button");
}
if (semanticsNode.hasFlag(Flag.IS_IMAGE)) {
result.setClassName("android.widget.ImageView");
// TODO(jonahwilliams): Figure out a way conform to the expected id from TalkBack's
// CustomLabelManager. talkback/src/main/java/labeling/CustomLabelManager.java#L525
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& semanticsNode.hasAction(Action.DISMISS)) {
result.setDismissable(true);
result.addAction(AccessibilityNodeInfo.ACTION_DISMISS);
}
if (semanticsNode.parent != null) {
if (BuildConfig.DEBUG && semanticsNode.id <= ROOT_NODE_ID) {
Log.e(TAG, "Semantics node id is not > ROOT_NODE_ID.");
}
result.setParent(rootAccessibilityView, semanticsNode.parent.id);
} else {
if (BuildConfig.DEBUG && semanticsNode.id != ROOT_NODE_ID) {
Log.e(TAG, "Semantics node id does not equal ROOT_NODE_ID.");
}
result.setParent(rootAccessibilityView);
}
if (semanticsNode.previousNodeId != -1
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
result.setTraversalAfter(rootAccessibilityView, semanticsNode.previousNodeId);
}
Rect bounds = semanticsNode.getGlobalRect();
if (semanticsNode.parent != null) {
Rect parentBounds = semanticsNode.parent.getGlobalRect();
Rect boundsInParent = new Rect(bounds);
boundsInParent.offset(-parentBounds.left, -parentBounds.top);
result.setBoundsInParent(boundsInParent);
} else {
result.setBoundsInParent(bounds);
}
final Rect boundsInScreen = getBoundsInScreen(bounds);
result.setBoundsInScreen(boundsInScreen);
result.setVisibleToUser(true);
result.setEnabled(
!semanticsNode.hasFlag(Flag.HAS_ENABLED_STATE) || semanticsNode.hasFlag(Flag.IS_ENABLED));
if (semanticsNode.hasAction(Action.TAP)) {
if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onTapOverride != null) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.ACTION_CLICK, semanticsNode.onTapOverride.hint));
result.setClickable(true);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_CLICK);
result.setClickable(true);
}
}
if (semanticsNode.hasAction(Action.LONG_PRESS)) {
if (Build.VERSION.SDK_INT >= 21 && semanticsNode.onLongPressOverride != null) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(
AccessibilityNodeInfo.ACTION_LONG_CLICK, semanticsNode.onLongPressOverride.hint));
result.setLongClickable(true);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
result.setLongClickable(true);
}
}
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_UP)
|| semanticsNode.hasAction(Action.SCROLL_RIGHT)
|| semanticsNode.hasAction(Action.SCROLL_DOWN)) {
result.setScrollable(true);
// This tells Android's a11y to send scroll events when reaching the end of
// the visible viewport of a scrollable, unless the node itself does not
// allow implicit scrolling - then we leave the className as view.View.
//
// We should prefer setCollectionInfo to the class names, as this way we get "In List"
// and "Out of list" announcements. But we don't always know the counts, so we
// can fallback to the generic scroll view class names.
//
// On older APIs, we always fall back to the generic scroll view class names here.
//
// TODO(dnfield): We should add semantics properties for rows and columns in 2 dimensional
// lists, e.g.
// GridView. Right now, we're only supporting ListViews and only if they have scroll
// children.
if (semanticsNode.hasFlag(Flag.HAS_IMPLICIT_SCROLLING)) {
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_RIGHT)) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT
&& shouldSetCollectionInfo(semanticsNode)) {
result.setCollectionInfo(
AccessibilityNodeInfo.CollectionInfo.obtain(
0, // rows
semanticsNode.scrollChildren, // columns
false // hierarchical
));
} else {
result.setClassName("android.widget.HorizontalScrollView");
}
} else {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2
&& shouldSetCollectionInfo(semanticsNode)) {
result.setCollectionInfo(
AccessibilityNodeInfo.CollectionInfo.obtain(
semanticsNode.scrollChildren, // rows
0, // columns
false // hierarchical
));
} else {
result.setClassName("android.widget.ScrollView");
}
}
}
// TODO(ianh): Once we're on SDK v23+, call addAction to
// expose AccessibilityAction.ACTION_SCROLL_LEFT, _RIGHT,
// _UP, and _DOWN when appropriate.
if (semanticsNode.hasAction(Action.SCROLL_LEFT)
|| semanticsNode.hasAction(Action.SCROLL_UP)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
if (semanticsNode.hasAction(Action.SCROLL_RIGHT)
|| semanticsNode.hasAction(Action.SCROLL_DOWN)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
}
if (semanticsNode.hasAction(Action.INCREASE) || semanticsNode.hasAction(Action.DECREASE)) {
// TODO(jonahwilliams): support AccessibilityAction.ACTION_SET_PROGRESS once SDK is
// updated.
result.setClassName("android.widget.SeekBar");
if (semanticsNode.hasAction(Action.INCREASE)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
}
if (semanticsNode.hasAction(Action.DECREASE)) {
result.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
}
if (semanticsNode.hasFlag(Flag.IS_LIVE_REGION)
&& Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) {
result.setLiveRegion(View.ACCESSIBILITY_LIVE_REGION_POLITE);
}
// Scopes routes are not focusable, only need to set the content
// for non-scopes-routes semantics nodes.
if (semanticsNode.hasFlag(Flag.IS_TEXT_FIELD)) {
result.setText(semanticsNode.getValue());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
result.setHintText(semanticsNode.getTextFieldHint());
}
} else if (!semanticsNode.hasFlag(Flag.SCOPES_ROUTE)) {
CharSequence content = semanticsNode.getValueLabelHint();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
if (semanticsNode.tooltip != null) {
// For backward compatibility with Flutter SDK before Android API
// level 28, the tooltip is appended at the end of content description.
content = content != null ? content : "";
content = content + "\n" + semanticsNode.tooltip;
}
}
if (content != null) {
result.setContentDescription(content);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (semanticsNode.tooltip != null) {
result.setTooltipText(semanticsNode.tooltip);
}
}
boolean hasCheckedState = semanticsNode.hasFlag(Flag.HAS_CHECKED_STATE);
boolean hasToggledState = semanticsNode.hasFlag(Flag.HAS_TOGGLED_STATE);
if (BuildConfig.DEBUG && (hasCheckedState && hasToggledState)) {
Log.e(TAG, "Expected semanticsNode to have checked state and toggled state.");
}
result.setCheckable(hasCheckedState || hasToggledState);
if (hasCheckedState) {
result.setChecked(semanticsNode.hasFlag(Flag.IS_CHECKED));
if (semanticsNode.hasFlag(Flag.IS_IN_MUTUALLY_EXCLUSIVE_GROUP)) {
result.setClassName("android.widget.RadioButton");
} else {
result.setClassName("android.widget.CheckBox");
}
} else if (hasToggledState) {
result.setChecked(semanticsNode.hasFlag(Flag.IS_TOGGLED));
result.setClassName("android.widget.Switch");
}
result.setSelected(semanticsNode.hasFlag(Flag.IS_SELECTED));
// Heading support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
result.setHeading(semanticsNode.hasFlag(Flag.IS_HEADER));
}
// Accessibility Focus
if (accessibilityFocusedSemanticsNode != null
&& accessibilityFocusedSemanticsNode.id == virtualViewId) {
result.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
} else {
result.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
}
// Actions on the local context menu
if (Build.VERSION.SDK_INT >= 21) {
if (semanticsNode.customAccessibilityActions != null) {
for (CustomAccessibilityAction action : semanticsNode.customAccessibilityActions) {
result.addAction(
new AccessibilityNodeInfo.AccessibilityAction(action.resourceId, action.label));
}
}
}
for (SemanticsNode child : semanticsNode.childrenInTraversalOrder) {
if (child.hasFlag(Flag.IS_HIDDEN)) {
continue;
}
if (child.platformViewId != -1) {
View embeddedView =
platformViewsAccessibilityDelegate.getPlatformViewById(child.platformViewId);
// Add the embedded view as a child of the current accessibility node if it's not
// using a virtual display.
//
// In this case, the view is in the Activity's view hierarchy, so it doesn't need to be
// mirrored.
//
// See the case above for how virtual displays are handled.
if (!platformViewsAccessibilityDelegate.usesVirtualDisplay(child.platformViewId)) {
result.addChild(embeddedView);
continue;
}
}
result.addChild(rootAccessibilityView, child.id);
}
return result;
}
/**
* Get the bounds in screen with root FlutterView's offset.
*
* @param bounds the bounds in FlutterView
* @return the bounds with offset
*/
private Rect getBoundsInScreen(Rect bounds) {
Rect boundsInScreen = new Rect(bounds);
int[] locationOnScreen = new int[2];
rootAccessibilityView.getLocationOnScreen(locationOnScreen);
boundsInScreen.offset(locationOnScreen[0], locationOnScreen[1]);
return boundsInScreen;
}
/**