Skip to content

Commit f560237

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 3f03bfc commit f560237

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
@@ -1849,7 +1849,9 @@ protected int getVirtualViewAt(float x, float y) {
18491849
if (onceVisible && (recordCircle != null && snapAnimationProgress > .1f) && onceRect.contains(x, y)) {
18501850
return 4;
18511851
}
1852-
return HOST_ID;
1852+
// this view covers the whole bottom of the chat while recording: exploring by
1853+
// touch off its buttons has to reach the timer and the rest under it
1854+
return INVALID_ID;
18531855
}
18541856

18551857
@Override
@@ -1877,7 +1879,15 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn
18771879

18781880
@Override
18791881
protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) {
1880-
return true;
1882+
if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) {
1883+
return false;
1884+
}
1885+
if (id == 2) {
1886+
return togglePauseForAccessibility();
1887+
} else if (id == 4) {
1888+
return pressViewAt(ControlsView.this, onceRect.centerX(), onceRect.centerY());
1889+
}
1890+
return false;
18811891
}
18821892
}
18831893
}
@@ -2521,14 +2531,18 @@ protected int getVirtualViewAt(float x, float y) {
25212531
}
25222532
}
25232533
}
2524-
return HOST_ID;
2534+
if (pauseRect.contains(x, y)) {
2535+
return 2;
2536+
}
2537+
return INVALID_ID;
25252538
}
25262539

25272540
@Override
25282541
protected void getVisibleVirtualViews(List<Integer> list) {
25292542
if (isSendButtonVisible()) {
25302543
list.add(1);
2531-
// list.add(2);
2544+
// the pause button is already reported by the controls drawn over the circle,
2545+
// and reporting it here as well would leave two buttons on the same spot
25322546
list.add(3);
25332547
}
25342548
}
@@ -2557,9 +2571,64 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn
25572571

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

25652634
public ChatActivityEnterView(Activity context, SizeNotifierFrameLayout parent, ChatActivity fragment, final boolean isChat) {
@@ -14221,6 +14290,18 @@ public class TimerView extends View {
1422114290

1422214291
public TimerView(Context context) {
1422314292
super(context);
14293+
setFocusable(true);
14294+
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
14295+
}
14296+
14297+
// the time is drawn rather than written, so it is reported when a screen reader asks for
14298+
// it: telling it as it runs would talk over everything else while recording
14299+
@Override
14300+
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
14301+
super.onInitializeAccessibilityNodeInfo(info);
14302+
info.setClassName("android.widget.TextView");
14303+
final long recorded = isRunning ? System.currentTimeMillis() - startTime : stopTime - startTime;
14304+
info.setText(LocaleController.formatDuration((int) Math.max(0, recorded / 1000)));
1422414305
}
1422514306

1422614307
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
@@ -5059,6 +5059,8 @@
50595059
<string name="AccDescrCameraGallery">Gallery</string>
50605060
<string name="AccDescrTakePhoto">Take photo</string>
50615061
<string name="AccDescrStartRecording">Start recording</string>
5062+
<string name="AccDescrRecordingTrimStart">Trim start</string>
5063+
<string name="AccDescrRecordingTrimEnd">Trim end</string>
50625064
<string name="AccDescrStopRecording">Stop recording</string>
50635065
<string name="AccDescrLockRecording">Lock recording</string>
50645066
<string name="AccDescrStickerSet">Sticker set</string>

0 commit comments

Comments
 (0)