App 里那张「SSH 终端」卡是我做得最上瘾的一页:在手机上跑 vim、top、htop,全屏程序正常刷新,汉字占满两列,切到后台会话也不掉。
这篇讲四个坑:为什么必须自己画一块终端、宽字符、软键盘把 PTY 抖出来的那一串尺寸、以及帮助卡片上那句当时是假话的话。
它是交互式 shell,不是命令回显框
最省事的做法是:发一行命令,把 stdout 当文本追加到一个 Text 上。这条路对 ls、cat、git log 好用,对 vim / top / htop / less 完全不行——它们要的是一块二维网格,靠 ANSI/VT 序列在网格上原地改写。
所以屏幕是一块自己画的终端画布(TerminalSurface),上面跑一份自己写的 ANSI/VT 解析器(TerminalScreen):
- xterm 256 色(
38;2;r;g;b的真彩降到 256 色立方,手机屏幕上看不出差别) - 粗体 / 斜体 / 下划线 / 反显
?1049/?1047/?47备用屏切换(全屏程序退出后原来的输出还在)- 滚出屏幕的行留在 2000 行回看缓冲里,手指往上拖能翻,翻上去右下角出现「↑ 回到最新」
resize要能反向上报给 PTY(不然vim认的是初次那几行几列)
协议层用的是 sshj:密码 / 私钥 / 主机密钥固定(TOFU)那一套。私钥支持 OpenSSH 新格式、PEM/PKCS8 和 PuTTY 的 PPK,带口令的要填口令;私钥得落一次盘给 sshj 读,那是 cacheDir 里 0600 的临时文件,读完立刻删。
汉字必须占两格,于是整块画布得严格等宽
vim 之类的程序按「一行 N 个字符」排版,而一个汉字的显示宽度是两个字符。如果按 Compose 正常的文本流去排,中文行会把后面所有列往右推半格,光标就飘到别的列上去了。
所以整块画布是逐格绘制的:每个单元格一个固定宽度,宽字符(CJK、emoji)占满两列并把右边那格标记为不可写。这件事交给 Compose 的排版做不到——这不是「不好看」,是坐标会错。
软键盘一开一合,vim 抖给你看
手机屏幕上最烦人的其实是键盘。软键盘的可见性一变,画布高度就变,于是行列数一路狂抖:41 → 42 → 39 → 28 → 25 → 24。每个中间值都往 PTY 报一次窗口尺寸,vim 就跟着重排六次,看着像坏了。
解法不是「少报一点」,而是等尺寸稳住 220ms 再报给 PTY:键盘开合整个过程只发一次最终窗口尺寸。
另一件事:量的坐标不能信整屏截图。这条按键条的位置是跟着键盘搬家的,adb shell input text 也不会把 %20 解码成空格(要发空格得走按键事件),这三个假象让我一度以为是终端在吞字符。
手机键盘上没有的键摆在屏幕底下那条按键条上:Esc、^C、^Z、^D、Tab、⌫、↵ 和四个方向键,「更多」那一层再给三十来个符号(竖线、波浪号、反斜杠、引号、美元符、星号那些)、十颗 Ctrl 组合、字号加减、粘贴。带子是横着滚的,不占终端地方。实体键盘(蓝牙、平板键盘、横屏不带软键盘)的方向键、退格、回车走那个 1dp 隐形输入框的按键回调,画布本身不吃焦点。
「退到后台都不会断」这句当时是假的
帮助卡片上我写了「会话挂在前台,切后台不断」。写完没测就发上去了。
实测:App 一退到后台,进程被系统冻结,socket 跟着被拆,二十秒左右回来就报 Software caused connection abort;而在前台空转一分钟什么都不掉。会话确实挂在 App 级的单例上——但单例活着不等于文件描述符活着。
补的是那套下载已经用过的东西:连上之后由 SshSessionService 把进程撑在前台优先级上。
foregroundServiceType用的是specialUse而不是dataSync:那一档从 Android 15 起要交代「同步的是哪份用户数据」,而这条会话是人正坐在终端前面的交互,说明写「保持一条用户主动开启的 SSH 会话」才是实话startForeground必须在开协程之前同步调用,系统只给这几秒;先startForegroundService再慢慢起通知,五秒后被系统掐掉- 服务只在真正连上之后才起。认证失败、连不上都不会留一条假通知
- 通知(
setSilent+setOngoing)写的是SSH 会话 · user@host:2222,点它回终端,上面一颗「断开」 - 服务里挂了一个
holding标记:SshRepository.close()收尾时会回头叫一次stop(),没有这个标记那次调用会把服务再起一遍、再 close 一遍,来回没完 - 另一个
sessionToken是防串场的:散场那段代码跑在 IO 线程上,可能晚于下一轮open()好几拍,认了这个号才允许改界面、清连接、停通知——否则上一条会话的收尾会把新会话一起拆掉
现在那句「切后台不断」是真的,验收方式是:连上 → 按 HOME → 等 60 秒 → 回 App 仍然「已连接」。
有意不做的几件
- 不传文件:上传下载走文件管理器那一页,或者服务器上自己敲
scp/rsync,App 里不再做一个 SFTP 客户端 - 没有端口转发和跳板机:要 tunnel 请用专业客户端
- 没有多标签:一次只有一条会话,再点「保存并连接」会先把上一条收掉
- 网络断了不替你重放:一条已经发出去的
rm在自动重连后再跑一遍是会被吓到的。断了就摊在终端最后一屏上面那张卡上(原来的输出还在下面看得见),给「重连 / 改配置 / 返回」三只手 - 回看缓冲 2000 行封顶,
yes那种刷屏只能往上翻一段
安全上只有两条,但都是硬的
密码和私钥只进系统密钥库的密文,界面上只有掩码;表单里「留空表示沿用已存的」就是字面意思,重连不用重填。
第一次连一台服务器要亲手核对指纹(TOFU):弹窗摊开 host:port、密钥类型和 SHA256:…,点了信任才记下来,往后每次自动核对。对上是硬拦,不是「要不要继续」——那意味着中间人或者服务器重装过系统,界面上给的话是「先去面板或机房确认,别急着点」。要主动换一把,去「改配置」最底下点「忘掉这台服务器」。
还有个小提醒写在了错误文案里:这里填的是 sshd 的地址(默认 22),不是面板那个端口。DNS、端口、被拒三种连不上分开说,不合成一句「连接失败」。
评论 0
还没有评论,来抢沙发