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