Skip to content

Commit e547bab

Browse files
Make the voice recording controls work with a screen reader
While recording, the controls are drawn on a canvas rather than laid out, and what stands for them to a screen reader is incomplete: the stop button is only found by touch and never by swiping, the recorded time is not reported at all, and activating any of them does nothing, since the handler behind them returns without doing the action. Report the stop button along with send and cancel, carry out what each of them stands for where it is drawn, and report the recorded time when it is asked for, rather than telling it as it runs, which would talk over everything else while recording. The recorded message that is left once recording stops is drawn the same way: report how long it is and let it be played and paused from where it is read, and report the two handles that trim it, which can then be moved a second at a time.
1 parent 9bcf3d2 commit e547bab

3 files changed

Lines changed: 226 additions & 5 deletions

File tree

TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1841,7 +1841,9 @@ protected int getVirtualViewAt(float x, float y) {
18411841
if (onceVisible && (recordCircle != null && snapAnimationProgress > .1f) && onceRect.contains(x, y)) {
18421842
return 4;
18431843
}
1844-
return HOST_ID;
1844+
// this view covers the whole bottom of the chat while recording: exploring by
1845+
// touch off its buttons has to reach the timer and the rest under it
1846+
return INVALID_ID;
18451847
}
18461848

18471849
@Override
@@ -1869,7 +1871,15 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn
18691871

18701872
@Override
18711873
protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) {
1872-
return true;
1874+
if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) {
1875+
return false;
1876+
}
1877+
if (id == 2) {
1878+
return togglePauseForAccessibility();
1879+
} else if (id == 4) {
1880+
return pressViewAt(ControlsView.this, onceRect.centerX(), onceRect.centerY());
1881+
}
1882+
return false;
18731883
}
18741884
}
18751885
}
@@ -2513,14 +2523,18 @@ protected int getVirtualViewAt(float x, float y) {
25132523
}
25142524
}
25152525
}
2516-
return HOST_ID;
2526+
if (pauseRect.contains(x, y)) {
2527+
return 2;
2528+
}
2529+
return INVALID_ID;
25172530
}
25182531

25192532
@Override
25202533
protected void getVisibleVirtualViews(List<Integer> list) {
25212534
if (isSendButtonVisible()) {
25222535
list.add(1);
2523-
// list.add(2);
2536+
// the pause button is already reported by the controls drawn over the circle,
2537+
// and reporting it here as well would leave two buttons on the same spot
25242538
list.add(3);
25252539
}
25262540
}
@@ -2549,9 +2563,64 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn
25492563

25502564
@Override
25512565
protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) {
2552-
return true;
2566+
if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) {
2567+
return false;
2568+
}
2569+
if (id == 1) {
2570+
// sending is taken from where the record button is pressed
2571+
return pressViewAt(audioVideoButtonContainer, audioVideoButtonContainer.getMeasuredWidth() / 2f, audioVideoButtonContainer.getMeasuredHeight() / 2f);
2572+
} else if (id == 2) {
2573+
return togglePauseForAccessibility();
2574+
} else if (id == 3 && slideText != null) {
2575+
return pressViewAt(slideText, slideText.cancelRect.centerX(), slideText.cancelRect.centerY());
2576+
}
2577+
return false;
2578+
}
2579+
}
2580+
}
2581+
2582+
private boolean togglePauseForAccessibility() {
2583+
if (isInVideoMode()) {
2584+
if (slideText != null) {
2585+
slideText.setEnabled(false);
25532586
}
2587+
delegate.toggleVideoRecordingPause();
2588+
return true;
2589+
}
2590+
if (!MediaController.getInstance().isRecordingPaused()) {
2591+
MessagesController.getGlobalMainSettings().edit().putInt("voicepausehint", 3).apply();
2592+
}
2593+
if (recordCircle != null && recordCircle.isSendButtonVisible()) {
2594+
calledRecordRunnable = true;
25542595
}
2596+
if (audioTimelineView != null) {
2597+
audioTimelineView.setPlaying(false);
2598+
}
2599+
MediaController.getInstance().toggleRecordingPause(voiceOnce);
2600+
delegate.needStartRecordAudio(0);
2601+
if (slideText != null) {
2602+
slideText.setEnabled(false);
2603+
}
2604+
return true;
2605+
}
2606+
2607+
// the recording controls are drawn rather than laid out, so what stands for them to a screen
2608+
// reader has to be carried out where they are drawn
2609+
private static boolean pressViewAt(View view, float x, float y) {
2610+
if (view == null) {
2611+
return false;
2612+
}
2613+
final long now = SystemClock.uptimeMillis();
2614+
final MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
2615+
final MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
2616+
try {
2617+
view.onTouchEvent(down);
2618+
view.onTouchEvent(up);
2619+
} finally {
2620+
down.recycle();
2621+
up.recycle();
2622+
}
2623+
return true;
25552624
}
25562625

