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:
WHC
2026-10-06 01:51:31 +08:00
commit a0023fd2ae
37 changed files with 7840 additions and 0 deletions
@@ -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;
}
}