WHC Shell: keyboard-first Android terminal over SSH to local Termux
Vendored Termux terminal-emulator/renderer (Apache 2.0) with kitty keyboard protocol, modifyOtherKeys and any-event mouse tracking; sshj client with Ed25519 key login and TOFU host keys; Termux font/colors/cursor sync; mouse selection, context menu, zoom and fullscreen shortcuts.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.graphics.Typeface;
|
||||
import android.util.Log;
|
||||
|
||||
import com.termux.terminal.TerminalEmulator;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Look and feel copied from Termux's ~/.termux (font.ttf, colors.properties, termux.properties),
|
||||
* so the terminal looks the same as in the Termux app.
|
||||
*/
|
||||
final class Appearance {
|
||||
|
||||
/** Files mirrored from the remote ~/.termux directory. */
|
||||
static final String[] FILES = {"font.ttf", "colors.properties", "termux.properties"};
|
||||
|
||||
final Typeface typeface;
|
||||
/** Null when Termux has no colors.properties. */
|
||||
final Properties colors;
|
||||
final int cursorStyle;
|
||||
/** Cursor blink period in milliseconds, 0 for a steady cursor. */
|
||||
final int blinkMs;
|
||||
final float marginHorizontalDp;
|
||||
final float marginVerticalDp;
|
||||
|
||||
private Appearance(Typeface typeface, Properties colors, Properties props) {
|
||||
this.typeface = typeface;
|
||||
this.colors = colors;
|
||||
switch (props.getProperty("terminal-cursor-style", "block").trim()) {
|
||||
case "underline":
|
||||
cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE;
|
||||
break;
|
||||
case "bar":
|
||||
cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR;
|
||||
break;
|
||||
default:
|
||||
cursorStyle = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK;
|
||||
}
|
||||
int blink = intProp(props, "terminal-cursor-blink-rate", 0);
|
||||
blinkMs = blink < 100 ? 0 : Math.min(blink, 2000);
|
||||
marginHorizontalDp = Math.max(0, Math.min(100, intProp(props, "terminal-margin-horizontal", 3)));
|
||||
marginVerticalDp = Math.max(0, Math.min(100, intProp(props, "terminal-margin-vertical", 0)));
|
||||
}
|
||||
|
||||
static File dir(File filesDir) {
|
||||
return new File(filesDir, "termux");
|
||||
}
|
||||
|
||||
static Appearance load(File filesDir) {
|
||||
File d = dir(filesDir);
|
||||
Typeface tf = Typeface.MONOSPACE;
|
||||
File font = new File(d, "font.ttf");
|
||||
if (font.isFile()) {
|
||||
try {
|
||||
tf = Typeface.createFromFile(font);
|
||||
} catch (RuntimeException e) {
|
||||
Log.w("whc-shell", "bad font.ttf", e);
|
||||
}
|
||||
}
|
||||
Properties colors = read(new File(d, "colors.properties"));
|
||||
Properties props = read(new File(d, "termux.properties"));
|
||||
return new Appearance(tf, colors, props == null ? new Properties() : props);
|
||||
}
|
||||
|
||||
private static Properties read(File f) {
|
||||
if (!f.isFile()) return null;
|
||||
Properties p = new Properties();
|
||||
try (Reader r = new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8)) {
|
||||
p.load(r);
|
||||
return p;
|
||||
} catch (Exception e) {
|
||||
Log.w("whc-shell", "cannot read " + f, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int intProp(Properties p, String key, int def) {
|
||||
try {
|
||||
return Integer.parseInt(p.getProperty(key, "").trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
import com.termux.terminal.KeyHandler;
|
||||
|
||||
/**
|
||||
* Turns a key press into the bytes a terminal application expects.
|
||||
*
|
||||
* Function keys use xterm's encoding (from {@link KeyHandler}). Text keys with modifiers follow,
|
||||
* in order of preference, the kitty keyboard protocol (flag 1, "disambiguate"), xterm's
|
||||
* modifyOtherKeys, or the legacy control-character / ESC-prefix encoding.
|
||||
*/
|
||||
final class KeyEncoder {
|
||||
|
||||
static final int SHIFT = 1;
|
||||
static final int ALT = 2;
|
||||
static final int CTRL = 4;
|
||||
|
||||
/** Terminal state that affects key encoding. */
|
||||
static final class Modes {
|
||||
boolean cursorApp;
|
||||
boolean keypadApp;
|
||||
int kittyFlags;
|
||||
int modifyOtherKeys;
|
||||
}
|
||||
|
||||
private KeyEncoder() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param keyCode Android key code
|
||||
* @param base the code point of the key without any modifier (e.g. 'a' for Shift+A), or 0
|
||||
* @param text the code point the key types with Shift applied (e.g. 'A'), or 0
|
||||
* @param mods {@link #SHIFT} | {@link #ALT} | {@link #CTRL}
|
||||
* @param numLock whether Num Lock is on
|
||||
* @return the bytes to send as a string, or null if the key produces nothing
|
||||
*/
|
||||
static String encode(int keyCode, int base, int text, int mods, boolean numLock, Modes m) {
|
||||
boolean kitty = (m.kittyFlags & 1) != 0;
|
||||
int param = 1 + mods;
|
||||
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_ESCAPE:
|
||||
if (kitty) return mods == 0 ? "\033[27u" : "\033[27;" + param + "u";
|
||||
return (mods & ALT) != 0 ? "\033\033" : "\033";
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ENTER:
|
||||
if (keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER && m.keypadApp) break;
|
||||
return special(13, "\r", mods, m);
|
||||
case KeyEvent.KEYCODE_TAB:
|
||||
if (!kitty && m.modifyOtherKeys < 2 && (mods & ~SHIFT) == 0)
|
||||
return mods == 0 ? "\t" : "\033[Z";
|
||||
return special(9, "\t", mods, m);
|
||||
case KeyEvent.KEYCODE_DEL:
|
||||
return special(127, "\u007f", mods, m);
|
||||
case KeyEvent.KEYCODE_PAGE_UP:
|
||||
return mods == 0 ? "\033[5~" : "\033[5;" + param + "~";
|
||||
case KeyEvent.KEYCODE_PAGE_DOWN:
|
||||
return mods == 0 ? "\033[6~" : "\033[6;" + param + "~";
|
||||
case KeyEvent.KEYCODE_SPACE:
|
||||
return textKey(' ', ' ', mods, m);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
int keyMode = 0;
|
||||
if ((mods & SHIFT) != 0) keyMode |= KeyHandler.KEYMOD_SHIFT;
|
||||
if ((mods & ALT) != 0) keyMode |= KeyHandler.KEYMOD_ALT;
|
||||
if ((mods & CTRL) != 0) keyMode |= KeyHandler.KEYMOD_CTRL;
|
||||
if (numLock) keyMode |= KeyHandler.KEYMOD_NUM_LOCK;
|
||||
String code = KeyHandler.getCode(keyCode, keyMode, m.cursorApp, m.keypadApp);
|
||||
if (code != null) return code;
|
||||
|
||||
if (text == 0) return null;
|
||||
return textKey(base == 0 ? text : base, text, mods, m);
|
||||
}
|
||||
|
||||
/** Enter, Tab and Backspace: legacy bytes unless a modifier makes them ambiguous. */
|
||||
private static String special(int cp, String plain, int mods, Modes m) {
|
||||
if (mods == 0) return plain;
|
||||
if ((m.kittyFlags & 1) != 0) return "\033[" + cp + ";" + (1 + mods) + "u";
|
||||
if (m.modifyOtherKeys >= 2 || (m.modifyOtherKeys == 1 && cp == 13))
|
||||
return "\033[27;" + (1 + mods) + ";" + cp + "~";
|
||||
if (cp == 127 && (mods & CTRL) != 0) plain = "\b";
|
||||
return (mods & ALT) != 0 ? "\033" + plain : plain;
|
||||
}
|
||||
|
||||
private static String textKey(int base, int text, int mods, Modes m) {
|
||||
boolean ctrl = (mods & CTRL) != 0, alt = (mods & ALT) != 0;
|
||||
if (!ctrl && !alt) return new String(Character.toChars(text));
|
||||
|
||||
if ((m.kittyFlags & 1) != 0) {
|
||||
if (base >= 'A' && base <= 'Z') base += 'a' - 'A';
|
||||
return "\033[" + base + ";" + (1 + mods) + "u";
|
||||
}
|
||||
|
||||
int legacy = ctrl ? ctrlChar(text) : text;
|
||||
boolean hasLegacy = !ctrl || legacy != text;
|
||||
// Ctrl+Shift+letter would collapse onto Ctrl+letter.
|
||||
if (ctrl && (mods & SHIFT) != 0 && Character.isLetter(text)) hasLegacy = false;
|
||||
if (m.modifyOtherKeys >= 2 || (m.modifyOtherKeys == 1 && !hasLegacy))
|
||||
return "\033[27;" + (1 + mods) + ";" + text + "~";
|
||||
|
||||
StringBuilder sb = new StringBuilder(4);
|
||||
if (alt) sb.append('\033');
|
||||
sb.appendCodePoint(legacy);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** The control character xterm sends for Ctrl plus {@code cp}, or {@code cp} if there is none. */
|
||||
static int ctrlChar(int cp) {
|
||||
if (cp >= 'a' && cp <= 'z') return cp - 'a' + 1;
|
||||
if (cp >= 'A' && cp <= 'Z') return cp - 'A' + 1;
|
||||
switch (cp) {
|
||||
case ' ':
|
||||
case '2':
|
||||
case '@':
|
||||
return 0;
|
||||
case '3':
|
||||
case '[':
|
||||
return 27;
|
||||
case '4':
|
||||
case '\\':
|
||||
return 28;
|
||||
case '5':
|
||||
case ']':
|
||||
return 29;
|
||||
case '6':
|
||||
case '^':
|
||||
return 30;
|
||||
case '7':
|
||||
case '-':
|
||||
case '_':
|
||||
case '/':
|
||||
return 31;
|
||||
case '8':
|
||||
case '?':
|
||||
return 127;
|
||||
default:
|
||||
return cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Insets;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowInsetsController;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.termux.terminal.TerminalColors;
|
||||
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class MainActivity extends Activity implements TermSession.Host, TermView.Actions {
|
||||
|
||||
private static final float DEFAULT_SP = 14f;
|
||||
private static final float MIN_SP = 6f;
|
||||
private static final float MAX_SP = 40f;
|
||||
private static final String RUN_COMMAND_PERMISSION = "com.termux.permission.RUN_COMMAND";
|
||||
private static final int REQ_RUN_COMMAND = 1;
|
||||
|
||||
private TermSession session;
|
||||
private TermView view;
|
||||
private SharedPreferences prefs;
|
||||
private float textSp;
|
||||
private boolean fullscreen;
|
||||
private Appearance appearance;
|
||||
private String primary;
|
||||
private Consumer<Boolean> pendingSshdReply;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
prefs = getSharedPreferences("ui", MODE_PRIVATE);
|
||||
textSp = prefs.getFloat("text_sp", DEFAULT_SP);
|
||||
fullscreen = prefs.getBoolean("fullscreen", false);
|
||||
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
view = new TermView(this);
|
||||
root.addView(view, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
root.setOnApplyWindowInsetsListener((v, insets) -> {
|
||||
Insets i = insets.getInsets(WindowInsets.Type.systemBars()
|
||||
| WindowInsets.Type.ime() | WindowInsets.Type.displayCutout());
|
||||
v.setPadding(i.left, i.top, i.right, i.bottom);
|
||||
return WindowInsets.CONSUMED;
|
||||
});
|
||||
} else {
|
||||
root.setFitsSystemWindows(true);
|
||||
}
|
||||
setContentView(root);
|
||||
|
||||
session = ((WhcApp) getApplication()).session();
|
||||
session.setHost(this);
|
||||
applyAppearance();
|
||||
view.attach(session, this);
|
||||
applyFullscreen();
|
||||
view.requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("deprecation")
|
||||
public void onBackPressed() {
|
||||
moveTaskToBack(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (session != null) session.setHost(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private float dp(float v) {
|
||||
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics());
|
||||
}
|
||||
|
||||
private int textPx() {
|
||||
return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, textSp,
|
||||
getResources().getDisplayMetrics()));
|
||||
}
|
||||
|
||||
private void applyAppearance() {
|
||||
appearance = Appearance.load(getFilesDir());
|
||||
Properties colors = appearance.colors == null ? new Properties() : appearance.colors;
|
||||
try {
|
||||
TerminalColors.COLOR_SCHEME.updateWith(colors);
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.w("whc-shell", "bad colors.properties", e);
|
||||
TerminalColors.COLOR_SCHEME.updateWith(new Properties());
|
||||
}
|
||||
session.resetColors();
|
||||
session.setCursorStyle(appearance.cursorStyle);
|
||||
int h = Math.round(dp(appearance.marginHorizontalDp));
|
||||
int v = Math.round(dp(appearance.marginVerticalDp));
|
||||
view.setPadding(h, v, h, v);
|
||||
view.setFont(appearance.typeface, textPx());
|
||||
view.setBlink(appearance.blinkMs);
|
||||
}
|
||||
|
||||
private void applyFullscreen() {
|
||||
if (Build.VERSION.SDK_INT < 30) return;
|
||||
WindowInsetsController c = getWindow().getInsetsController();
|
||||
if (c == null) return;
|
||||
if (fullscreen) {
|
||||
c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
c.hide(WindowInsets.Type.systemBars());
|
||||
} else {
|
||||
c.show(WindowInsets.Type.systemBars());
|
||||
}
|
||||
}
|
||||
|
||||
private void setClipboard(String text) {
|
||||
ClipboardManager cm = getSystemService(ClipboardManager.class);
|
||||
if (cm != null) cm.setPrimaryClip(ClipData.newPlainText("terminal", text));
|
||||
}
|
||||
|
||||
private void pasteText(String text) {
|
||||
if (text != null && !text.isEmpty() && session.emulator() != null && session.isConnected()) {
|
||||
view.clearSelection();
|
||||
session.emulator().paste(text);
|
||||
}
|
||||
}
|
||||
|
||||
// TermView.Actions
|
||||
|
||||
@Override
|
||||
public void paste() {
|
||||
ClipboardManager cm = getSystemService(ClipboardManager.class);
|
||||
ClipData clip = cm == null ? null : cm.getPrimaryClip();
|
||||
if (clip == null || clip.getItemCount() == 0) return;
|
||||
CharSequence text = clip.getItemAt(0).coerceToText(this);
|
||||
if (text != null) pasteText(text.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pastePrimary() {
|
||||
if (primary != null) pasteText(primary);
|
||||
else paste();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void copy(String text) {
|
||||
setClipboard(text);
|
||||
view.clearSelection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPrimary(String text) {
|
||||
primary = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void zoom(int delta) {
|
||||
textSp = delta == 0 ? DEFAULT_SP : Math.max(MIN_SP, Math.min(MAX_SP, textSp + delta));
|
||||
prefs.edit().putFloat("text_sp", textSp).apply();
|
||||
view.setTextSizePx(textPx());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toggleFullscreen() {
|
||||
fullscreen = !fullscreen;
|
||||
prefs.edit().putBoolean("fullscreen", fullscreen).apply();
|
||||
applyFullscreen();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void buildMenu(ContextMenu menu, String selection) {
|
||||
if (selection != null)
|
||||
menu.add("复制 Ctrl+Shift+C").setOnMenuItemClickListener(i -> {
|
||||
copy(selection);
|
||||
return true;
|
||||
});
|
||||
menu.add("粘贴 Ctrl+Shift+V").setOnMenuItemClickListener(i -> {
|
||||
paste();
|
||||
return true;
|
||||
});
|
||||
menu.add("全选 Ctrl+Shift+A").setOnMenuItemClickListener(i -> {
|
||||
view.selectAll();
|
||||
return true;
|
||||
});
|
||||
menu.add("放大 Ctrl+Shift+=").setOnMenuItemClickListener(i -> {
|
||||
zoom(1);
|
||||
return true;
|
||||
});
|
||||
menu.add("缩小 Ctrl+Shift+-").setOnMenuItemClickListener(i -> {
|
||||
zoom(-1);
|
||||
return true;
|
||||
});
|
||||
menu.add((fullscreen ? "退出全屏" : "全屏") + " F11").setOnMenuItemClickListener(i -> {
|
||||
toggleFullscreen();
|
||||
return true;
|
||||
});
|
||||
if (!session.isConnected() && !session.isConnecting())
|
||||
menu.add("重新连接").setOnMenuItemClickListener(i -> {
|
||||
session.connect();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// TermSession.Host
|
||||
|
||||
@Override
|
||||
public void onScreenUpdated() {
|
||||
view.onScreenUpdated();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTitleChanged(String title) {
|
||||
setTitle(title == null || title.isEmpty() ? getString(R.string.app_name) : title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopy(String text) {
|
||||
setClipboard(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPasteRequest() {
|
||||
paste();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBell() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAppearanceSynced() {
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startSshd(Consumer<Boolean> reply) {
|
||||
try {
|
||||
getPackageManager().getPackageInfo("com.termux", 0);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
reply.accept(false);
|
||||
return;
|
||||
}
|
||||
if (checkSelfPermission(RUN_COMMAND_PERMISSION) == PackageManager.PERMISSION_GRANTED) {
|
||||
reply.accept(sendRunSshd());
|
||||
return;
|
||||
}
|
||||
if (pendingSshdReply != null) pendingSshdReply.accept(false);
|
||||
pendingSshdReply = reply;
|
||||
requestPermissions(new String[]{RUN_COMMAND_PERMISSION}, REQ_RUN_COMMAND);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int code, String[] permissions, int[] results) {
|
||||
super.onRequestPermissionsResult(code, permissions, results);
|
||||
if (code != REQ_RUN_COMMAND || pendingSshdReply == null) return;
|
||||
Consumer<Boolean> reply = pendingSshdReply;
|
||||
pendingSshdReply = null;
|
||||
boolean granted = results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED;
|
||||
if (!granted) Toast.makeText(this, "未授权“在 Termux 中运行命令”,请手动在 Termux 里运行 sshd",
|
||||
Toast.LENGTH_LONG).show();
|
||||
reply.accept(granted && sendRunSshd());
|
||||
}
|
||||
|
||||
private boolean sendRunSshd() {
|
||||
Intent i = new Intent("com.termux.RUN_COMMAND");
|
||||
i.setClassName("com.termux", "com.termux.app.RunCommandService");
|
||||
i.putExtra("com.termux.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/sshd");
|
||||
i.putExtra("com.termux.RUN_COMMAND_BACKGROUND", true);
|
||||
try {
|
||||
startService(i);
|
||||
return true;
|
||||
} catch (RuntimeException e) {
|
||||
Log.w("whc-shell", "RUN_COMMAND failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void askPassword(String message, Consumer<String> reply) {
|
||||
int pad = Math.round(dp(20));
|
||||
LinearLayout box = new LinearLayout(this);
|
||||
box.setOrientation(LinearLayout.VERTICAL);
|
||||
box.setPadding(pad, pad / 2, pad, 0);
|
||||
TextView msg = new TextView(this);
|
||||
msg.setText(message);
|
||||
EditText pw = new EditText(this);
|
||||
pw.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
pw.setHint("Termux 密码");
|
||||
box.addView(msg);
|
||||
box.addView(pw);
|
||||
|
||||
AtomicBoolean answered = new AtomicBoolean();
|
||||
AlertDialog d = new AlertDialog.Builder(this)
|
||||
.setTitle("登录 Termux")
|
||||
.setView(box)
|
||||
.setPositiveButton("登录", (dlg, w) -> {
|
||||
if (answered.compareAndSet(false, true)) reply.accept(pw.getText().toString());
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.setOnDismissListener(dlg -> {
|
||||
if (answered.compareAndSet(false, true)) reply.accept(null);
|
||||
view.requestFocus();
|
||||
})
|
||||
.create();
|
||||
pw.setOnEditorActionListener((tv, action, ev) -> {
|
||||
if (answered.compareAndSet(false, true)) reply.accept(pw.getText().toString());
|
||||
d.dismiss();
|
||||
return true;
|
||||
});
|
||||
d.show();
|
||||
pw.requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void confirm(String message, Consumer<Boolean> reply) {
|
||||
AtomicBoolean answered = new AtomicBoolean();
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage(message)
|
||||
.setPositiveButton("信任", (dlg, w) -> {
|
||||
if (answered.compareAndSet(false, true)) reply.accept(true);
|
||||
})
|
||||
.setNegativeButton("拒绝", null)
|
||||
.setOnDismissListener(dlg -> {
|
||||
if (answered.compareAndSet(false, true)) reply.accept(false);
|
||||
view.requestFocus();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator;
|
||||
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters;
|
||||
import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil;
|
||||
import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/** The client's Ed25519 identity, stored in OpenSSH format in a private directory. */
|
||||
final class SshKeys {
|
||||
|
||||
final File privateKey;
|
||||
final File publicKey;
|
||||
|
||||
private SshKeys(File dir) {
|
||||
privateKey = new File(dir, "id_ed25519");
|
||||
publicKey = new File(dir, "id_ed25519.pub");
|
||||
}
|
||||
|
||||
/** Loads the key pair from {@code dir}, generating it first if missing. */
|
||||
static SshKeys ensure(File dir, String comment) throws IOException {
|
||||
SshKeys k = new SshKeys(dir);
|
||||
if (k.privateKey.isFile() && k.publicKey.isFile()) return k;
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("cannot create " + dir);
|
||||
|
||||
Ed25519KeyPairGenerator gen = new Ed25519KeyPairGenerator();
|
||||
gen.init(new Ed25519KeyGenerationParameters(new SecureRandom()));
|
||||
AsymmetricCipherKeyPair pair = gen.generateKeyPair();
|
||||
|
||||
Base64.Encoder b64 = Base64.getEncoder();
|
||||
String priv = "-----BEGIN OPENSSH PRIVATE KEY-----\n"
|
||||
+ Base64.getMimeEncoder(70, new byte[]{'\n'}).encodeToString(
|
||||
OpenSSHPrivateKeyUtil.encodePrivateKey(pair.getPrivate()))
|
||||
+ "\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
String pub = "ssh-ed25519 "
|
||||
+ b64.encodeToString(OpenSSHPublicKeyUtil.encodePublicKey(pair.getPublic()))
|
||||
+ " " + comment.replaceAll("[^A-Za-z0-9._@-]", "_") + "\n";
|
||||
|
||||
write(k.privateKey, priv);
|
||||
write(k.publicKey, pub);
|
||||
return k;
|
||||
}
|
||||
|
||||
/** The single authorized_keys line, without a trailing newline. */
|
||||
String publicLine() throws IOException {
|
||||
return new String(Files.readAllBytes(publicKey.toPath()), StandardCharsets.UTF_8).trim();
|
||||
}
|
||||
|
||||
private static void write(File f, String s) throws IOException {
|
||||
File tmp = new File(f.getPath() + ".tmp");
|
||||
try (FileOutputStream out = new FileOutputStream(tmp)) {
|
||||
out.write(s.getBytes(StandardCharsets.UTF_8));
|
||||
out.getFD().sync();
|
||||
}
|
||||
tmp.setReadable(false, false);
|
||||
tmp.setReadable(true, true);
|
||||
tmp.setWritable(false, false);
|
||||
tmp.setWritable(true, true);
|
||||
if (!tmp.renameTo(f)) throw new IOException("cannot rename " + tmp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.termux.terminal.ByteQueue;
|
||||
import com.termux.terminal.TerminalEmulator;
|
||||
import com.termux.terminal.TerminalOutput;
|
||||
import com.termux.terminal.TerminalSessionClient;
|
||||
|
||||
import net.schmizz.sshj.SSHClient;
|
||||
import net.schmizz.sshj.common.Buffer;
|
||||
import net.schmizz.sshj.connection.channel.direct.Session;
|
||||
import net.schmizz.sshj.sftp.FileAttributes;
|
||||
import net.schmizz.sshj.sftp.SFTPClient;
|
||||
import net.schmizz.sshj.transport.verification.HostKeyVerifier;
|
||||
import net.schmizz.sshj.userauth.UserAuthException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ConnectException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A terminal emulator fed by an SSH shell channel to the local Termux sshd.
|
||||
*
|
||||
* All emulator access happens on the main thread; the connection thread only moves bytes.
|
||||
*/
|
||||
public final class TermSession extends TerminalOutput implements TerminalSessionClient {
|
||||
|
||||
public interface Host {
|
||||
void onScreenUpdated();
|
||||
|
||||
void onTitleChanged(String title);
|
||||
|
||||
void onCopy(String text);
|
||||
|
||||
void onPasteRequest();
|
||||
|
||||
void onBell();
|
||||
|
||||
/** Shows a password prompt; {@code reply} receives null when cancelled. */
|
||||
void askPassword(String message, Consumer<String> reply);
|
||||
|
||||
void confirm(String message, Consumer<Boolean> reply);
|
||||
|
||||
/** Asks Termux to start sshd; {@code reply} receives whether the request was sent. */
|
||||
void startSshd(Consumer<Boolean> reply);
|
||||
|
||||
/** Files under {@link Appearance#dir} changed. */
|
||||
void onAppearanceSynced();
|
||||
}
|
||||
|
||||
private static final String TAG = "whc-shell";
|
||||
private static final int TRANSCRIPT_ROWS = 10000;
|
||||
|
||||
static final String HOST = "127.0.0.1";
|
||||
static final int PORT = 8022;
|
||||
|
||||
private final Context app;
|
||||
private final SharedPreferences prefs;
|
||||
private final Handler main = new Handler(Looper.getMainLooper());
|
||||
private final byte[] drainBuf = new byte[16 * 1024];
|
||||
|
||||
private Host host;
|
||||
private TerminalEmulator emulator;
|
||||
private volatile int cols, rows, cellW, cellH;
|
||||
private Conn conn;
|
||||
private boolean connected;
|
||||
private int cursorStyle = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE;
|
||||
|
||||
TermSession(Context app) {
|
||||
this.app = app;
|
||||
this.prefs = app.getSharedPreferences("session", Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public void setHost(Host h) {
|
||||
host = h;
|
||||
}
|
||||
|
||||
public TerminalEmulator emulator() {
|
||||
return emulator;
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
public boolean isConnecting() {
|
||||
return conn != null && !connected;
|
||||
}
|
||||
|
||||
public void setCursorStyle(int style) {
|
||||
cursorStyle = style;
|
||||
if (emulator != null) emulator.setCursorStyle();
|
||||
}
|
||||
|
||||
/** Re-reads the color scheme into the emulator after {@code TerminalColors.COLOR_SCHEME} changed. */
|
||||
public void resetColors() {
|
||||
if (emulator != null) emulator.mColors.reset();
|
||||
}
|
||||
|
||||
/** Called by the view whenever its size in cells changes; the first call starts the session. */
|
||||
public void resize(int c, int r, int cw, int ch) {
|
||||
cols = c;
|
||||
rows = r;
|
||||
cellW = cw;
|
||||
cellH = ch;
|
||||
if (emulator == null) {
|
||||
emulator = new TerminalEmulator(this, c, r, cw, ch, TRANSCRIPT_ROWS, this);
|
||||
connect();
|
||||
} else {
|
||||
emulator.resize(c, r, cw, ch);
|
||||
if (conn != null) conn.resize(c, r, c * cw, r * ch);
|
||||
}
|
||||
}
|
||||
|
||||
public void connect() {
|
||||
if (emulator == null || conn != null) return;
|
||||
conn = new Conn();
|
||||
conn.start();
|
||||
}
|
||||
|
||||
private void local(String text) {
|
||||
byte[] b = text.getBytes(StandardCharsets.UTF_8);
|
||||
emulator.append(b, b.length);
|
||||
if (host != null) host.onScreenUpdated();
|
||||
}
|
||||
|
||||
private void localFromAnyThread(String text) {
|
||||
main.post(() -> local(text));
|
||||
}
|
||||
|
||||
// TerminalOutput: bytes the user (or the emulator, for replies) sends to the remote side.
|
||||
|
||||
@Override
|
||||
public void write(byte[] data, int offset, int count) {
|
||||
if (conn != null && connected) conn.send(data, offset, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void titleChanged(String oldTitle, String newTitle) {
|
||||
if (host != null) host.onTitleChanged(newTitle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCopyTextToClipboard(String text) {
|
||||
if (host != null) host.onCopy(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPasteTextFromClipboard() {
|
||||
if (host != null) host.onPasteRequest();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBell() {
|
||||
if (host != null) host.onBell();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorsChanged() {
|
||||
if (host != null) host.onScreenUpdated();
|
||||
}
|
||||
|
||||
// TerminalSessionClient
|
||||
|
||||
@Override
|
||||
public void onTerminalCursorStateChange(boolean state) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getTerminalCursorStyle() {
|
||||
return cursorStyle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logError(String tag, String message) {
|
||||
Log.e(TAG, tag + ": " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logWarn(String tag, String message) {
|
||||
Log.w(TAG, tag + ": " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logInfo(String tag, String message) {
|
||||
Log.i(TAG, tag + ": " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logDebug(String tag, String message) {
|
||||
Log.d(TAG, tag + ": " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logVerbose(String tag, String message) {
|
||||
Log.v(TAG, tag + ": " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logStackTraceWithMessage(String tag, String message, Exception e) {
|
||||
Log.e(TAG, tag + ": " + message, e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logStackTrace(String tag, Exception e) {
|
||||
Log.e(TAG, tag, e);
|
||||
}
|
||||
|
||||
/** Termux's Linux user name, e.g. u0_a315. Termux sshd ignores it, but stock sshd would not. */
|
||||
private String termuxUser() {
|
||||
try {
|
||||
int uid = app.getPackageManager().getApplicationInfo("com.termux", 0).uid;
|
||||
return "u" + (uid / 100000) + "_a" + (uid % 100000 - 10000);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return "termux";
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T askOnMain(Consumer<Consumer<T>> ask) throws InterruptedException {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<T> result = new AtomicReference<>();
|
||||
main.post(() -> {
|
||||
if (host == null) {
|
||||
done.countDown();
|
||||
return;
|
||||
}
|
||||
ask.accept(v -> {
|
||||
result.set(v);
|
||||
done.countDown();
|
||||
});
|
||||
});
|
||||
done.await();
|
||||
return result.get();
|
||||
}
|
||||
|
||||
private static String fingerprint(PublicKey key) {
|
||||
try {
|
||||
byte[] blob = new Buffer.PlainBuffer().putPublicKey(key).getCompactData();
|
||||
byte[] d = MessageDigest.getInstance("SHA-256").digest(blob);
|
||||
return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString(d);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(Throwable e) {
|
||||
if (e instanceof ConnectException)
|
||||
return "连不上 " + HOST + ":" + PORT + ",请先在 Termux 里运行 sshd";
|
||||
String m = e.getMessage();
|
||||
return m == null || m.isEmpty() ? e.getClass().getSimpleName() : m;
|
||||
}
|
||||
|
||||
private final class Conn extends Thread implements HostKeyVerifier {
|
||||
private final ByteQueue queue = new ByteQueue(64 * 1024);
|
||||
private final AtomicBoolean posted = new AtomicBoolean();
|
||||
private final ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
private volatile SSHClient ssh;
|
||||
private volatile Session.Shell shell;
|
||||
private volatile OutputStream out;
|
||||
|
||||
Conn() {
|
||||
super("ssh-conn");
|
||||
setDaemon(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String err = null;
|
||||
try {
|
||||
session();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "session ended", e);
|
||||
err = describe(e);
|
||||
}
|
||||
queue.close();
|
||||
io.shutdownNow();
|
||||
try {
|
||||
if (ssh != null) ssh.disconnect();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
String why = err;
|
||||
main.post(() -> finished(why));
|
||||
}
|
||||
|
||||
private SSHClient open() throws Exception {
|
||||
for (int attempt = 0; ; attempt++) {
|
||||
SSHClient c = new SSHClient();
|
||||
c.setConnectTimeout(5000);
|
||||
c.addHostKeyVerifier(this);
|
||||
try {
|
||||
c.connect(HOST, PORT);
|
||||
return c;
|
||||
} catch (ConnectException e) {
|
||||
c.close();
|
||||
if (attempt == 0) {
|
||||
Boolean sent = askOnMain(reply -> host.startSshd(reply));
|
||||
if (sent == null || !sent) throw e;
|
||||
localFromAnyThread("\033[2m已请求 Termux 启动 sshd …\033[0m\r\n");
|
||||
} else if (attempt >= 8) {
|
||||
throw new IOException("Termux 没有启动 sshd。请在 ~/.termux/termux.properties 里设置 "
|
||||
+ "allow-external-apps = true,或手动在 Termux 里运行 sshd", e);
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void session() throws Exception {
|
||||
localFromAnyThread("\033[2m正在连接 Termux (" + HOST + ":" + PORT + ") …\033[0m\r\n");
|
||||
SSHClient c = open();
|
||||
ssh = c;
|
||||
authenticate(c);
|
||||
|
||||
Session s = c.startSession();
|
||||
s.allocatePTY("xterm-256color", cols, rows, cols * cellW, rows * cellH,
|
||||
Collections.emptyMap());
|
||||
Session.Shell sh = s.startShell();
|
||||
shell = sh;
|
||||
out = sh.getOutputStream();
|
||||
main.post(() -> {
|
||||
if (conn == this) connected = true;
|
||||
});
|
||||
Thread sync = new Thread(() -> syncAppearance(c), "ssh-sync");
|
||||
sync.setDaemon(true);
|
||||
sync.start();
|
||||
|
||||
InputStream in = sh.getInputStream();
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
if (!queue.write(buf, 0, n)) break;
|
||||
if (posted.compareAndSet(false, true)) main.post(this::drain);
|
||||
}
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
posted.set(false);
|
||||
int n;
|
||||
boolean any = false;
|
||||
while ((n = queue.read(drainBuf, false)) > 0) {
|
||||
emulator.append(drainBuf, n);
|
||||
any = true;
|
||||
}
|
||||
if (any && host != null) host.onScreenUpdated();
|
||||
}
|
||||
|
||||
private void authenticate(SSHClient c) throws Exception {
|
||||
String user = termuxUser();
|
||||
SshKeys keys = SshKeys.ensure(app.getFilesDir(), "whc-shell@" + Build.MODEL);
|
||||
try {
|
||||
c.authPublickey(user, c.loadKeys(keys.privateKey.getPath()));
|
||||
return;
|
||||
} catch (UserAuthException ignored) {
|
||||
}
|
||||
String msg = "首次连接需要 Termux 的登录密码(在 Termux 里用 passwd 设置)。\n"
|
||||
+ "成功后会把本机公钥加入 ~/.ssh/authorized_keys,以后自动用密钥登录。";
|
||||
for (int i = 0; i < 3; i++) {
|
||||
String m = msg;
|
||||
String pw = askOnMain(reply -> host.askPassword(m, reply));
|
||||
if (pw == null) throw new IOException("已取消登录");
|
||||
try {
|
||||
c.authPassword(user, pw);
|
||||
installKey(c, keys.publicLine());
|
||||
return;
|
||||
} catch (UserAuthException e) {
|
||||
msg = "密码不对,请重试。";
|
||||
}
|
||||
}
|
||||
throw new IOException("密码验证失败");
|
||||
}
|
||||
|
||||
private void installKey(SSHClient c, String line) throws IOException {
|
||||
String q = "'" + line.replace("'", "'\\''") + "'";
|
||||
String cmd = "umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && "
|
||||
+ "{ grep -qxF " + q + " ~/.ssh/authorized_keys || echo " + q
|
||||
+ " >> ~/.ssh/authorized_keys; }";
|
||||
try (Session s = c.startSession()) {
|
||||
Session.Command run = s.exec(cmd);
|
||||
run.join(10, TimeUnit.SECONDS);
|
||||
Integer st = run.getExitStatus();
|
||||
if (st == null || st != 0)
|
||||
localFromAnyThread("\033[33m公钥写入失败(exit " + st + "),下次仍需密码。\033[0m\r\n");
|
||||
else
|
||||
localFromAnyThread("\033[2m已写入公钥,以后自动登录。\033[0m\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors ~/.termux/{font.ttf,colors.properties,termux.properties} when they change. */
|
||||
private void syncAppearance(SSHClient c) {
|
||||
File dir = Appearance.dir(app.getFilesDir());
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||
boolean changed = false;
|
||||
try (SFTPClient sftp = c.newSFTPClient()) {
|
||||
for (String name : Appearance.FILES) {
|
||||
String remote = ".termux/" + name;
|
||||
File local = new File(dir, name);
|
||||
FileAttributes a = sftp.statExistence(remote);
|
||||
if (a != null && a.getSize() > 32L << 20) a = null;
|
||||
String stamp = a == null ? null : a.getSize() + ":" + a.getMtime();
|
||||
String key = "sync." + name;
|
||||
if (Objects.equals(stamp, prefs.getString(key, null)) && (a == null) != local.isFile())
|
||||
continue;
|
||||
if (a == null) {
|
||||
if (local.exists() && !local.delete()) continue;
|
||||
} else {
|
||||
File part = new File(dir, name + ".part");
|
||||
sftp.get(remote, part.getPath());
|
||||
if (!part.renameTo(local)) continue;
|
||||
}
|
||||
prefs.edit().putString(key, stamp).apply();
|
||||
changed = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "appearance sync failed", e);
|
||||
}
|
||||
if (changed) main.post(() -> {
|
||||
if (host != null) host.onAppearanceSynced();
|
||||
});
|
||||
}
|
||||
|
||||
void send(byte[] data, int offset, int count) {
|
||||
byte[] copy = new byte[count];
|
||||
System.arraycopy(data, offset, copy, 0, count);
|
||||
try {
|
||||
io.execute(() -> {
|
||||
try {
|
||||
out.write(copy);
|
||||
out.flush();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "write failed", e);
|
||||
}
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
void resize(int c, int r, int w, int h) {
|
||||
if (shell == null) return;
|
||||
try {
|
||||
io.execute(() -> {
|
||||
try {
|
||||
shell.changeWindowDimensions(c, r, w, h);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "resize failed", e);
|
||||
}
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
// HostKeyVerifier: trust on first use, ask when the key changes.
|
||||
|
||||
@Override
|
||||
public boolean verify(String hostname, int port, PublicKey key) {
|
||||
String k = "hostkey." + hostname + ":" + port;
|
||||
String fp = fingerprint(key);
|
||||
String old = prefs.getString(k, null);
|
||||
if (fp.equals(old)) return true;
|
||||
if (old != null) {
|
||||
String m = "Termux 的主机密钥变了(重装 Termux 或重新生成 ssh_host_* 会这样)。\n\n旧:" + old
|
||||
+ "\n新:" + fp + "\n\n信任新密钥?";
|
||||
Boolean ok;
|
||||
try {
|
||||
ok = askOnMain(reply -> host.confirm(m, reply));
|
||||
} catch (InterruptedException e) {
|
||||
return false;
|
||||
}
|
||||
if (ok == null || !ok) return false;
|
||||
}
|
||||
prefs.edit().putString(k, fp).apply();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> findExistingAlgorithms(String hostname, int port) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private void finished(String err) {
|
||||
conn = null;
|
||||
connected = false;
|
||||
local("\r\n\033[33m[" + (err == null ? "连接已关闭" : err) + "] 按 Enter 重新连接\033[0m\r\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.SystemClock;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.view.ContextMenu;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyCharacterMap;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.PointerIcon;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.BaseInputConnection;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
|
||||
import com.termux.terminal.KeyHandler;
|
||||
import com.termux.terminal.TerminalBuffer;
|
||||
import com.termux.terminal.TerminalEmulator;
|
||||
import com.termux.terminal.TerminalRow;
|
||||
import com.termux.terminal.TextStyle;
|
||||
import com.termux.view.TerminalRenderer;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Draws a {@link TermSession} and turns keyboard, IME and pointer input into terminal input. */
|
||||
public final class TermView extends View {
|
||||
|
||||
public interface Actions {
|
||||
void paste();
|
||||
|
||||
/** Pastes the last mouse selection (X11-style primary selection), or the clipboard. */
|
||||
void pastePrimary();
|
||||
|
||||
void copy(String text);
|
||||
|
||||
void setPrimary(String text);
|
||||
|
||||
void zoom(int delta);
|
||||
|
||||
void toggleFullscreen();
|
||||
|
||||
/** Fills the right-click / long-press menu. */
|
||||
void buildMenu(ContextMenu menu, String selection);
|
||||
}
|
||||
|
||||
private static final long MULTI_CLICK_MS = 400;
|
||||
private static final String WORD_CHARS = "_-.~/:@%+=?&#$";
|
||||
|
||||
private TermSession session;
|
||||
private Actions actions;
|
||||
private TerminalRenderer renderer;
|
||||
private Typeface typeface = Typeface.MONOSPACE;
|
||||
private int textSizePx = 28;
|
||||
private int topRow;
|
||||
private float scrollAccum;
|
||||
private int combiningAccent;
|
||||
private final GestureDetector gestures;
|
||||
private final KeyEncoder.Modes modes = new KeyEncoder.Modes();
|
||||
|
||||
// Selection, in external buffer coordinates (negative rows are scrollback), inclusive.
|
||||
private boolean selActive;
|
||||
private int selX1, selY1, selX2, selY2;
|
||||
private int anchorX, anchorY, selMode;
|
||||
private boolean selDragging;
|
||||
private long lastClickTime;
|
||||
private int lastClickX = -1, lastClickY, clickCount;
|
||||
|
||||
// Mouse button state as last seen, and whether the current gesture goes to the application.
|
||||
private int mouseButtons;
|
||||
private boolean mouseReporting;
|
||||
private boolean implicitPrimary;
|
||||
private int lastMouseCol = -1, lastMouseRow = -1;
|
||||
|
||||
private int blinkMs;
|
||||
private boolean blinkOn = true;
|
||||
private final Runnable blink = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null || blinkMs == 0) return;
|
||||
blinkOn = !blinkOn;
|
||||
e.setCursorBlinkState(blinkOn);
|
||||
invalidate();
|
||||
postDelayed(this, blinkMs);
|
||||
}
|
||||
};
|
||||
|
||||
public TermView(Context context) {
|
||||
super(context);
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
setPointerIcon(PointerIcon.getSystemIcon(context, PointerIcon.TYPE_TEXT));
|
||||
setOnCreateContextMenuListener((menu, v, info) -> {
|
||||
if (actions != null) actions.buildMenu(menu, selectedText());
|
||||
});
|
||||
gestures = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
|
||||
scrollAccum += dy / renderer.getFontLineSpacing();
|
||||
int lines = (int) scrollAccum;
|
||||
if (lines != 0) {
|
||||
scrollAccum -= lines;
|
||||
scrollLines(lines, e2);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
clearSelection();
|
||||
showSoftKeyboard();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e) {
|
||||
if (emulator() == null) return;
|
||||
selMode = 2;
|
||||
anchorX = column(e);
|
||||
anchorY = bufferRow(e);
|
||||
extendSelection(anchorX, anchorY);
|
||||
showContextMenu(e.getX(), e.getY());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void attach(TermSession s, Actions a) {
|
||||
session = s;
|
||||
actions = a;
|
||||
updateSize();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setFont(Typeface tf, int px) {
|
||||
typeface = tf;
|
||||
textSizePx = px;
|
||||
renderer = new TerminalRenderer(px, tf);
|
||||
updateSize();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setTextSizePx(int px) {
|
||||
setFont(typeface, px);
|
||||
}
|
||||
|
||||
public void setBlink(int ms) {
|
||||
blinkMs = ms;
|
||||
removeCallbacks(blink);
|
||||
TerminalEmulator e = emulator();
|
||||
if (e != null) {
|
||||
e.setCursorBlinkingEnabled(ms > 0);
|
||||
e.setCursorBlinkState(true);
|
||||
}
|
||||
blinkOn = true;
|
||||
if (ms > 0 && hasWindowFocus()) postDelayed(blink, ms);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void restartBlink() {
|
||||
if (blinkMs == 0) return;
|
||||
TerminalEmulator e = emulator();
|
||||
if (e != null) e.setCursorBlinkState(true);
|
||||
blinkOn = true;
|
||||
removeCallbacks(blink);
|
||||
postDelayed(blink, blinkMs);
|
||||
}
|
||||
|
||||
public void showSoftKeyboard() {
|
||||
requestFocus();
|
||||
InputMethodManager imm = getContext().getSystemService(InputMethodManager.class);
|
||||
if (imm != null) imm.showSoftInput(this, 0);
|
||||
}
|
||||
|
||||
/** Called after new output; keeps a scrolled-back view and the selection anchored to the text. */
|
||||
public void onScreenUpdated() {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null) return;
|
||||
int scrolled = e.getScrollCounter();
|
||||
e.clearScrollCounter();
|
||||
if (scrolled != 0) {
|
||||
int min = -e.getScreen().getActiveTranscriptRows();
|
||||
if (topRow < 0) topRow = Math.max(topRow - scrolled, min);
|
||||
if (selActive || selDragging) {
|
||||
selY1 -= scrolled;
|
||||
selY2 -= scrolled;
|
||||
anchorY -= scrolled;
|
||||
if (selY1 < min) clearSelection();
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private TerminalEmulator emulator() {
|
||||
return session == null ? null : session.emulator();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
updateSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPadding(int left, int top, int right, int bottom) {
|
||||
super.setPadding(left, top, right, bottom);
|
||||
updateSize();
|
||||
}
|
||||
|
||||
private void updateSize() {
|
||||
int w = getWidth() - getPaddingLeft() - getPaddingRight();
|
||||
int h = getHeight() - getPaddingTop() - getPaddingBottom();
|
||||
if (session == null || renderer == null || w <= 0 || h <= 0) return;
|
||||
float cw = renderer.getFontWidth();
|
||||
int ch = renderer.getFontLineSpacing();
|
||||
int cols = Math.max(4, (int) (w / cw));
|
||||
int rows = Math.max(4, (h - renderer.getFontLineSpacingAndAscent()) / ch);
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null || e.mColumns != cols || e.mRows != rows) {
|
||||
clearSelection();
|
||||
topRow = 0;
|
||||
}
|
||||
session.resize(cols, rows, (int) cw, ch);
|
||||
if (e == null) setBlink(blinkMs);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null || renderer == null) {
|
||||
canvas.drawColor(0xff000000);
|
||||
return;
|
||||
}
|
||||
canvas.drawColor(e.mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND]);
|
||||
canvas.save();
|
||||
canvas.translate(getPaddingLeft(), getPaddingTop());
|
||||
if (selActive) renderer.render(e, canvas, topRow, selY1, selY2, selX1, selX2);
|
||||
else renderer.render(e, canvas, topRow, -1, -1, -1, -1);
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean focused) {
|
||||
super.onWindowFocusChanged(focused);
|
||||
TerminalEmulator e = emulator();
|
||||
if (e != null && e.isFocusReportingActive() && session.isConnected())
|
||||
send(focused ? "\033[I" : "\033[O");
|
||||
if (focused) restartBlink();
|
||||
else removeCallbacks(blink);
|
||||
}
|
||||
|
||||
// ---- output helpers
|
||||
|
||||
private void send(String s) {
|
||||
if (session == null || s == null || s.isEmpty()) return;
|
||||
byte[] b = s.getBytes(StandardCharsets.UTF_8);
|
||||
session.write(b, 0, b.length);
|
||||
}
|
||||
|
||||
/** Sends user input: snaps back to the bottom and drops the selection. */
|
||||
private void sendInput(String s) {
|
||||
if (s == null || s.isEmpty()) return;
|
||||
if (topRow != 0) topRow = 0;
|
||||
clearSelection();
|
||||
restartBlink();
|
||||
send(s);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// ---- geometry
|
||||
|
||||
private int column(MotionEvent ev) {
|
||||
int cols = emulator().mColumns;
|
||||
int c = (int) ((ev.getX() - getPaddingLeft()) / renderer.getFontWidth());
|
||||
return Math.max(0, Math.min(cols - 1, c));
|
||||
}
|
||||
|
||||
/** Visible row, 0-based. */
|
||||
private int row(MotionEvent ev) {
|
||||
int rows = emulator().mRows;
|
||||
int r = (int) Math.floor((ev.getY() - getPaddingTop()) / renderer.getFontLineSpacing());
|
||||
return Math.max(0, Math.min(rows - 1, r));
|
||||
}
|
||||
|
||||
private int bufferRow(MotionEvent ev) {
|
||||
return row(ev) + topRow;
|
||||
}
|
||||
|
||||
// ---- selection
|
||||
|
||||
public String selectedText() {
|
||||
TerminalEmulator e = emulator();
|
||||
if (!selActive || e == null) return null;
|
||||
String s = e.getSelectedText(selX1, selY1, selX2, selY2);
|
||||
return s == null || s.isEmpty() ? null : s;
|
||||
}
|
||||
|
||||
public void clearSelection() {
|
||||
if (selActive || selDragging) {
|
||||
selActive = false;
|
||||
selDragging = false;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void selectAll() {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null) return;
|
||||
selActive = true;
|
||||
selX1 = 0;
|
||||
selY1 = -e.getScreen().getActiveTranscriptRows();
|
||||
selX2 = e.mColumns - 1;
|
||||
selY2 = e.mRows - 1;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private static boolean before(int x1, int y1, int x2, int y2) {
|
||||
return y1 < y2 || (y1 == y2 && x1 <= x2);
|
||||
}
|
||||
|
||||
private int codePointAt(TerminalBuffer screen, int col, int row) {
|
||||
TerminalRow line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row));
|
||||
int i = line.findStartOfColumn(col);
|
||||
if (i >= line.getSpaceUsed()) return ' ';
|
||||
return Character.codePointAt(line.mText, i);
|
||||
}
|
||||
|
||||
private static boolean isWordChar(int cp) {
|
||||
return Character.isLetterOrDigit(cp) || WORD_CHARS.indexOf(cp) >= 0;
|
||||
}
|
||||
|
||||
/** Selects from the anchor to (x, y), snapping to words or lines depending on the click count. */
|
||||
private void extendSelection(int x, int y) {
|
||||
TerminalEmulator e = emulator();
|
||||
TerminalBuffer screen = e.getScreen();
|
||||
int cols = e.mColumns;
|
||||
int ax = anchorX, ay = anchorY;
|
||||
int x1, y1, x2, y2;
|
||||
if (before(ax, ay, x, y)) {
|
||||
x1 = ax; y1 = ay; x2 = x; y2 = y;
|
||||
} else {
|
||||
x1 = x; y1 = y; x2 = ax; y2 = ay;
|
||||
}
|
||||
if (selMode == 2) {
|
||||
if (isWordChar(codePointAt(screen, x1, y1)))
|
||||
while (x1 > 0 && isWordChar(codePointAt(screen, x1 - 1, y1))) x1--;
|
||||
if (isWordChar(codePointAt(screen, x2, y2)))
|
||||
while (x2 < cols - 1 && isWordChar(codePointAt(screen, x2 + 1, y2))) x2++;
|
||||
} else if (selMode == 3) {
|
||||
int min = -screen.getActiveTranscriptRows();
|
||||
while (y1 > min && screen.getLineWrap(y1 - 1)) y1--;
|
||||
while (y2 < e.mRows - 1 && screen.getLineWrap(y2)) y2++;
|
||||
x1 = 0;
|
||||
x2 = cols - 1;
|
||||
}
|
||||
selX1 = x1;
|
||||
selY1 = y1;
|
||||
selX2 = x2;
|
||||
selY2 = y2;
|
||||
selActive = true;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
// ---- scrolling
|
||||
|
||||
/** Positive {@code lines} scrolls towards newer output. */
|
||||
private void scrollLines(int lines, MotionEvent ev) {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null || lines == 0) return;
|
||||
boolean up = lines < 0;
|
||||
int n = Math.abs(lines);
|
||||
boolean shift = ev != null && (ev.getMetaState() & KeyEvent.META_SHIFT_ON) != 0;
|
||||
if (e.isMouseTrackingActive() && ev != null && !shift) {
|
||||
int col = column(ev) + 1, row = row(ev) + 1;
|
||||
int button = (up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON
|
||||
: TerminalEmulator.MOUSE_WHEELDOWN_BUTTON) | mouseMods(ev);
|
||||
for (int i = 0; i < n; i++) e.sendMouseEvent(button, col, row, true);
|
||||
} else if (e.isAlternateBufferActive() && !shift) {
|
||||
String key = KeyHandler.getCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
0, e.isCursorKeysApplicationMode(), e.isKeypadApplicationMode());
|
||||
for (int i = 0; i < n; i++) send(key);
|
||||
} else {
|
||||
scrollTranscript(lines);
|
||||
}
|
||||
}
|
||||
|
||||
private void scrollTranscript(int lines) {
|
||||
TerminalEmulator e = emulator();
|
||||
if (e == null) return;
|
||||
int min = -e.getScreen().getActiveTranscriptRows();
|
||||
int t = Math.min(0, Math.max(min, topRow + lines));
|
||||
if (t != topRow) {
|
||||
topRow = t;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pointer input
|
||||
|
||||
@Override
|
||||
public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) {
|
||||
return PointerIcon.getSystemIcon(getContext(), PointerIcon.TYPE_TEXT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotionEvent(MotionEvent ev) {
|
||||
if (emulator() == null || renderer == null) return super.onGenericMotionEvent(ev);
|
||||
if (ev.getAction() == MotionEvent.ACTION_SCROLL
|
||||
&& ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
|
||||
float v = ev.getAxisValue(MotionEvent.AXIS_VSCROLL);
|
||||
if ((ev.getMetaState() & KeyEvent.META_CTRL_ON) != 0) {
|
||||
if (actions != null && v != 0) actions.zoom(v > 0 ? 1 : -1);
|
||||
return true;
|
||||
}
|
||||
scrollAccum -= v * 3;
|
||||
int lines = (int) scrollAccum;
|
||||
if (lines != 0) {
|
||||
scrollAccum -= lines;
|
||||
scrollLines(lines, ev);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return onMouse(ev);
|
||||
return super.onGenericMotionEvent(ev);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
if (emulator() == null || renderer == null) return true;
|
||||
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return onMouse(ev);
|
||||
return gestures.onTouchEvent(ev);
|
||||
}
|
||||
|
||||
private static int mouseMods(MotionEvent ev) {
|
||||
int meta = ev.getMetaState(), m = 0;
|
||||
if ((meta & KeyEvent.META_SHIFT_ON) != 0) m |= 4;
|
||||
if ((meta & KeyEvent.META_ALT_ON) != 0) m |= 8;
|
||||
if ((meta & KeyEvent.META_CTRL_ON) != 0) m |= 16;
|
||||
return m;
|
||||
}
|
||||
|
||||
private static final int[][] BUTTONS = {
|
||||
{MotionEvent.BUTTON_PRIMARY, TerminalEmulator.MOUSE_LEFT_BUTTON},
|
||||
{MotionEvent.BUTTON_TERTIARY, 1},
|
||||
{MotionEvent.BUTTON_SECONDARY, 2},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mouse events arrive through both onTouchEvent and onGenericMotionEvent depending on the
|
||||
* button, so presses and releases are derived from changes in the button state.
|
||||
*/
|
||||
private boolean onMouse(MotionEvent ev) {
|
||||
TerminalEmulator e = emulator();
|
||||
int action = ev.getActionMasked();
|
||||
int state = ev.getButtonState()
|
||||
& (MotionEvent.BUTTON_PRIMARY | MotionEvent.BUTTON_SECONDARY | MotionEvent.BUTTON_TERTIARY);
|
||||
// Touchpad taps and injected events can come as DOWN/UP without any button state.
|
||||
if (action == MotionEvent.ACTION_DOWN && state == 0) implicitPrimary = true;
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) implicitPrimary = false;
|
||||
if (implicitPrimary) state |= MotionEvent.BUTTON_PRIMARY;
|
||||
if (action == MotionEvent.ACTION_CANCEL) state = 0;
|
||||
int pressed = state & ~mouseButtons, released = mouseButtons & ~state;
|
||||
int held = mouseButtons;
|
||||
mouseButtons = state;
|
||||
int col = column(ev), row = row(ev);
|
||||
boolean shift = (ev.getMetaState() & KeyEvent.META_SHIFT_ON) != 0;
|
||||
boolean tracking = e.isMouseTrackingActive() && !shift && session.isConnected();
|
||||
|
||||
if (pressed != 0 && held == 0) {
|
||||
mouseReporting = tracking;
|
||||
requestFocus();
|
||||
}
|
||||
boolean report = (held != 0 || pressed != 0) ? mouseReporting : tracking;
|
||||
|
||||
if (report) {
|
||||
int mods = mouseMods(ev);
|
||||
for (int[] b : BUTTONS)
|
||||
if ((released & b[0]) != 0) e.sendMouseEvent(b[1] | mods, col + 1, row + 1, false);
|
||||
for (int[] b : BUTTONS)
|
||||
if ((pressed & b[0]) != 0) e.sendMouseEvent(b[1] | mods, col + 1, row + 1, true);
|
||||
boolean moved = col != lastMouseCol || row != lastMouseRow;
|
||||
if (moved && pressed == 0 && released == 0 && (action == MotionEvent.ACTION_MOVE
|
||||
|| action == MotionEvent.ACTION_HOVER_MOVE)) {
|
||||
int b = 3;
|
||||
for (int[] bb : BUTTONS)
|
||||
if ((state & bb[0]) != 0) {
|
||||
b = bb[1];
|
||||
break;
|
||||
}
|
||||
e.sendMouseEvent((TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED + b) | mods, col + 1, row + 1, true);
|
||||
}
|
||||
lastMouseCol = col;
|
||||
lastMouseRow = row;
|
||||
return true;
|
||||
}
|
||||
lastMouseCol = col;
|
||||
lastMouseRow = row;
|
||||
|
||||
if ((pressed & MotionEvent.BUTTON_PRIMARY) != 0) {
|
||||
long now = SystemClock.uptimeMillis();
|
||||
int y = row + topRow;
|
||||
if (now - lastClickTime < MULTI_CLICK_MS && Math.abs(col - lastClickX) <= 1 && y == lastClickY)
|
||||
clickCount = clickCount % 3 + 1;
|
||||
else
|
||||
clickCount = 1;
|
||||
lastClickTime = now;
|
||||
lastClickX = col;
|
||||
lastClickY = y;
|
||||
clearSelection();
|
||||
selMode = clickCount;
|
||||
anchorX = col;
|
||||
anchorY = y;
|
||||
selDragging = true;
|
||||
if (selMode > 1) extendSelection(col, y);
|
||||
} else if (selDragging && (state & MotionEvent.BUTTON_PRIMARY) != 0
|
||||
&& action == MotionEvent.ACTION_MOVE) {
|
||||
float y = ev.getY();
|
||||
if (y < getPaddingTop()) scrollTranscript(-1);
|
||||
else if (y > getHeight() - getPaddingBottom()) scrollTranscript(1);
|
||||
extendSelection(col, row + topRow);
|
||||
}
|
||||
if ((released & MotionEvent.BUTTON_PRIMARY) != 0 && selDragging) {
|
||||
selDragging = false;
|
||||
String s = selectedText();
|
||||
if (s != null && actions != null) actions.setPrimary(s);
|
||||
}
|
||||
if ((pressed & MotionEvent.BUTTON_TERTIARY) != 0 && actions != null) actions.pastePrimary();
|
||||
if ((pressed & MotionEvent.BUTTON_SECONDARY) != 0) showContextMenu(ev.getX(), ev.getY());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- keyboard
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent ev) {
|
||||
if (session == null) return super.onKeyDown(keyCode, ev);
|
||||
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
// Mouse right-click and the navigation bar both arrive as BACK; only a real keyboard means Esc.
|
||||
if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) return true;
|
||||
if (isVirtual(ev)) return super.onKeyDown(keyCode, ev);
|
||||
keyCode = KeyEvent.KEYCODE_ESCAPE;
|
||||
}
|
||||
|
||||
if (ev.isMetaPressed()) return super.onKeyDown(keyCode, ev);
|
||||
|
||||
boolean ctrl = ev.isCtrlPressed(), alt = ev.isAltPressed(), shift = ev.isShiftPressed();
|
||||
if (handleShortcut(keyCode, ctrl, alt, shift)) return true;
|
||||
|
||||
if (!session.isConnected()) {
|
||||
if (!session.isConnecting() && (keyCode == KeyEvent.KEYCODE_ENTER
|
||||
|| keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)) {
|
||||
session.connect();
|
||||
return true;
|
||||
}
|
||||
return !KeyEvent.isModifierKey(keyCode) || super.onKeyDown(keyCode, ev);
|
||||
}
|
||||
|
||||
int meta = ev.getMetaState();
|
||||
int text = ev.getUnicodeChar(meta & ~(KeyEvent.META_CTRL_MASK | KeyEvent.META_ALT_MASK));
|
||||
int base = ev.getUnicodeChar(0);
|
||||
if ((text & KeyCharacterMap.COMBINING_ACCENT) != 0) {
|
||||
combiningAccent = text & KeyCharacterMap.COMBINING_ACCENT_MASK;
|
||||
return true;
|
||||
}
|
||||
if (combiningAccent != 0 && text != 0) {
|
||||
int composed = KeyCharacterMap.getDeadChar(combiningAccent, text);
|
||||
combiningAccent = 0;
|
||||
if (composed != 0) text = base = composed;
|
||||
}
|
||||
if ((base & KeyCharacterMap.COMBINING_ACCENT) != 0) base = 0;
|
||||
|
||||
TerminalEmulator e = emulator();
|
||||
modes.cursorApp = e.isCursorKeysApplicationMode();
|
||||
modes.keypadApp = e.isKeypadApplicationMode();
|
||||
modes.kittyFlags = e.getKittyKeyboardFlags();
|
||||
modes.modifyOtherKeys = e.getModifyOtherKeys();
|
||||
int mods = (shift ? KeyEncoder.SHIFT : 0) | (alt ? KeyEncoder.ALT : 0) | (ctrl ? KeyEncoder.CTRL : 0);
|
||||
String out = KeyEncoder.encode(keyCode, base, text, mods, ev.isNumLockOn(), modes);
|
||||
if (out == null) return KeyEvent.isModifierKey(keyCode) ? super.onKeyDown(keyCode, ev) : false;
|
||||
sendInput(out);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Terminal-level shortcuts that never reach the application. */
|
||||
private boolean handleShortcut(int keyCode, boolean ctrl, boolean alt, boolean shift) {
|
||||
if (actions == null) return false;
|
||||
if (keyCode == KeyEvent.KEYCODE_F11 && !ctrl && !alt && !shift) {
|
||||
actions.toggleFullscreen();
|
||||
return true;
|
||||
}
|
||||
TerminalEmulator e = emulator();
|
||||
if (shift && !ctrl && !alt && e != null && !e.isAlternateBufferActive()) {
|
||||
if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
|
||||
scrollTranscript(-(e.mRows - 1));
|
||||
return true;
|
||||
} else if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
|
||||
scrollTranscript(e.mRows - 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!ctrl || !shift || alt) return false;
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_C: {
|
||||
String s = selectedText();
|
||||
if (s != null) actions.copy(s);
|
||||
return true;
|
||||
}
|
||||
case KeyEvent.KEYCODE_V:
|
||||
actions.paste();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_A:
|
||||
selectAll();
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_EQUALS:
|
||||
case KeyEvent.KEYCODE_PLUS:
|
||||
case KeyEvent.KEYCODE_NUMPAD_ADD:
|
||||
actions.zoom(1);
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MINUS:
|
||||
case KeyEvent.KEYCODE_NUMPAD_SUBTRACT:
|
||||
actions.zoom(-1);
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_0:
|
||||
case KeyEvent.KEYCODE_NUMPAD_0:
|
||||
actions.zoom(0);
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_PAGE_UP:
|
||||
if (e != null) scrollTranscript(-(e.mRows - 1));
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_PAGE_DOWN:
|
||||
if (e != null) scrollTranscript(e.mRows - 1);
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MOVE_HOME:
|
||||
if (e != null) scrollTranscript(-e.getScreen().getActiveTranscriptRows());
|
||||
return true;
|
||||
case KeyEvent.KEYCODE_MOVE_END:
|
||||
scrollTranscript(Integer.MAX_VALUE / 2);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyUp(int keyCode, KeyEvent ev) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && (ev.isFromSource(InputDevice.SOURCE_MOUSE) || !isVirtual(ev)))
|
||||
return true;
|
||||
return super.onKeyUp(keyCode, ev);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("deprecation")
|
||||
public boolean onKeyMultiple(int keyCode, int count, KeyEvent ev) {
|
||||
if (keyCode == KeyEvent.KEYCODE_UNKNOWN && ev.getCharacters() != null) {
|
||||
sendInput(ev.getCharacters());
|
||||
return true;
|
||||
}
|
||||
return super.onKeyMultiple(keyCode, count, ev);
|
||||
}
|
||||
|
||||
private static boolean isVirtual(KeyEvent ev) {
|
||||
InputDevice d = ev.getDevice();
|
||||
return d == null || d.isVirtual();
|
||||
}
|
||||
|
||||
// ---- IME
|
||||
|
||||
@Override
|
||||
public boolean onCheckIsTextEditor() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo out) {
|
||||
// A plain text field (not "visible password") so that Chinese IMEs keep their candidate UI.
|
||||
out.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
|
||||
out.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN | EditorInfo.IME_FLAG_NO_EXTRACT_UI
|
||||
| EditorInfo.IME_ACTION_NONE;
|
||||
return new BaseInputConnection(this, true) {
|
||||
@Override
|
||||
public boolean commitText(CharSequence text, int newCursorPosition) {
|
||||
super.commitText(text, newCursorPosition);
|
||||
flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean finishComposingText() {
|
||||
super.finishComposingText();
|
||||
flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteSurroundingText(int before, int after) {
|
||||
Editable ed = getEditable();
|
||||
if (ed == null || ed.length() == 0) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < before; i++) sb.append('\u007f');
|
||||
for (int i = 0; i < after; i++) sb.append("\033[3~");
|
||||
if (session != null && session.isConnected()) sendInput(sb.toString());
|
||||
return true;
|
||||
}
|
||||
return super.deleteSurroundingText(before, after);
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
Editable ed = getEditable();
|
||||
if (ed == null || ed.length() == 0) return;
|
||||
String s = ed.toString().replace('\n', '\r');
|
||||
ed.clear();
|
||||
if (session != null && !session.isConnected()) {
|
||||
if (s.contains("\r") && !session.isConnecting()) session.connect();
|
||||
return;
|
||||
}
|
||||
sendInput(s);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package xyz.whcself.whcshell;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
|
||||
import java.security.Security;
|
||||
|
||||
public final class WhcApp extends Application {
|
||||
|
||||
private TermSession session;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
// Android ships a trimmed "BC" provider without Ed25519/X25519; sshj needs the full one.
|
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME);
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 1);
|
||||
}
|
||||
|
||||
/** The session outlives the activity so that configuration changes never drop the connection. */
|
||||
public TermSession session() {
|
||||
if (session == null) session = new TermSession(this);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user