25572626
public ChatActivityEnterView(Activity context, SizeNotifierFrameLayout parent, ChatActivity fragment, final boolean isChat) {
@@ -14167,6 +14236,18 @@ public class TimerView extends View {
1416714236

1416814237
public TimerView(Context context) {
1416914238
super(context);
14239+
setFocusable(true);
14240+
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
14241+
}
14242+
14243+
// the time is drawn rather than written, so it is reported when a screen reader asks for
14244+
// it: telling it as it runs would talk over everything else while recording
14245+
@Override
14246+
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
14247+
super.onInitializeAccessibilityNodeInfo(info);
14248+
info.setClassName("android.widget.TextView");
14249+
final long recorded = isRunning ? System.currentTimeMillis() - startTime : stopTime - startTime;
14250+
info.setText(LocaleController.formatDuration((int) Math.max(0, recorded / 1000)));
1417014251
}
1417114252

1417214253
public void start(long milliseconds) {

TMessagesProj/src/main/java/org/telegram/ui/Components/RecordedAudioPlayerView.java

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,26 @@
1414
import android.graphics.SurfaceTexture;
1515
import android.graphics.drawable.Drawable;
1616
import android.net.Uri;
17+
import android.graphics.Rect;
18+
import android.os.Bundle;
1719
import android.view.MotionEvent;
1820
import android.view.View;
21+
import android.view.accessibility.AccessibilityEvent;
22+
import android.view.accessibility.AccessibilityNodeInfo;
1923

2024
import androidx.annotation.NonNull;
25+
import androidx.annotation.Nullable;
26+
import androidx.core.view.ViewCompat;
27+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
28+
import androidx.customview.widget.ExploreByTouchHelper;
2129

2230
import org.telegram.messenger.AndroidUtilities;
31+
import org.telegram.messenger.LocaleController;
32+
import org.telegram.messenger.R;
2333
import org.telegram.ui.ActionBar.Theme;
2434

2535
import java.io.File;
36+
import java.util.List;
2637

2738
public class RecordedAudioPlayerView extends View {
2839

@@ -50,6 +61,133 @@ public RecordedAudioPlayerView(Context context, Theme.ResourcesProvider resource
5061
text.setTextSize(dp(12));
5162
text.setTypeface(AndroidUtilities.bold());
5263
text.setOverrideFullWidth(AndroidUtilities.displaySize.x);
64+
65+
setFocusable(true);
66+
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
67+
trimHandlesHelper = new TrimHandlesHelper(this);
68+
ViewCompat.setAccessibilityDelegate(this, trimHandlesHelper);
69+
}
70+
71+
private final TrimHandlesHelper trimHandlesHelper;
72+
73+
// exploring by touch asks the view where the handles are, so the question has to reach them
74+
@Override
75+
public boolean dispatchHoverEvent(MotionEvent event) {
76+
return trimHandlesHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
77+
}
78+
79+
// the ends of the recording are moved by dragging two handles that are drawn on the waveform:
80+
// report them where they are drawn and let them be moved a step at a time
81+
private class TrimHandlesHelper extends ExploreByTouchHelper {
82+
83+
private static final int START_HANDLE = 1;
84+
private static final int END_HANDLE = 2;
85+
86+
private final Rect bounds = new Rect();
87+
88+
public TrimHandlesHelper(@NonNull View host) {
89+
super(host);
90+
}
91+
92+
@Override
93+
protected int getVirtualViewAt(float x, float y) {
94+
if (leftHandleClickRect.contains(x, y)) {
95+
return START_HANDLE;
96+
} else if (rightHandleClickRect.contains(x, y)) {
97+
return END_HANDLE;
98+
}
99+
return HOST_ID;
100+
}
101+
102+
@Override
103+
protected void getVisibleVirtualViews(List<Integer> list) {
104+
// before the waveform is laid out the handles have no place yet, and reporting them
105+
// without one is refused
106+
if (leftHandleClickRect.isEmpty() || rightHandleClickRect.isEmpty()) {
107+
return;
108+
}
109+
list.add(START_HANDLE);
110+
list.add(END_HANDLE);
111+
}
112+
113+
@Override
114+
protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeInfoCompat info) {
115+
final boolean start = id == START_HANDLE;
116+
final RectF handle = start ? leftHandleClickRect : rightHandleClickRect;
117+
if (handle.isEmpty()) {
118+
bounds.set(0, 0, getWidth(), getHeight());
119+
} else {
120+
bounds.set((int) handle.left, (int) handle.top, (int) handle.right, (int) handle.bottom);
121+
}
122+
info.setBoundsInParent(bounds);
123+
info.setClassName("android.widget.SeekBar");
124+
info.setText(LocaleController.getString(start ? R.string.AccDescrRecordingTrimStart : R.string.AccDescrRecordingTrimEnd)
125+
+ ", " + LocaleController.formatDuration((int) ((start ? getAudioLeftMs() : getAudioRightMs()) / 1000L)));
126+
info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
127+
info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
128+
}
129+
130+
@Override
131+
protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) {
132+
if (action != AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD && action != AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) {
133+
return false;
134+
}
135+
if (!moveTrimHandle(id == START_HANDLE, action == AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD)) {
136+
return false;
137+
}
138+
invalidateVirtualView(id);
139+
sendEventForVirtualView(id, AccessibilityEvent.TYPE_VIEW_SELECTED);
140+
return true;
141+
}
142+
}
143+
144+
// a second at a time, kept apart by the same room the handles need when they are dragged
145+
private boolean moveTrimHandle(boolean start, boolean forward) {
146+
if (duration <= 0 || backgroundRect.width() <= dp(22.66f)) {
147+
return false;
148+
}
149+
final float step = 1.0f / duration;
150+
final float room = Math.max(1.0f / duration, (float) dp(30) / (backgroundRect.width() - dp(22.66f)));
151+
if (start) {
152+
final float wanted = left + (forward ? step : -step);
153+
final float moved = clamp(wanted, clamp01(right - room), 0);
154+
if (moved == left) {
155+
return false;
156+
}
157+
left = moved;
158+
} else {
159+
final float wanted = right + (forward ? step : -step);
160+
final float moved = clamp(wanted, 1.0f, clamp01(left + room));
161+
if (moved == right) {
162+
return false;
163+
}
164+
right = moved;
165+
}
166+
text.setText(AndroidUtilities.formatDuration((int) Math.round(Math.max(1, duration * (right - left))), false), true);
167+
invalidate();
168+
return true;
169+
}
170+
171+
// the recorded voice message is drawn as a waveform that plays and can be trimmed, and none
172+
// of it is written anywhere: report what it holds and let it be played from where it is read
173+
@Override
174+
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
175+
super.onInitializeAccessibilityNodeInfo(info);
176+
info.setClassName("android.widget.Button");
177+
info.setEnabled(true);
178+
info.setClickable(true);
179+
info.setText(LocaleController.formatDuration((int) Math.max(0, getNewDuration())));
180+
info.addAction(new AccessibilityNodeInfo.AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK,
181+
LocaleController.getString(isPlaying() ? R.string.AccActionPause : R.string.AccActionPlay)));
182+
}
183+
184+
@Override
185+
public boolean performAccessibilityAction(int action, android.os.Bundle arguments) {
186+
if (action == AccessibilityNodeInfo.ACTION_CLICK) {
187+
setPlaying(!isPlaying());
188+
return true;
189+
}
190+
return super.performAccessibilityAction(action, arguments);
53191
}
54192

55193
@Override

TMessagesProj/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5031,6 +5031,8 @@
50315031
<string name="AccDescrCameraGallery">Gallery</string>
50325032
<string name="AccDescrTakePhoto">Take photo</string>
50335033
<string name="AccDescrStartRecording">Start recording</string>
5034+
<string name="AccDescrRecordingTrimStart">Trim start</string>
5035+
<string name="AccDescrRecordingTrimEnd">Trim end</string>
50345036
<string name="AccDescrStopRecording">Stop recording</string>
50355037
<string name="AccDescrLockRecording">Lock recording</string>
50365038
<string name="AccDescrStickerSet">Sticker set</string>

0 commit comments

Comments
 (0)