diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java index 21d6518785..d8b3585c7e 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -455,6 +455,17 @@ public void setChar(int column, int row, int codePoint, long style) { allocateFullLineIfNecessary(row).setChar(column, codePoint, style); } + /** used to read aloud the character under the cursor in A11Y */ + public Character getChar(int column, int row) { + if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) + throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + if(column < mLines[row].mText.length) + return mLines[row].mText[column]; + else + return null; + } + public long getStyleAt(int externalRow, int column) { return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); } diff --git a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java index cbbccbb4f2..94bb939ecc 100644 --- a/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java +++ b/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -2443,6 +2443,11 @@ public String getSelectedText(int x1, int y1, int x2, int y2) { return mScreen.getSelectedText(x1, y1, x2, y2); } + /** used to read aloud the character under the cursor in A11Y */ + public Character getChar(int x, int y) { + return mScreen.getChar(x, y); + } + /** Get the terminal session's title (null if not set). */ public String getTitle() { return mTitle; diff --git a/terminal-view/src/main/java/com/termux/view/TerminalView.java b/terminal-view/src/main/java/com/termux/view/TerminalView.java index b4254a9832..3412c9d807 100644 --- a/terminal-view/src/main/java/com/termux/view/TerminalView.java +++ b/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -9,6 +9,7 @@ import android.graphics.Canvas; import android.graphics.Typeface; import android.os.Build; +import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.SystemClock; @@ -27,11 +28,14 @@ import android.view.ViewConfiguration; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityNodeInfo; import android.view.autofill.AutofillValue; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.widget.Scroller; +import android.widget.Toast; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -221,6 +225,10 @@ public void onLongPress(MotionEvent event) { mScroller = new Scroller(context); AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); mAccessibilityEnabled = am.isEnabled(); + + // A view is important for accessibility if it fires accessibility events + // and if it is reported to accessibility services that query the screen. + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); } @@ -457,7 +465,158 @@ public void onScreenUpdated(boolean skipScrolling) { mEmulator.clearScrollCounter(); invalidate(); - if (mAccessibilityEnabled) setContentDescription(getText()); + if (mAccessibilityEnabled) { + // fire off events that the content of this control changed, + // so that the accessibility service gets the updated text + sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED); + } + } + + // ultimately called as a result of the code in updateScreen + @Override + public void onPopulateAccessibilityEvent(AccessibilityEvent event) { + super.onPopulateAccessibilityEvent(event); + + // add our (most up to date) text + final CharSequence text = getText(); + if (!TextUtils.isEmpty(text)) { + event.getText().add(text); + } + } + + // called by accessibility service exploring what's available + @Override + public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo node) { + super.onInitializeAccessibilityNodeInfo(node); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + node.setImportantForAccessibility(true); + } + + final CharSequence text = getText(); + node.setText(text); + + // why only if text is non-empty? cargo cult, core TextView does this check, + // and the accessibility guide example also does this check, who am I to argue + if (!TextUtils.isEmpty(text)) { + // all granularities are valid, don't let the accessibility system guess; + // this allows a TalkBack user to navigate by char/word/paragraph within + // the TerminalView text only without accidently breaking out; other navigation + // modes such as default/controls allow you to move to other controls + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); + node.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE); + + // add more selection actions + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_SELECTION); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLEAR_SELECTION); + } + + // behave more like a multiline text view + node.setEditable(true); + node.setMultiLine(true); + node.setScrollable(true); + node.setCanOpenPopup(true); + + // add actions that you can do on this thing + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_LONG_CLICK); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_FOCUS); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COPY); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PASTE); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); + + // Add accessibility actions + + node.addAction(new AccessibilityNodeInfo.AccessibilityAction( + R.id.a11y_speak_cursor_position, + getResources().getString(R.string.a11y_speak_cursor_position_text))); + node.addAction(new AccessibilityNodeInfo.AccessibilityAction( + R.id.a11y_speak_cursor_line, + getResources().getString(R.string.a11y_speak_cursor_line_text))); + // Using a different to the Copy action in the popup, which you can technically + // get to if someone tells you it's there. You can't have the same button label + // do different things in different contexts; hence, different label + node.addAction(new AccessibilityNodeInfo.AccessibilityAction( + R.id.a11y_copy_id, + getResources().getString(R.string.a11y_copy_screen_text))); + node.addAction(new AccessibilityNodeInfo.AccessibilityAction( + R.id.a11y_paste_id, + getResources().getString(R.string.paste_text))); + node.addAction(new AccessibilityNodeInfo.AccessibilityAction( + R.id.a11y_show_termux_menu_id, + getResources().getString(R.string.a11y_termux_menu_text))); + } + + @Override + public boolean performAccessibilityAction(int action, Bundle args) { + // only handle custom actions here, the defaults implemented by super are good enough + if (action == R.id.a11y_show_termux_menu_id) { + showContextMenu(); + return true; + } else if (action == R.id.a11y_paste_id) { + doPaste(); + return true; + } else if (action == R.id.a11y_copy_id) { + // I can't quite figure out how to make TextSelectionHandleView and/or + // TextSelectionCursor accessible; and I can't figure out how to hook up + // with the Accessibility Selection (2 finger 2x tap & hold) either; + // so at least give people the option to copy the screen; the whole + // transcript might be too much, plus there's a share transcript option + // in the More... menu; + // 3 finger double tap works as copy, but editable fields elsewhere tend + // to offer a Copy accessibility option + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = ClipData.newPlainText("screen text", getText()); + clipboard.setPrimaryClip(clip); + Toast toast = Toast.makeText( + getContext(), + getResources().getText(R.string.copied_to_clipboard_text), + Toast.LENGTH_SHORT); + toast.show(); + + return true; + } else if (action == R.id.a11y_speak_cursor_position && mEmulator != null) { + // because TalkBack might omit speaking out whitespace or punctuation, + // get the character under cursor to get a better idea what "column 24" means... + // in conjunction with "speak line", it should give you a good idea where you are + Character charAtCursor = mEmulator.getChar(mEmulator.getCursorCol(), mTopRow + mEmulator.getCursorRow()); + // get the unicode name of the character; The screen reader may be configured to not + // speak out punctuation, and it will probably not say " " + String namedCharAtCursor = charAtCursor != null + ? Character.getName(charAtCursor) + : ""; + // Character.getName() is allowed to return null... + // ...and it's easy to "accidently" your terminal with an unfortunate cat + if (namedCharAtCursor == null) + namedCharAtCursor = "unknown"; + // "line Y / nScreenLines column X / nScreenColumns. unicode_name_of_character" + final String text = getResources().getString(R.string.a11y_line_text) + + " " + + (mEmulator.getCursorRow() + 1) + + " / " + + mEmulator.mRows + + " " + + getResources().getString(R.string.a11y_column_text) + + " " + + (mEmulator.getCursorCol() + 1) + + " / " + + mEmulator.mColumns + + ". " + + namedCharAtCursor; + announceForAccessibility(text); + return true; + } else if (action == R.id.a11y_speak_cursor_line && mEmulator != null) { + CharSequence lineText = mEmulator.getScreen().getSelectedText(0, mTopRow + mEmulator.getCursorRow(), mEmulator.mColumns, mTopRow + mEmulator.getCursorRow()); + announceForAccessibility(lineText); + return true; + } + + return super.performAccessibilityAction(action, args); } /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} @@ -578,15 +737,7 @@ public boolean onTouchEvent(MotionEvent event) { if (action == MotionEvent.ACTION_DOWN) showContextMenu(); return true; } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { - ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clipData = clipboardManager.getPrimaryClip(); - if (clipData != null) { - ClipData.Item clipItem = clipData.getItemAt(0); - if (clipItem != null) { - CharSequence text = clipItem.coerceToText(getContext()); - if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); - } - } + doPaste(); } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. switch (event.getAction()) { case MotionEvent.ACTION_DOWN: @@ -604,6 +755,18 @@ public boolean onTouchEvent(MotionEvent event) { return true; } + private void doPaste() { + ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData != null) { + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem != null) { + CharSequence text = clipItem.coerceToText(getContext()); + if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); + } + } + } + @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) @@ -987,6 +1150,7 @@ public TerminalSession getCurrentSession() { } private CharSequence getText() { + if (mEmulator == null) return ""; return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows); } diff --git a/terminal-view/src/main/res/values/ids.xml b/terminal-view/src/main/res/values/ids.xml new file mode 100644 index 0000000000..88abcd8c61 --- /dev/null +++ b/terminal-view/src/main/res/values/ids.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/terminal-view/src/main/res/values/strings.xml b/terminal-view/src/main/res/values/strings.xml index cd03c618ef..2333efe8cf 100644 --- a/terminal-view/src/main/res/values/strings.xml +++ b/terminal-view/src/main/res/values/strings.xml @@ -2,4 +2,11 @@ Paste Copy More… + Screen copied to clipboard + More… + Copy Screen + Speak Cursor Position + Speak Cursor Line + Line + Column diff --git a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java index 4fbbf1e171..843e07523d 100644 --- a/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java +++ b/termux-shared/src/main/java/com/termux/shared/termux/extrakeys/ExtraKeysView.java @@ -25,6 +25,8 @@ import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; import android.widget.GridLayout; import android.widget.PopupWindow; @@ -208,6 +210,7 @@ public interface IExtraKeysView { protected SpecialButtonsLongHoldRunnable mSpecialButtonsLongHoldRunnable; protected int mLongPressCount; + protected boolean mAccessibilityEnabled; public ExtraKeysView(Context context, AttributeSet attrs) { super(context, attrs); @@ -223,6 +226,9 @@ public ExtraKeysView(Context context, AttributeSet attrs) { setLongPressTimeout(ViewConfiguration.getLongPressTimeout()); setLongPressRepeatDelay(DEFAULT_LONG_PRESS_REPEAT_DELAY); + + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + mAccessibilityEnabled = am.isEnabled(); } @@ -408,6 +414,19 @@ public void reload(ExtraKeysInfo extraKeysInfo, float heightPx) { } else { button = new MaterialButton(getContext(), null, android.R.attr.buttonBarButtonStyle); } + button.setAccessibilityDelegate(new AccessibilityDelegate() { + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(host, info); + // these should funtion like soft keyboard keys; + // with this flag set, they honor the TalkBack setting + // of hold/release to activate, 2x tap to activate or hybrid; + // assuming your Android and TalkBack are recent enough + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + info.setTextEntryKey(true); + } + } + }); button.setText(buttonInfo.getDisplay()); button.setTextColor(mButtonTextColor); @@ -526,11 +545,32 @@ public void onAnyExtraKeyButtonClick(View view, @NonNull ExtraKeyButton buttonIn state.setIsActive(!state.isActive); if (!state.isActive) state.setIsLocked(false); + + announceSpecialKeyStateChangeForAccessibility(buttonInfo.getKey(), state); } else { onExtraKeyButtonClick(view, buttonInfo, button); } } + private void announceSpecialKeyStateChangeForAccessibility(CharSequence buttonName, SpecialButtonState state) { + if(mAccessibilityEnabled) { + CharSequence stateText; + if(!state.isActive) { + stateText = getResources().getText(R.string.a11y_special_key_off); + } else if(state.isLocked) { + stateText = getResources().getText(R.string.a11y_special_key_latched_on); + } else { + stateText = getResources().getText(R.string.a11y_special_key_on); + + } + String announcementText = buttonName + + " " + + stateText + ; + announceForAccessibility(announcementText); + } + } + public void startScheduledExecutors(View view, ExtraKeyButton buttonInfo, MaterialButton button) { stopScheduledExecutors(); @@ -552,7 +592,7 @@ public void startScheduledExecutors(View view, ExtraKeyButton buttonInfo, Materi if (state == null) return; if (mHandler == null) mHandler = new Handler(Looper.getMainLooper()); - mSpecialButtonsLongHoldRunnable = new SpecialButtonsLongHoldRunnable(state); + mSpecialButtonsLongHoldRunnable = new SpecialButtonsLongHoldRunnable(state, buttonInfo); mHandler.postDelayed(mSpecialButtonsLongHoldRunnable, mLongPressTimeout); } } @@ -571,9 +611,11 @@ public void stopScheduledExecutors() { public class SpecialButtonsLongHoldRunnable implements Runnable { public final SpecialButtonState mState; + public final ExtraKeyButton mButtonInfo; - public SpecialButtonsLongHoldRunnable(SpecialButtonState state) { + public SpecialButtonsLongHoldRunnable(SpecialButtonState state, ExtraKeyButton buttonInfo) { mState = state; + mButtonInfo = buttonInfo; } public void run() { @@ -581,6 +623,8 @@ public void run() { mState.setIsLocked(!mState.isActive); mState.setIsActive(!mState.isActive); mLongPressCount++; + + announceSpecialKeyStateChangeForAccessibility(mButtonInfo.getKey(), mState); } } @@ -648,8 +692,10 @@ public Boolean readSpecialButton(SpecialButton specialButton, boolean autoSetInA return false; // Disable active state only if not locked - if (autoSetInActive && !state.isLocked) + if (autoSetInActive && !state.isLocked) { state.setIsActive(false); + announceSpecialKeyStateChangeForAccessibility(specialButton.getKey(), state); + } return true; } diff --git a/termux-shared/src/main/res/values/strings.xml b/termux-shared/src/main/res/values/strings.xml index d585e24f97..b5e4ce4dba 100644 --- a/termux-shared/src/main/res/values/strings.xml +++ b/termux-shared/src/main/res/values/strings.xml @@ -134,5 +134,8 @@ Verbose *Unknown* Logcat log level set to \"%1$s\" + on, locked + off + on