diff --git a/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java b/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java index 7114eef8163..3b7e7c1b600 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/Components/ChatActivityEnterView.java @@ -1841,7 +1841,9 @@ protected int getVirtualViewAt(float x, float y) { if (onceVisible && (recordCircle != null && snapAnimationProgress > .1f) && onceRect.contains(x, y)) { return 4; } - return HOST_ID; + // this view covers the whole bottom of the chat while recording: exploring by + // touch off its buttons has to reach the timer and the rest under it + return INVALID_ID; } @Override @@ -1869,7 +1871,15 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn @Override protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) { - return true; + if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) { + return false; + } + if (id == 2) { + return togglePauseForAccessibility(); + } else if (id == 4) { + return pressViewAt(ControlsView.this, onceRect.centerX(), onceRect.centerY()); + } + return false; } } } @@ -2513,14 +2523,18 @@ protected int getVirtualViewAt(float x, float y) { } } } - return HOST_ID; + if (pauseRect.contains(x, y)) { + return 2; + } + return INVALID_ID; } @Override protected void getVisibleVirtualViews(List list) { if (isSendButtonVisible()) { list.add(1); -// list.add(2); + // the pause button is already reported by the controls drawn over the circle, + // and reporting it here as well would leave two buttons on the same spot list.add(3); } } @@ -2549,9 +2563,64 @@ protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeIn @Override protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) { - return true; + if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) { + return false; + } + if (id == 1) { + // sending is taken from where the record button is pressed + return pressViewAt(audioVideoButtonContainer, audioVideoButtonContainer.getMeasuredWidth() / 2f, audioVideoButtonContainer.getMeasuredHeight() / 2f); + } else if (id == 2) { + return togglePauseForAccessibility(); + } else if (id == 3 && slideText != null) { + return pressViewAt(slideText, slideText.cancelRect.centerX(), slideText.cancelRect.centerY()); + } + return false; + } + } + } + + private boolean togglePauseForAccessibility() { + if (isInVideoMode()) { + if (slideText != null) { + slideText.setEnabled(false); } + delegate.toggleVideoRecordingPause(); + return true; + } + if (!MediaController.getInstance().isRecordingPaused()) { + MessagesController.getGlobalMainSettings().edit().putInt("voicepausehint", 3).apply(); + } + if (recordCircle != null && recordCircle.isSendButtonVisible()) { + calledRecordRunnable = true; } + if (audioTimelineView != null) { + audioTimelineView.setPlaying(false); + } + MediaController.getInstance().toggleRecordingPause(voiceOnce); + delegate.needStartRecordAudio(0); + if (slideText != null) { + slideText.setEnabled(false); + } + return true; + } + + // the recording controls are drawn rather than laid out, so what stands for them to a screen + // reader has to be carried out where they are drawn + private static boolean pressViewAt(View view, float x, float y) { + if (view == null) { + return false; + } + final long now = SystemClock.uptimeMillis(); + final MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0); + final MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0); + try { + view.onTouchEvent(down); + view.onTouchEvent(up); + } finally { + down.recycle(); + up.recycle(); + } + return true; } public ChatActivityEnterView(Activity context, SizeNotifierFrameLayout parent, ChatActivity fragment, final boolean isChat) { @@ -14167,6 +14236,18 @@ public class TimerView extends View { public TimerView(Context context) { super(context); + setFocusable(true); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + } + + // the time is drawn rather than written, so it is reported when a screen reader asks for + // it: telling it as it runs would talk over everything else while recording + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName("android.widget.TextView"); + final long recorded = isRunning ? System.currentTimeMillis() - startTime : stopTime - startTime; + info.setText(LocaleController.formatDuration((int) Math.max(0, recorded / 1000))); } public void start(long milliseconds) { diff --git a/TMessagesProj/src/main/java/org/telegram/ui/Components/RecordedAudioPlayerView.java b/TMessagesProj/src/main/java/org/telegram/ui/Components/RecordedAudioPlayerView.java index f6e42b24639..a82abee5609 100644 --- a/TMessagesProj/src/main/java/org/telegram/ui/Components/RecordedAudioPlayerView.java +++ b/TMessagesProj/src/main/java/org/telegram/ui/Components/RecordedAudioPlayerView.java @@ -14,15 +14,26 @@ import android.graphics.SurfaceTexture; import android.graphics.drawable.Drawable; import android.net.Uri; +import android.graphics.Rect; +import android.os.Bundle; import android.view.MotionEvent; import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.customview.widget.ExploreByTouchHelper; import org.telegram.messenger.AndroidUtilities; +import org.telegram.messenger.LocaleController; +import org.telegram.messenger.R; import org.telegram.ui.ActionBar.Theme; import java.io.File; +import java.util.List; public class RecordedAudioPlayerView extends View { @@ -50,6 +61,133 @@ public RecordedAudioPlayerView(Context context, Theme.ResourcesProvider resource text.setTextSize(dp(12)); text.setTypeface(AndroidUtilities.bold()); text.setOverrideFullWidth(AndroidUtilities.displaySize.x); + + setFocusable(true); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + trimHandlesHelper = new TrimHandlesHelper(this); + ViewCompat.setAccessibilityDelegate(this, trimHandlesHelper); + } + + private final TrimHandlesHelper trimHandlesHelper; + + // exploring by touch asks the view where the handles are, so the question has to reach them + @Override + public boolean dispatchHoverEvent(MotionEvent event) { + return trimHandlesHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event); + } + + // the ends of the recording are moved by dragging two handles that are drawn on the waveform: + // report them where they are drawn and let them be moved a step at a time + private class TrimHandlesHelper extends ExploreByTouchHelper { + + private static final int START_HANDLE = 1; + private static final int END_HANDLE = 2; + + private final Rect bounds = new Rect(); + + public TrimHandlesHelper(@NonNull View host) { + super(host); + } + + @Override + protected int getVirtualViewAt(float x, float y) { + if (leftHandleClickRect.contains(x, y)) { + return START_HANDLE; + } else if (rightHandleClickRect.contains(x, y)) { + return END_HANDLE; + } + return HOST_ID; + } + + @Override + protected void getVisibleVirtualViews(List list) { + // before the waveform is laid out the handles have no place yet, and reporting them + // without one is refused + if (leftHandleClickRect.isEmpty() || rightHandleClickRect.isEmpty()) { + return; + } + list.add(START_HANDLE); + list.add(END_HANDLE); + } + + @Override + protected void onPopulateNodeForVirtualView(int id, @NonNull AccessibilityNodeInfoCompat info) { + final boolean start = id == START_HANDLE; + final RectF handle = start ? leftHandleClickRect : rightHandleClickRect; + if (handle.isEmpty()) { + bounds.set(0, 0, getWidth(), getHeight()); + } else { + bounds.set((int) handle.left, (int) handle.top, (int) handle.right, (int) handle.bottom); + } + info.setBoundsInParent(bounds); + info.setClassName("android.widget.SeekBar"); + info.setText(LocaleController.getString(start ? R.string.AccDescrRecordingTrimStart : R.string.AccDescrRecordingTrimEnd) + + ", " + LocaleController.formatDuration((int) ((start ? getAudioLeftMs() : getAudioRightMs()) / 1000L))); + info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); + } + + @Override + protected boolean onPerformActionForVirtualView(int id, int action, @Nullable Bundle args) { + if (action != AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD && action != AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) { + return false; + } + if (!moveTrimHandle(id == START_HANDLE, action == AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD)) { + return false; + } + invalidateVirtualView(id); + sendEventForVirtualView(id, AccessibilityEvent.TYPE_VIEW_SELECTED); + return true; + } + } + + // a second at a time, kept apart by the same room the handles need when they are dragged + private boolean moveTrimHandle(boolean start, boolean forward) { + if (duration <= 0 || backgroundRect.width() <= dp(22.66f)) { + return false; + } + final float step = 1.0f / duration; + final float room = Math.max(1.0f / duration, (float) dp(30) / (backgroundRect.width() - dp(22.66f))); + if (start) { + final float wanted = left + (forward ? step : -step); + final float moved = clamp(wanted, clamp01(right - room), 0); + if (moved == left) { + return false; + } + left = moved; + } else { + final float wanted = right + (forward ? step : -step); + final float moved = clamp(wanted, 1.0f, clamp01(left + room)); + if (moved == right) { + return false; + } + right = moved; + } + text.setText(AndroidUtilities.formatDuration((int) Math.round(Math.max(1, duration * (right - left))), false), true); + invalidate(); + return true; + } + + // the recorded voice message is drawn as a waveform that plays and can be trimmed, and none + // of it is written anywhere: report what it holds and let it be played from where it is read + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName("android.widget.Button"); + info.setEnabled(true); + info.setClickable(true); + info.setText(LocaleController.formatDuration((int) Math.max(0, getNewDuration()))); + info.addAction(new AccessibilityNodeInfo.AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, + LocaleController.getString(isPlaying() ? R.string.AccActionPause : R.string.AccActionPlay))); + } + + @Override + public boolean performAccessibilityAction(int action, android.os.Bundle arguments) { + if (action == AccessibilityNodeInfo.ACTION_CLICK) { + setPlaying(!isPlaying()); + return true; + } + return super.performAccessibilityAction(action, arguments); } @Override diff --git a/TMessagesProj/src/main/res/values/strings.xml b/TMessagesProj/src/main/res/values/strings.xml index 02b01e23e55..8877ebb8da4 100644 --- a/TMessagesProj/src/main/res/values/strings.xml +++ b/TMessagesProj/src/main/res/values/strings.xml @@ -5038,6 +5038,8 @@ Gallery Take photo Start recording + Trim start + Trim end Stop recording Lock recording Sticker set