绿联NAS往极空间NAS备份分享

前言

需求很简单,将绿联 NAS 内的东西都备份到极空间内,我先尝试了将硬盘取下来放到极空间,挂载不上

然后就是通过 webdav/ftp/smb 等协议来备份,但是最后我开始尝试用 rsync,这样同步二者的时间戳可以保留,然后我更相信这个命令一点

经过之前的教训,我不相信绿联以及极空间任何 UI 上提供的功能

用 rsync 来完成 绿联 NAS 到极空间的备份,中途我在极空间用 FTP 从绿联备份了一些文件,用 rsync 应该能继续备份

就是中途用 FTP,感觉慢得不行,特别到了一些小文件的时候,感觉 rsync 要快很多

准备工作

绿联和极空间都需要开启 ssh

极空间是 ubuntu,绿联是 debian,绿联连清华源都没设置

绿联 apt install neofetch 需要执行 apt –fix-broken install

然后极空间默认更换了清华的源

极空间相关信息

ssh 登录后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 Welcome to ZOS (GNU/Linux 6.8.1-z4pro+-generic x86_64) 

ZZZZZZZ OOO SSSSS
ZZ O O SS
ZZ O O SSSS
ZZ O O SS
ZZ O O SS
ZZZZZZZ OOO SSSS
---------------------------------
System: "V1.0.0440020"
Service: "V1.0.0440102"
---------------------------------


The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.


The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.

Could not chdir to home directory /home/${phone}: No such file or directory
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

${phone}@Z4ProPlus-MEDO:/$

然后输入 sudo passwd 可以设置极空间 root 密码

随后可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
➜  ~ ssh -p 10000 root@192.168.31.64
root@192.168.31.64's password:


Welcome to ZOS (GNU/Linux 6.8.1-z4pro+-generic x86_64)

ZZZZZZZ OOO SSSSS
ZZ O O SS
ZZ O O SSSS
ZZ O O SS
ZZ O O SS
ZZZZZZZ OOO SSSS
---------------------------------
System: "V1.0.0440020"
Service: "V1.0.0440102"
---------------------------------

Last login: Sun Aug 17 05:27:33 2025 from 127.0.0.1
root@Z4ProPlus-MEDO:~#

apt 版本和源

1
2
3
4
root@Z4ProPlus-MEDO:~# apt -v
apt 2.8.3 (amd64)
root@Z4ProPlus-MEDO:~# cat /etc/apt/sources.list
deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu noble main

neofetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@Z4ProPlus-MEDO:~# neofetch
##### root@Z4ProPlus-MEDO
####### -------------------
##O#O## OS: zos x86_64
####### Kernel: 6.8.1-z4pro+-generic
########### Uptime: 3 days, 4 hours, 52 mins
############# Packages: 238 (dpkg)
############### Shell: bash 5.2.21
################ WM: Openbox
################# Theme: Yaru [GTK3]
##################### Icons: Yaru [GTK3]
##################### Terminal: /dev/pts/0
################# CPU: Intel N150 (4) @ 3.600GHz
GPU: Intel Graphics]
Memory: 1481MiB / 15771MiB
1
2
root@Z4ProPlus-MEDO:~# uname -a
Linux Z4ProPlus-MEDO 6.8.1-z4pro+-generic #13 SMP PREEMPT_DYNAMIC Thu Jun 5 15:03:23 CST 2025 x86_64 x86_64 x86_64 GNU/Linux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@Z4ProPlus-MEDO:~# hostnamectl
Static hostname: Z4ProPlus-MEDO
Icon name: computer-desktop
Chassis: desktop 🖥️
Machine ID: a251d735b2c44028b37bda6e9fddb2f7
Boot ID: de28c0eeb73e45608d22b5d1efe46031
Operating System: zos
Kernel: Linux 6.8.1-z4pro+-generic
Architecture: x86-64
Hardware Vendor: Default string
Hardware Model: Default string
Firmware Version: 5.27
Firmware Date: Thu 2025-04-10
Firmware Age: 4month 1w
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root@Z4ProPlus-MEDO:~# cat /etc/os-release
NAME="ZOS"
VERSION="GNU/Linux ZOS"
ID="zos"
ID_LIKE="debian"
PRETTY_NAME="zos"
VERSION_ID="zos"
ZOS_VERSION="V1.0.0440020"
root@Z4ProPlus-MEDO:~# lsb_release -a
No LSB modules are available.
Distributor ID: ZOS
Description: zos
Release: zos
Codename: n/a
root@Z4ProPlus-MEDO:~#

绿联 NAS 相关信息

绿联 ssh 登录后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜  ~ ssh nightmare@192.168.31.70
nightmare@192.168.31.70's password:
nightmare@DXP2800-JECT:~$ apt -v
apt 2.6.1 (amd64)
nightmare@DXP2800-JECT:~$ cat /etc/apt/sources.list
deb https://deb.debian.org/debian/ bookworm contrib main non-free non-free-firmware
deb-src https://deb.debian.org/debian/ bookworm contrib main non-free non-free-firmware
deb https://deb.debian.org/debian/ bookworm-updates contrib main non-free non-free-firmware
deb-src https://deb.debian.org/debian/ bookworm-updates contrib main non-free non-free-firmware
deb https://deb.debian.org/debian/ bookworm-proposed-updates contrib main non-free non-free-firmware
deb-src https://deb.debian.org/debian/ bookworm-proposed-updates contrib main non-free non-free-firmware
deb https://deb.debian.org/debian/ bookworm-backports contrib main non-free non-free-firmware
deb-src https://deb.debian.org/debian/ bookworm-backports contrib main non-free non-free-firmware
deb https://deb.debian.org/debian-security/ bookworm-security contrib main non-free non-free-firmware
deb-src https://deb.debian.org/debian-security/ bookworm-security contrib main non-free non-free-firmware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nightmare@DXP2800-JECT:~$ neofetch
_,met$$$$$gg. nightmare@DXP2800-JECT
,g$$$$$$$$$$$$$$$P. ----------------------
,g$$P" """Y$$.". OS: Debian GNU/Linux 12 (bookworm) x86_64
,$$P' `$$$. Host: DXP2800 EM_DXP2800_V1.0.25
',$$P ,ggs. `$$b: Kernel: 6.1.27
`d$$' ,$P"' . $$$ Uptime: 9 hours, 44 mins
$$P d$' , $$P Packages: 1225 (dpkg)
$$: $$. - ,d$$' Shell: bash 5.2.15
$$; Y$b._ _,d$P' Terminal: /dev/pts/0
Y$$. `.`"Y$$$$P"' CPU: Intel N100 (4) @ 3.400GHz
`$$b "-.__ GPU: Intel Alder Lake-N [UHD Graphics]
`Y$$ Memory: 1248MiB / 7684MiB
`Y$$.
`$$b.
`Y$$b.
`"Y$b._
`"""
1
2
3
4
5
6
7
8
9
10
11
nightmare@DXP2800-JECT:~$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
OS_VERSION=1.7.0.3125
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
nightmare@DXP2800-JECT:~$ lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
nightmare@DXP2800-JECT:~$ hostnamectl
Static hostname: DXP2800-JECT
Icon name: computer-desktop
Chassis: desktop 🖥️
Machine ID: 986ee82ffb1876a93eee9ea5bbeab5a5
Boot ID: 14170c1f5dee40c89519b3b4ef3e6926
Operating System: Debian GNU/Linux 12 (bookworm)
Kernel: Linux 6.1.27
Architecture: x86-64
Firmware Version: EM_DXP2800_V1.0.25
nightmare@DXP2800-JECT:~$ cat /etc/*release
cat /etc/issue
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
OS_VERSION=1.7.0.3125
UGOSPRO Linux \n \l
1
2
nightmare@DXP2800-JECT:~$ uname -a
Linux DXP2800-JECT 6.1.27 #36 SMP PREEMPT_DYNAMIC Mon Apr 14 23:38:49 CST 2025 x86_64 GNU/Linux

绿联 sudo passwd 设置 root 密码,然后执行 su 仍然进不到 root

开始尝试

找到极空间的磁盘路径

/data_s001/data/udata/real/${phone}

在极空间上执行

1
rsync -avhP nightmare@192.168.31.70:/home/nightmare/ /data_s001/data/udata/real/${phone}/rsync_test

尝试将绿联的文件往本地拉

总是跑不通,home/nightmare/ 文件夹是存在的

1
2
3
4
5
6
ug_start_server, check access user: 1000, group: 10
getuid(): 1000 geteuid(): 1000
login group is admin, set euid as root
cannot set euid as root
receiving incremental file list
rsync: [sender] change_dir "nightmare" (in home) failed: No such file or directory (2)

但是极空间是可以设置 root 密码的

最后方案

在绿联上

1
2
screen -S backup
rsync -avhP -e "ssh -p 10000" /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

用 screen 命令是为了断开 ssh 还能继续拷贝

TODO

ssh-copy-id nightmare@192.168.31.70 作用?

rsync 备份核心命令(绿联 → 极空间)

一些 QA

推送:
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

含义:
-a 等价 -rlptgoD,保留权限/时间戳/符号链接等
-v 显示传输文件
-h 人类可读大小
-P 显示进度并支持断点续传(含 –partial –progress)

加删除同步(镜像,谨慎):
rsync -avhP –delete -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

后台(screen):
screen -S backup
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/
Ctrl+A D 分离
screen -r backup 重新进入

后台(nohup 日志):
nohup rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/ > /home/nightmare/rsync_$(date +%Y%m%d_%H%M%S).log 2>&1 &

时间戳保证

-a 已包含 -t,完成后用 stat 对比:
stat /home/nightmare/某文件
stat /data_s001/data/udata/real/${phone}/rsync_test/某文件
Modify 行一致即成功。

若看到 failed to set times 说明目标权限或挂载阻止设置时间。

差异判定机制

默认判定:文件大小 + 修改时间
两者都相同直接跳过,不读取文件内容。
需要内容级别核对时临时用 –checksum(-c)。

一次性内容校验

首次全量同步完成后执行(不写入,仅验证,慢):
rsync -avhP -e “ssh -p 10000” –dry-run -ic /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/
如果没有出现以 >f 开头的文件行,表示内容一致。

之后日常继续用不带 -c 的命令(快):
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

快速差异演练(不做内容哈希)

rsync -avhP -e “ssh -p 10000” –dry-run -i /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

-i 与 -c 解释

-i 输出变化标志:

f.st…… 文件大小和时间变
f+++++++++ 新文件
f..t…… 仅时间不同

-c 基于内容校验和决定是否需要同步,需读取两端所有文件,慢。

组合 –dry-run -ic 用于严格一次性内容验证。

已传文件列表格式化

仅列出真正传输的文件(时间 + 标志 + 名字):
rsync -aP -e “ssh -p 10000” -i –out-format=’%t %i %n’ /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

写日志:
rsync -aP -e “ssh -p 10000” -i –out-format=’%t %i %n’ –log-file=/home/nightmare/rsync_transfer.log /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

排除示例

排除缓存与无意义文件:
rsync -avhP -e “ssh -p 10000” –exclude ‘.cache/‘ –exclude ‘.DS_Store’ /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

后台脚本

/home/nightmare/push_backup.sh:
#!/usr/bin/env bash
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

后台版本 /home/nightmare/push_backup_bg.sh:
#!/usr/bin/env bash
LOG=”/home/nightmare/rsync_$(date +%Y%m%d_%H%M%S).log”
echo “Log => $LOG”
nohup rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/ > “$LOG” 2>&1 &
echo “PID=$!”

校验抽样

stat /home/nightmare/Release.zip
stat /data_s001/data/udata/real/${phone}/rsync_test/Release.zip

何时使用 –delete

需要让目标与源完全一致(删除目标多余文件)时再加:
rsync -avhP –delete -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/
先用 –dry-run 演练:
rsync -avhP –dry-run –delete -i -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

典型流程总结

1 初次同步:
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

2 一次性内容验证:
rsync -avhP –dry-run -ic -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

3 日常增量:
rsync -avhP -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

4 定期快速检查:
rsync -avhP –dry-run -i -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

5 需要镜像:
rsync -avhP –delete -e “ssh -p 10000” /home/nightmare/ root@192.168.31.64:/data_s001/data/udata/real/${phone}/rsync_test/

NeoDesktop更新日志

0.0.21(2025.07.19)

注意: 2025.07.11 的版本重构了很多的底层,需要卸载重装才行

修复

  • 修复窗口左下角和右下角无法缩放窗口的问题(2025.07.19)
  • 修复触控板变得难用的问题(2025.07.18)
  • 修复返回事件无法下发到窗口内的问题(2025.07.11)
  • 修复触控板界面无法加载第二显示器上的应用的问题(2025.07.10)
  • 尝试修复软件列表无法获取的问题
  • 窗口全屏返回后应用大小没恢复的问题
  • 窗口关闭后台任务仍然存在的问题
  • 修复窗口更改大小/切换全屏状态都会闪一下的问题
  • ND 在后台任务只有一个的问题(有外接显示器的情况下)
  • 修复设置窗口无法全屏的问题

优化

  • 增加超级工作台(前台前调度)开关控制,支持全局开关和手动悬挂到屏幕左侧开关(2025.07.19)
  • 优化窗口无极缩放的交互与UI(2025.07.19)
  • 设置显示器页面增加显示器 id 展示(2025.07.12)
  • 优化手柄的支持,现在手柄事件会默认绑定到最前面的窗口,无需单独设置绑定,且向下支持到 Android 10(理论)(2025.07.11)
  • 优化手机竖屏时的桌面,不启用台前调度功能,横屏时才启用
  • 优化触控板界面抽屉 UI,设置5秒未操作自动息屏(2025.07.10)
  • 优化窗口操作响应速度(2025.07.07)
  • 优化设置界面 UI,保持圆角统一,优化背景图标过大的问题(@Joy Ink)(2025.07.07)
  • 优化日志页面,合并服务端日志客户端日志,增加日志复制按钮(后续反馈可以复制日志到群里)(2025.07.07)
  • 软件在 Neo 中切换前后台或切换层叠关系不会导致应用重载(例如 Moonlight 已经打开了串流画面,切换前后台,都不会让Moonlight回到上个页面)
  • 支持将输入设备直接绑定到某个窗口内(例如把鼠标直接绑定到 Moonlight/Termux-x11 等,可以将手柄直接绑定到 Switch 模拟器等)
  • 重新适配了应用全屏显示,可以隐藏状态栏和小白条/导航栏
  • 支持平板触控板的鼠标在多个显示器间切换,往屏幕右侧滑动即可,仅在小米平板 7pro 测试过
  • 优化设置页面UI,增加关于页面
  • 优化设置中的输入设备的 UI,增加一些设备标识(例如 mouse,joystick)
  • 优化窗口性能

新增

  • 台前调度支持按钮触发(2025.07.19)
  • 台前调度支持类 Mac 的切换风格,当前默认开启(2025.07.12)
  • 应用窗口支持更多的鼠标事件,支持触控笔,支持触控笔压感(2025.07.07)
  • 移除了之前着色器动画当背景的选项,增加了一些静态壁纸的选项(2025.07.07)

已知Bug
不是故意写出来了,最近在大量重构底层,不知道什么原因导致的,后续会修复

  • 服务几率会崩溃,需要重新激活
  • 启动会卡住一段时间,原因未知
  • 鼠标在小米15上会必然导致 ND 无响应,原因未知

0.0.19

修复

  • 修复 Dock 栏遮挡应用窗口的问题(上个版本修复的忘记写了)
  • 修复窗口后台后再打开白屏的问题
  • 修复任务台前调度后,点击Dock栏最小化,台前调度的区域会空出一格的问题

优化

  • 优化 Dock 栏的UI
  • 优化窗口在多个显示器的移动,会流畅一点
  • 台前调度列表支持上下滚动
  • 台前调度的任务恢复到普通状态增加了缩放动画
  • 移除了之前的神灯动画,先写了一个简单的缩放动画

新增

  • 新增输入设备的功能,。可以将当前查询到的外设绑定到第二个显示器上,作为鼠标在多个显示器无法移动的第二种实现方式

0.0.18

  • 点击底层应用的任务栏即可将窗口切换至顶层
  • 优化光标在多个屏幕间的移动逻辑,之前光标还未到达第二显示器最左侧就就切换到了第一个显示器,现在可以丝滑在多个显示器间移动

优化

  • 大幅优化触控板体验,支持触发长按事件,拖拽更加顺滑,现在逻辑为: 1.快速单机下发点按事件;2.长按可以下发长按事件(之前不行);3.长按后滑动可以下发拖拽事件,触发时间300ms;4.双指滑动触发滚动事件
  • 全屏后会隐藏Dock栏目
  • 单击窗口顶部任务栏即可将窗口切换至顶层
  • 打开新的应用优化为默认在顶层
  • 减少侧滑切换触控板的区域,防止误触

新增

  • 鼠标在多个显示器之间移动
  • 增加直接切换触控板的按钮
  • 触控板增加了一个小的菜单栏,点击面板图标可以展开,可以直接控制第二显示器的应用状态,点击应用图标会循环切换点击应用的窗口状态(全屏、普通、最小化,注:UI设计只是我随便写的,后面会改)
  • 台前调度又加回来了,做了不少优化,添加了动画,取消了类 Mac 的倾斜效果

修复

  • 修复鼠标滚轮无法在窗口生效的问题
  • 手势或ESC无法退出全屏的问题

注意

  • 当前版本有个测试中的功能,可以将窗口直接移动到第二显示器,但还有非常多的问题,勿反馈
  • 台前调度目前还不支持上下滚动

接下来的计划

  • 优化 GXDE 系统的镜像包,修复不能上网的问题,增加多种硬件加速的设置
  • 台前调度后续可以开关控制

0.1.16

  • 支持更简单的安装 Linux 容器

0.1.15

  • 终端支持多个 session
  • Linux 容器攻克成功,但是目前不能一键安装,正在尝试集成 GXDE 和 灵墨

0.1.14

  • 目前给 NeoDesktop 塞进了一个 termux 环境,但是在尝试启动 termux-x11 连接 proot-distro 的时候,始终不成功,这个如果能攻克的话,Neo 可以实现开箱即用的 Linux 图形环境,和 Windows 模拟器,可以接便携屏,玩儿真正的 PC 游戏,愿意一起研究的联系我QQ:906262255,注明来意
  • 触控板一直比较难用,目前很多手势会冲突,先移除了双指滑动的功能,改为双击再移动触发滑动
  • 修复窗口不能切换层叠关系的问题

0.1.13

最近在究极攻克 termux 和 termux-x11,准备直接集成到 NeoDesktop,后面也许就有开箱即用的 PC 软件,可以有开箱即用的 PC 游戏

  • 台前调度太难用,先移除,后面比较稳定了再放回来,并增加开关关闭的方式
  • 换了个包名,之前的可以卸载了
  • 全屏时隐藏顶部的任务栏,软件能够完全利用显示器
  • 全屏/普通窗口的切换为三指单机窗口,目前还没写触控板从全屏切换到普通窗口的事件
  • 国际化优化,现在激活页面不再是英文了
  • 注意:这个版本不激活会闪退,激活需要配合新版的 ADB KIT(还没发布),或者用旧版,进到对应设备的终端管理器,输入sh /storage/emulated/0/Android/data/com.nightmare.neo/files/start.sh来激活

0.1.11

  • 加回了台前调度的功能
  • 右上角展示当前时间
  • 优化任务栏 UI 样式,增加一些小动画
  • 增加任务最小化和最大化的神灯效果(目前比较卡,还伴随着闪屏)

目前任务无法调整过层级关系,等下一个版本

0.1.10

  • 准备复活
  • 支持切换背景
  • UI 优化

0.1.9

这个版本重新写了触控事件的下发方式,可能会存在异常触控失效的情况,但将触控向下支持到了 Android12

  • 修复安卓12窗口触控失效的问题
  • 更改核心组件的启动逻辑,现在随 App 启动,不再常驻进程,这样会减少没有启动 Neo 对设备的影响
  • 优化文件管理器图标
  • 优化3D视频播放的交互,现在支持整个 UI 镜像而不仅是字幕,点击左右屏幕均可操作
  • 优化淘宝、微信、爱奇艺等 App 强制竖屏导致方向便横屏的问题,不过启动后仍然会自动旋转一下
  • 支持先启动 App,后连接外接显示器,也能多屏异显
  • 字幕居中,对中英字幕友好

目前仍发现的问题:

  • 启动偶尔闪退,启动会先展示一下激活界面
  • 点击窗口内部暂不会交换窗口层级关系
  • 缩放窗口偶先窗口在左上角闪烁,原因未知

0.1.8

  • 修复返回失效的问题
  • 修复触控板双指无法下发返回事件的问题

0.1.7

  • 先暂时取消台前调度
  • 支持 3D 视频播放,用任务栏的文件管理器打开视频,需要准备同名的 srt 文件,播放后在任务栏切换为镜像字幕

0.1.6

  • 支持类苹果的台前调度

0.1.5

  • 支持下发返回事件到最上层的任务窗口
  • 支持窗口下的多指触控
  • 优化窗口无极缩放,现在缩放后窗口只需要100ms即可加载,之前需要200ms,且缩放更丝滑,缩放不再依赖创建新的虚拟显示器,可以解决部分窗口画面消失以及触控失效的问题
  • 修复部分情况缩放后界面消失的问题
  • 优化缩放固定窗口的算法,现在可以完美固定对角线的视图位置

0.1.3

  • 加宽呼出触控板,空间鼠标的区域
  • 支持创建桌面快捷方式,从应用列表拖拽到桌面即可
  • 支持空间鼠标(严重测试版),左侧上下滑动切换

0.1.2

  • 修复触控板失效的问题
  • 支持窗口的全屏和退出全屏

0.1.1

  • 尝试解决与 Shizuku 冲突的问题,经测试,现在 Shizuku 不会影响 Sula 了
  • 修复非桌面上的软件无法打开的问题
  • 移除无用设置
  • 默认使用混合模式加载窗口

0.1.0

[重要更新]

  • 真正的无极缩放: 厂商的无极缩放都是*
  • 更好的应用交互体验
  • 多窗口跳出 Sula 问题解决
  • 无需 Shizuku 强制依赖
  • 全新的窗口事件下发,更丝滑

0.0.6

[前言]
这个版本更新日志前面的一些碎碎念,在 Sula 收费以前,我希望我所有的开发都是自由的
大家也不应该以“这是我的执念”来绑架我

我会尽全力不让 Sula 很快就走向终点,后面还有非常多的事需要做,编写技术分享文章(其中包括画各种架构图),编写官网,国际化,录制B站,YouTube 视频等,这些都应该会对 Sula 有正向的输入,一旦有更多的人发现并使用这个软件,我自身的投入也会更有动力

目前人在上海,花了几天时间在酒店,实际上更改的代码远不止日志中的内容

[XREAL那事儿]
跟XREAL合作闹崩还没有结束,起初我就是想问他们要 Android API,可以实现调用后,将眼镜切换到 2D 120hz 的模式,这样 Sula 运行的软件也都有 120 hz,60 -> 120 的提升是非常明显的,但是后来他们不仅不愿意给,还以极高傲的态度,让此事成了最后的样子

后续我会发一个完整的视频,只是觉得,这些事应该常有发生,只是很多人因为很多原因,不敢说,不能说,我现在孑然一身,亲人就剩一个姐姐,没有什么朋友,死了便死了

[新版本建议]
目前仍然需要同时依赖 Sula Server 的激活和 Shizuku
我后面会想办法可以实现二选一即可

建议打开设置,将 PlatfromView 的模式切换到 hybridComposition

这个切换是做了本地存储的,下次启动仍然有效

这样虽然窗口层叠关系会有问题,但是性能是最好的

后续我会尝试解决这种模式下的窗口层叠问题

[更新日志]

  • feat: 空间鼠标勉强可用
  • feat: 使用 HybridComposition 支持本地存储
  • feat: 桌面默认软件增加 GameViewer、Parsec
  • feat: 设置增加更新日志界面
  • fix: 修复设置中的一些着色器模板无法展示的问题
  • fix: 设置中识别到 Sula 自身创建的 Display 的问题
  • fix: 有 Scrcpy 投屏或者其他虚拟显示器在的时候,Sula 光标无法控制的问题
  • fix: 应用全屏时右侧会有一个很窄的像素宽度不能铺满,会露出桌面的问题

后续会支持外接物理鼠标

0.0.5

  • 修复激活不能重启服务的问题

0.0.4

多页面跳转问题解决了,目前没有多余的精力开发一个引导页面

这个版本开始需要 Shizuku + Sula Server

后续只用一种方式即可

  • 增大左侧切换模式的区域,滑动时,会有区域提示
  • 支持 Shizuku 加强窗口模式

0.0.3

  • 修复 Sula Server 高占用的问题

0.0.2

  • 修复无法激活的问题

0.0.1

  • 重启该项目后的第一个版本

NeoDesktop上手教程

NeoDesktop 简介

NeoDesktop 致力于将锤子 TNT 或者三星 Dex 这种桌面模式变得通用,不限制品牌,不硬限制安卓版本

解决现在带 DP 输出视频设备仅能投屏的问题

也可以搭配投影仪,AR 眼镜使用

这是一个锤子 TNT 桌面模式的一张图

也是 NeoDesktop 的一个目标吧

这个软件的构思在2023年10月30,大概是小米14发布的时候,因为小米14支持 DP 输出

B站动态

在经过了比较多小版本的迭代,目前 ND 不用同时依赖自由激活和 Shizuku ,当前仅可通过自有服务激活

感谢 PzHown 提供的软件图标

Android 版本

经测试支持 Android 11 - 15

安卓12及以下,比较多的 App 会限制存在 ND 的窗口中,简单说就是比较不可用,但 Moonlight 这类应该没问题

下载地址

链接

激活: ADB KIT 或传统命令行二选一

推荐使用 ADB KIT 激活,比较方便,全平台都有,支持有线、无线、无线配对等方式,激活的辅助设备不管是 Windows、Linux、macOS,甚至是 Android,通过数据线,点几个按钮即可激活

激活前需要先启动一次软件

启动软件也有激活的引导

激活方式一: 通过 ADB KIT

下载 ADB KIT: https://nightmare.press/YanTool/resources/ADBTool/?C=N;O=A

1
adb shell sh /storage/emulated/0/Android/data/com.nightmare.sula/files/start.sh

1.有线连接设备

建议用有线连接设备,因为传统无线、无线配对这两种方式都还需要额外的学习

连接后点击对应的设备进入控制面板界面

2.激活 NeoDesktop
点击顶部的服务激活,点第一个按钮激活 Sula 即可完成激活,输出长这样即可

请勿使用晨钟酱的 ADB 工具箱的终端进行激活
由于晨钟酱的终端未使用 PTY,看不到实时的流,无法确认启动状态

激活方式二: 命令行(需要 ADB 环境)

能到这一步,我相信你已经有了一定的基础

首先需要启动一次 Sula,然后用 adb 命令启动服务

1
adb shell sh /storage/emulated/0/Android/data/com.nightmare.sula/files/start.sh

切换触控板

启动 NeoDesktop 的情况下,手指在左侧上下滑动可以切换触控板、空间鼠标

这里后续补一个 Gif

创建桌面快捷方式

其他

在这个软件诞生的过程中,我感受到来自 XREAL 产品以及运营的高傲,所以 NeoDesktop 不将视频 XREAL 的眼镜

看下XREAL的说法,他们已经把这定义为一种不可能

XREAL社区官方图文

“首先,在正常的厂商系统中,应用与应用之间的通讯与访问时被禁止的。这本身是为了防止应用之间篡改或者盗取数据而做的防护措施。但这个隔离系统所产生的问题就是——如果我们想要通过Nebula APP这个身份将另一个应用在AR空间中拉起,在厂商未授予高级系统权限的前提下是无法实现的。”

但我至少让这成为了可能,同时我不建议在 XREAL 眼镜中使用这个软件

一些 Q&A

1.为什么需要激活?
ND 的窗口管理,事件下发都是调用系统级别的 API 来完成的,普通的 App 是不具备找个权限的,而如果仅限制为 root 用户可用,则更高的加大了使用门槛,更何况现在主流的设备都不能解锁

所以采用了自有服务激活的方式,一次激活设备不重启,不插拔 USB,大概率是不会掉的,如果掉了借助 ADB KIT,也能快速的激活

上手记录(一)

NeoDesktop 依赖 Shizuku 代码,激活阻塞的问题

实际排查过程中,发现并不是阻塞了,而是报错了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

public static void register(ShizukuService shizukuService) {
sShizukuService = shizukuService;
LOGGER.i("register");
try {
LOGGER.i("invoke ActivityManagerApis.registerProcessObserver");
ActivityManagerApis.registerProcessObserver(new ProcessObserver());
LOGGER.i("invoke ActivityManagerApis.registerProcessObserver done");
} catch (Throwable tr) {
LOGGER.e(tr, "registerProcessObserver");
}

LOGGER.i("invoke ActivityManagerApis.registerProcessObserver done1");
if (Build.VERSION.SDK_INT >= 26) {
LOGGER.i("invoke ActivityManagerApis.registerProcessObserver done1.1");
LOGGER.i("invoke ActivityManagerApis.registerProcessObserver done1.1.2");
int flags = UID_OBSERVER_GONE | UID_OBSERVER_IDLE | UID_OBSERVER_ACTIVE;
LOGGER.i("invoke ActivityManagerApis.registerProcessObserver done2");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
flags |= UID_OBSERVER_CACHED;
}
try {
LOGGER.i("invoke ActivityManagerApis.registerUidObserver");
ActivityManagerApis.registerUidObserver(new UidObserver(), flags,
ActivityManagerHidden.PROCESS_STATE_UNKNOWN,
null);
LOGGER.i("invoke ActivityManagerApis.registerUidObserver done");
} catch (Throwable tr) {
LOGGER.e(tr, "registerUidObserver");
}
}
}

int flags = UID_OBSERVER_GONE | UID_OBSERVER_IDLE | UID_OBSERVER_ACTIVE;

这行代码就报错,原因是因为没有在 Android 工程中

subprojects {
plugins.withId(‘com.android.base’) {
plugins.apply(‘dev.rikka.tools.refine’)

    android {
        compileSdk = 33
        defaultConfig {
            minSdk = 23
            targetSdk = 33
        }
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
        buildFeatures {
            aidl true
        }
    }
}

}

Shizuku 介绍

在我个人的认知,需要解释 Shizuku 这个东西,需要从 root 的原理开始

在 Android 上,执行 su 命令并不是切换了当前的 uid,而是打开了一个阻塞的进程,新的进程是 root 权限,然后读取用户的输入,再将输入传递到 su 的子进程中

这样会比较慢,并且,一切的权限都只能提升命令行

而 Shizuku 的诞生,让 root 或者 adb 权限直接提升 Android 的代码变成了可能,这就是 Shizuku 所干的事

并且它的速度会比传统的 su 命令会更快

但经过比较大量的使用和集成,发现里面的魔法远比我想象的多

基础使用

导包

def shizuku_version = (the version above)
implementation “dev.rikka.shizuku:api:$shizuku_version”

// Add this line if you want to support Shizuku
implementation “dev.rikka.shizuku:provider:$shizuku_version”

这些都没什么说的,都是一些简单的操作,但是我并不准备从 demo 开始介绍它的魔法所在

因为 demo 本身的一些设计,让你很难找到其中关键的魔法代码

更魔法的 UserService 模式

在这种模式下,你的任何代码,不仅仅是获取某些 Services,甚至 C/C++ 代码,都可以被提权

这是最魔法的地方,我也正式因为排查问题,才一点一点,弄明白了一点原理

激活程序所干的事

Android API Server 开源分享

前言

好久不见,我是梦魇兽,距离我上一次写文章已经是3年前

这篇会当我回归技术社区的一个开篇吧

其实中间我还一直在自己的博客写文章,虽然频率不高

本人背书:

本科大二收到比较多大厂的面试邀请,大三下滴滴实习,大四上拿到正式 Offer,入职半年 D5 升 D6,毕业后正式工作了一年多后裸辞,前滴滴高级架构
(也许这也是一个噱头,但我会尽可能减少)

如果你是目前正在滴滴工作,可以在内网社区看到我离职的文章

这是一篇一样的在自己博客的文章 再见滴滴研发,你好梦魇兽

简单说,还是因为我感受到这个环境的不公,我厌恶这一切

其中很多想讽刺的内容,写不出来,也是因为所谓的不公

是速享、无界、ADB KIT、Code FA 等软件的开发者

现有开源代码大概在 10W 行+

目前裸辞已经半年多了

今天分享一个开发挺久的一个库,Android API Server
(跟随流量至上的原则,或许是这样的标题,比 Shizuku 还强?历经6年开发,吐血开源!)

背景

这是一个长期在自用的库,但后来发现它的应用越来越广,涉及到我的 FastShare、ADB KIT、Uncon 等

最近几天对整体架构进行了重构

起初这只是一个很小的库,在很早期我需要在 Flutter 侧获得 Android API 的一些信息时,需要编写大量的 MethodChannel

并且随着后来 ADB KIT、Uncon 等在功能上的需求,我需要在 PC 端也能获取到这些信息

并且我希望在使用上,不管是从 PC 端获取,还是安卓本地获取,都能保持高度一致

于是经过比较大量的 Scrcpy 和 Shizuku 这两个我最仰慕的开源项目的学习

才有了 Android API Server(AAS) 这样的解决方案

后来发现对这个库的需求越来越多,例如支持快速集成到我的速享、ADB KIT、Uncon 等

以及支持 Dex 模式等

在今天对整体架构重新进行了设计,正式作为一个对外的开源

也许听起来很复杂,没关系,我们后面会通过简单的示例代码来看一下他的功能

Android API Server 介绍

AAS 是一个为 Android 设备提供 RESTful API 的服务器。它基于 HTTP 协议,可以被任何支持 HTTP 的客户端访问。它设计轻量且易于使用,支持热插拔,你可以通过很简短的代码,来让 AAS 加载你自定义的插件

支持上层框架为 Web 或者 Flutter 或者其他任意不能直接访问 Java 的框架中使用

例如在 Flutter 中,我们几乎需要使用 MethodChannel 来访问安卓的 API

使用 MethodChannel 实现后,无法支持在 Flutter Web 中访问安卓的 MethodChannel

AAS 提供了封装好的开箱即用的 Flutter Plugin,或者你可以根据 API.md 实现任意语言编写的客户端

功能特性

  • RESTful: 通过 HTTP 协议,获取安卓 API 相关的信息
  • 插件化: 通过简单的代码编写,可实现自定义插件的支持
  • 内置 API: 内置开箱即用的在 Dex 中获取 Context、Services 的各种 API
  • 内置插件: 内置多个插件,例如获取应用列表、应用图标、创建虚拟显示器等
  • Flutter Plugin 支持: 只需要引入 Flutter 依赖,AAS 会随插件的注册而启动,在 Flutter 侧只需要调用 Dart API 即可
  • 多种模式支持: 支持 Activity Mode 与 Dex Mode
  • 安全: 有一个简单的鉴权,来防止端口扫描恶意调用

架构图

对上层的应用来说,只有 Address 和 Port 的感知,它不在乎对方是哪种模式运行的

你可以在任何地方,任何设备上,通过 HTTP 获取安卓的信息

由于 HTTP 并不安全,所以 AAS 内置了一个简单的接口鉴权,来防止端口扫描恶意调用

从示例代码来理解 AAS

这是 Flutter 编写的示例代码,展示了内置的一些插件和 API 的使用

基于 HTTP 的好处是,你可以通过这样的代码来获取一个 App 的图标(Flutter)

1
2
AASClient aasClient = AASClient();
Image.network(aasClient.iconUrl('com.nightmare'))

AASClient 是多实例,所有的 API 被封装到 AASClient 下

多实例可以让同一个页面加载不同设备的信息,例如 Uncon

获取所有应用信息代码

1
AppInfos infos = await getAllAppInfos(isSystemApp: false);

这看起来没什么,但我们可以将这个 Example 编译到 Mac 端

我们只需要更改端口号

1
AASClient aasClient = AASClient(port: Platform.isMacOS ? 15000 : null);

这就是在 API 使用上的一致性,我有各种同时运行在 App 内的页面,只需要更改端口号,就可以运行在 PC 端

当然 PC 端需要以 Dex Mode 启动 AAS

并且在这种模式下,我们可以有更多的 API 支持

可以获取到安卓的后台截图,这个功能应该是极少见到的

示例代码中包含了所有的 API 使用方法,示例代码是最好能理解 AAS 的方式

完整代码见 Flutter Example

启动模式介绍

通过这些例子你应该发现了,PC 端加载对应页面的时候,安卓端的服务是怎么启动的?

AAS 有两种启动模式

Activity Mode

这种情况下,AAS 拥有真实的 Activity Context,对于获取应用列表,同普通安卓本身访问 API一样,需要申请权限

Dex Mode

启动脚本在 build_and_run.sh

这种模式,会先将 java 编译成 class,再由 dx 或 d8 工具转换成 dex 文件

通过 adb 运行 app_process 启动 dex

这种模式带来的好处是,我们能使用的权限更多,例如获取后台任务缩略图,创建虚拟显示器(带 Group 的)

所有 java 的权限为 shell(uid 2000),你无需再为获取应用列表,创建虚拟显示器等单独申请权限

我们可以通过为连接到 PC 的设备启动这个服务,再通过 adb forward 获得通信的端口(auto.sh 中带有端口转发)

接下来,你仍然只需要像这样就获得 App的图标

1
2
AASClient aasClient = AASClient(port: port);
Image.network(aasClient.iconUrl('com.nightmare'))

你还可以自己实现各种各样的 API ,来获得远超 adb 命令行的功能,例如图标,后台应用截图,adb 命令本身不支持

在 Flutter 中使用

提供 android_api_server_client 来快速的让 Flutter App 拥有这个能力,无需手动启动服务,AAS 随 Flutter Plugin 注册而启动,直接创建 AASClient 则会使用 Flutter Plugin 中启动的端口

1
2
3
dependencies:
android_api_server_client:
git: https://github.com/nightmare-space/android_api_server_client

然后直接使用封装好的 Dart API

1
2
AASClient aasClient = AASClient();
AppInfos infos = await aasClient.getAppInfos();

如果你需要在 PC 上访问同样的接口,通过 Dex Mode,你只需要更改端口

1
2
AASClient aasClient = AASClient(port: 15000);
AppInfos infos = await aasClient.getAppInfos();

假如我目前有一个 Flutter 编写的展示应用列表的界面是这样

现在我想这个界面在 PC 上展示,亦或者在 Web 中展示

启动 Dex 后,我只需要修改端口号即可

1
AASClient aasClient = AASClient(port: Platform.isMacOS ? 15000 : null);

实际上,这样的模式已大量的在无界、速享、ADB KIT中使用

其中的文件管理器、应用列表、任务列表,都是完全的同一份代码,仅仅是端口号不一样

在原生安卓中使用

根据仓库的 Tag 版本,引入对应的依赖

1
implementation 'com.github.nightmare-space.android_api_server:aas_integrated:v0.1.27'

启动服务

1
2
3
4
5
6
7
8
AASIntegrate aasIntegrate = new AASIntegrate();
try {
int port = aasIntegrate.startServerFromActivity(context);
Log.d(TAG, "port -> " + port);
} catch (Exception e) {
Log.d(TAG, "error -> " + e);
e.printStackTrace();
}

所以通过这两种模式的了解,在 PC 端获取安卓的信息,通常需要 ADB KIT 或者 Uncon 中一样,需要处理 Dex 的启动流程

详见 ADB KIT

使用 AAS API 以及自定义插件

自定义一个插件

这是获取安卓后台快照的完整插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class ActivityTaskManagerPlugin extends AndroidAPIPlugin {
@Override
public String route() {
return "/task_thumbnail";
}

public Bitmap graphicBufferToBitmap(GraphicBuffer graphicBuffer) {
int width = graphicBuffer.getWidth();
int height = graphicBuffer.getHeight();
int format = graphicBuffer.getFormat();

Bitmap.Config config;
if (format == PixelFormat.RGBA_8888) {
config = Bitmap.Config.ARGB_8888;
} else {
throw new IllegalArgumentException("Unsupported format: " + format);
}

Bitmap bitmap = Bitmap.createBitmap(width, height, config);
graphicBuffer.lockCanvas().drawBitmap(bitmap, 0, 0, null);
graphicBuffer.unlockCanvasAndPost(graphicBuffer.lockCanvas());

return bitmap;
}


@Override
public NanoHTTPD.Response handle(NanoHTTPD.IHTTPSession session) {
String id = session.getParms().get("id");
L.d("id -> " + id);
byte[] bytes = null;
try {
long start = System.currentTimeMillis();
IActivityTaskManager activityTaskManager = ActivityTaskManager.getService();
ReflectionHelper.listAllObject(activityTaskManager);
Object snapshot = null;

// Android 12/Android 15
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.S || Build.VERSION.SDK_INT == 35) {
L.d("S or VANILLA_ICE_CREAM");
snapshot = ReflectionHelper.invokeHiddenMethod(activityTaskManager, "getTaskSnapshot", Integer.parseInt(id), false);
L.d("snapshot -> " + snapshot);
}
// Android 13/Android 14
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU || Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
L.d("TIRAMISU or UPSIDE_DOWN_CAKE");
snapshot = ReflectionHelper.invokeHiddenMethod(activityTaskManager, "getTaskSnapshot", Integer.parseInt(id), false, false);
L.d("snapshot -> " + snapshot);
}
Object hardBuffer = ReflectionHelper.getHiddenField(snapshot, "mSnapshot");
L.d("hardBuffer -> " + hardBuffer);
Object colorSpace = ReflectionHelper.getHiddenField(snapshot, "mColorSpace");
//
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
HardwareBuffer hardwareBuffer = (HardwareBuffer) hardBuffer;
Bitmap bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, (ColorSpace) colorSpace);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos);
bytes = baos.toByteArray();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
return newFixedLengthResponse(NanoHTTPD.Response.Status.OK, "image/png", new ByteArrayInputStream(bytes), bytes.length);
}
}

相关解释

  • route 方法返回的是这个插件的路由
  • handle 方法是这个插件的处理方法,这里是获取后台任务的缩略图,返回对象参考 NanoHTTPD.Response

如果是同一类插件,建议只实现一个 AndroidAPIPlugin
然后增加 param 来区分,例如 action

调用隐藏 API

1
Object result = ReflectionHelper.invokeHiddenMethod(Object object, String methodName, Object... args);

获取隐藏字段

1
Object result = ReflectionHelper.getHiddenField(object, "$name");

获取 Context

1
Context context = ContextStore.getContext();

解释一下为什么放在了单例里面,由于 AAS 有两种模式,两种模式下 Context 的获取方式是不一样的,Activity Mode 是直接储存的 Activity Context,Dex Mode 是通过反射获取的 Context

获取 Service

ServiceManager 来自 aas_hidden_api

1
ServiceManager.getService("activity_task");

例如我们要一个 PackageManager 的 Service

IPackageManager 来自 aas_hidden_api,PackageManager 是系统的 API

1
2
3
IPackageManager pms = IPackageManager.Stub.asInterface(ServiceManager.getService("package"));

PackageManager pm = ContextStore.getContext().getPackageManager();

这两种方法的选择要根据场景来,如果 IXManager 能实现需求,就用这种,因为在 Dex Mode 中,Context 是不完整的,但是例如 DisplayManager 这种,IDisplayManager 的 API 没有 DisplayManager 好用

更多获取方法详见 SystemServerApi

构建实例

1
AndroidAPIServer server = new AndroidAPIServer();

注册插件

1
server.registerPlugin(new PackageManagerPlugin());

AAS Intergated 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AASIntegrate {

static public void main(String[] args) {
AndroidAPIServer server = new AndroidAPIServer();
server.startServerForShell(args);
registerRoutes(server);
Looper.loop();
}

public static int startServerFromActivity(Context context) {
AndroidAPIServer server = new AndroidAPIServer();
int port = server.startServerFromActivity(context);
registerRoutes(server);
return port;
}

private static void registerRoutes(AndroidAPIServer server) {
server.registerPlugin(new PackageManagerPlugin());
server.registerPlugin(new ChangeDisplayHandler());
server.registerPlugin(new DisplayManagerPlugin());
server.registerPlugin(new ActivityManagerPlugin());
server.registerPlugin(new ActivityTaskManagerPlugin());
server.registerPlugin(new FilePlugin());
}

}

开源仓库介绍

  • ass: 这是框架本身,是一个壳,不带任何插件,你如果需要使用这样的模式,只需要依赖这个
  • aas_hidden_api:一个优雅的可以访问 Android 隐藏 API 的解决方案,通过 compileOnly 依赖到 aas_plugin 和 aas 中
  • aas_integrated: 一个集成了 aas_plugin 的库,如果需要现有的一些插件,直接用这个
  • aas_plugin: 实现了一些个人项目中会用到的插件,例如 ActivityManagerPlugin、DisplayMnagerPlugin 等

更多场景介绍

文件选择、应用选择(ADB KIT、Uncon、Fast Share)

启动器

可以在 PC 端启动安卓的 App,配合应用流转使用,可实现无需解锁手机,即可在 PC 上运行安卓上的软件

文件预览(Uncon)

视频极速缓冲播放,100G 的文件都能随意拉动进度条

无需安卓安装额外 App,仅需要开启 USB 调试

已有插件介绍

ActivityManagerPlugin

  • /activity_manager?action=start_activity: 打开一个 Activity
  • /activity_manager?action=stop_activity: 关闭 Activity
  • /activity_manager?action=get_app_activities&package=${package}: 获取 App 的 Activty
  • /activity_manager?action=get_app_detail&package=${package}: 获取 App 的详细信息
  • /activity_manager?action=get_all_app_info&is_system_app=false: 获取所有 App 信息
  • /activity_manager?action=get_tasks: 获取后台的 Task 信息
  • /activity_manager?action=app_main_activity&package=${package}: 获取 App 的 Main Activity

ActivityTaskManagerPlugin

  • /task_thumbnail?id=${id}: 获取 Task 的截图

DisplayManagerPlugin

  • /display_manager?action=get_displays: 获取所有的 Display 信息
  • /display_manager?action=create_virtual_display&width=1080&height=1920&dpi=320: 创建虚拟显示器

PackageManagerPlugin

  • /package_manager?action=get_permissions&package=${package}: 获取 App 的权限
  • /package_manager?action=get_icon&package=${package}: 获取 App 的图标

FilePlugin

  • /file?action=dir&path=\${dir_path}: 获取目录下的文件信息
  • /file?action=file&path=\${file_path}: 获取文件,会直接返回整个文件,用浏览器访问则会预览这个文件,如果是视频则支持极速缓冲播放,支持断点续传

最后

  • 没有考虑用 HTTPS,因为有类似于文件的服务,HTTPS 会让访问变慢,增加了一个简单的鉴权,来防止端口扫描恶意调用的情况
  • 一点小问题: 小可实力不济, gradle-8.0 + com.android.tools.build:gradle:8.1.0 组合发布 Jitpack 引入依赖后无法导包,这个组合发到 Jitpack 上,没有 aar,后来用 gradle-7.6.4 + com.android.tools.build:gradle:7.4.2 进行的发布

不要给我私信参加任何活动

不要给我任何狗屁创作激励,社区就是被这狗屁激励搞砸的

我也不知道这次回归,能持续多久,也许被某一条评论恶心到,然后再次离开?

世间的变数太多了

几句话与大家共勉

  • “偏信则暗,监听则明”
  • “道阻且长,行则将至”
  • “你看起来很厉害,但一事无成”

我曾一度讨厌任何变成以流量为目的社区

我们编写文章,记录开发历程,分享经验,变成了,我要如何制造噱头,如何编写更有噱头的标题

怎么改文章,才会有更多流量

包括社区各种各样的活动,无一不是在告诉你,你的文章的唯一目的,就是为了更多的流量

其他的技术本身,文章质量,都是狗屁

反驳拉黑

也有一些人说羡慕我现在的生活,但这都是取舍罢了

你想要什么

你准备好失去什么

时间往前,交换进行

世界可总是不公平的,也许你只得到了一点,但你会失去很多

循环往复

下次见

ROKID Staton2 ADB Crash 问题解决记录

很奇怪啊,adb 在 Rokid 上崩了

报错只有这个信息

fdsan: failed to exchange ownership of file descriptor: fd 11 is owned by unique_fd 0x76e2500458, was expected to be unowned

我个人对于这种有着及大量 C/C++ 代码的程序出现问题,基本就是用很土的方法

二分日志法

就是从 main 函数的开始和结尾打上一个日志,然后在中间的位置打上一个日志

看下崩溃的日志是在哪个区间,然后逐渐缩小范围

看一下加上日志后 crash 的输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Launching lib/main.dart on RG station2 in debug mode...
✓ Built build/app/outputs/flutter-apk/app-debug.apk
***
I/flutter (12482): [I] ADB polling error : Exception: * daemon not running; starting now at tcp:5037
I/flutter (12482): [I] * daemon started successfully
I/flutter (12482): [I] error: protocol fault (couldn't read status): Connection reset by peer
I/flutter (12482): [I]
D/ProfileInstaller(12482): Installing profile for com.nightmare.remote
I/flutter (12482): [I] ADB polling error : Exception: * daemon not running; starting now at tcp:5037
I/flutter (12482): [I] * daemon started successfully
I/flutter (12482): [I] error: protocol fault (couldn't read status): Connection reset by peer
I/flutter (12482): [I]
I/flutter (12482): [I] ADB polling error : Exception: * daemon not running; starting now at tcp:5037
I/flutter (12482): [I] ADB server didn't ACK
I/flutter (12482): [I] Full server startup log: /data/data/com.nightmare.remote/files/usr/bin/adb.10132.log
I/flutter (12482): [I] Server had pid: 12598
I/flutter (12482): [I] --- adb starting (pid 12598) ---
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:63 Android Debug Bridge version 1.0.41
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:63 Version 35.0.3-3.35.001-20241010-130201
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:63 Installed as /data/app/~~C4RvqyAAdViMpMm8iMumHA==/com.nightmare.remote-D61hRw9Vp3k2uSteY-mqyQ==/lib/arm64/libadb.so
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:63 Running on Linux 5.10.198-qki-consolidate-android12-9-gd70a73cbc90f (aarch64)
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:63
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:132 setup_daemon_logging invoke done atexit will be called
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:136 init_reconnect_handler will called
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:140 init_reconnect_handler invoke done
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb : main.cpp:143 init_mdns_transport_discovery invoke
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : task_runner.cpp:57 tasks_.emplace called now + delay -> 3369119 ms since epoch
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:145 init_mdns_transport_discovery invoke done
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:152 usb_init invoke
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12600 I adb : fdevent.cpp:276 9.1
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12600 I adb : fdevent.cpp:211 8.1.xx
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:154 usb_init invoke done
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:161 local_init invoke
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:163 local_init invoke done
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 E adb : usb_linux.cpp:143 find_usb_device bus_name-> /dev/bus/usb/003
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> .
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> ..
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : auth.cpp:419 adb_auth_init...
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:170 unix_open failed d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 E adb : usb_linux.cpp:143 find_usb_device bus_name-> /dev/bus/usb/002
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> .
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> ..
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> 002
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:170 unix_open failed d_name-> 002
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:170 unix_open failed d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 E adb : usb_linux.cpp:143 find_usb_device bus_name-> /dev/bus/usb/001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> .
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> ..
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:149 find_usb_device d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12601 I adb : usb_linux.cpp:170 unix_open failed d_name-> 001
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12602 I adb : fdevent.cpp:276 9.1
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12602 I adb : fdevent.cpp:211 8.1.xx
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:152 loaded new key from '/data/data/com.nightmare.remote/files/usr/bin/.android/adbkey' with fingerprint 1472BA4C908DFCB4B03AB6912355A3B83BBF5EF322142123E9D2D6C51CA55536
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:391 adb_auth_inotify_init...
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:393 1...
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:395 2... infd = 8
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:412 3
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : auth.cpp:436 4...
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : main.cpp:185 5
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : main.cpp:200 6
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : main.cpp:243 7
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : main.cpp:259 8
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:281 8.1
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:112 8.1.1
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:118 8.1.2
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:125 8.1.3
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:146 8.1.4
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:178 8.1.5
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent_epoll.cpp:197 8.1.6
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:155 8.1.6.1
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:157 8.1.6.2(fdevent 0: fd 5 R) got events 1
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:161 8.1.6.3
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:166 8.1.6.4
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:175 8.1.6.4.1
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:180 8.1.6.4.2
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:186 8.1.6.4.4
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:188 8.1.6.4.5
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:190 8.1.6.4.6
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : fdevent.cpp:207 Executing fn
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : task_runner.cpp:120 waitable_task called
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : task_runner.cpp:113 task called
I/flutter (12482): [I] fdsan: failed to exchange ownership of file descriptor: fd 7 is owned by unique_fd 0x7959de91b8, was expected to be unowned

起初我只定位到崩溃的地方在 fdevent.cpp:207,也就是还没有这两行日志

1
2
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb     : task_runner.cpp:120 waitable_task called
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : task_runner.cpp:113 task called

fdevent.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void fdevent_context::Run(std::function<void()> fn) {
auto wrapped_fn = [fn]() {
LOG(INFO) << "Executing fn";
// fdevent.cpp:207
fn();
};
{
LOG(INFO) << "8.1.xx ";
std::lock_guard<std::mutex> lock(run_queue_mutex_);
run_queue_.push_back(std::move(wrapped_fn));
}

Interrupt();
}

207 行调用了一个回调函数,姑且这么称它吧,这种就棘手一点,因为你不知道回调函数是谁

看下崩溃的链路,从入口函数到崩溃的整条链路如下

adb_server_main -> fdevent_loop -> fdevent_get_ambient()->Loop()
-> fdevent_context::HandleEvents -> FlushRunQueue

FlushRunQueue 函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

void fdevent_context::FlushRunQueue() {
// We need to be careful around reentrancy here, since a function we call can queue up another
// function.

LOG(INFO) << "8.1.6.4.1";
while (true) {
std::function<void()> fn;
{
std::lock_guard<std::mutex> lock(this->run_queue_mutex_);
LOG(INFO) << "8.1.6.4.2";
if (this->run_queue_.empty()) {
LOG(INFO) << "8.1.6.4.3";
break;
}
fn = std::move(this->run_queue_.front());
LOG(INFO) << "8.1.6.4.4";
this->run_queue_.pop_front();
LOG(INFO) << "8.1.6.4.5";
}
LOG(INFO) << "8.1.6.4.6";
fn();
LOG(INFO) << "8.1.6.4.7";
}
LOG(INFO) << "8.1.6.10";
}

看起来像是一个执行队列里面的任务的函数

而任务的注入是 fdevent_run_on_looper -> fdevent_get_ambient()->Run(std::move(fn))

所以这个时候的任务就是找到是哪个函数注入的这个任务(二分日志)

执行 fn 的时候崩了,是很难找打是哪儿注入的函数崩的,这个时候只有全局搜索 fdevent_run_on_looper 的调用,然后加日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void fdevent_context::Run(std::function<void()> fn) {
auto wrapped_fn = [fn]() {
LOG(INFO) << "Executing fn";
fn();
};
{
LOG(INFO) << "8.1.xx ";
std::lock_guard<std::mutex> lock(run_queue_mutex_);
run_queue_.push_back(std::move(wrapped_fn));
}

Interrupt();
}

void fdevent_run_on_looper(std::function<void()> fn) {
LOG(INFO) << "9.1";
fdevent_get_ambient()->Run(std::move(fn));
}

于是就有了这两行日志

1
2
3
4
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb     : task_runner.cpp:120 waitable_task called
I/flutter (12482): [I] 10-27 13:45:19.842 12598 12598 I adb : task_runner.cpp:113 task called
I/flutter (12482): [I] fdsan: failed to exchange ownership of file descriptor: fd 7 is owned by unique_fd 0x7959de91b8, was
expected to be unowned

最后崩溃函数是 task ,不难发现,无论是这个 task_runner 还是 event_poll ,都是 Event Loop,事件循环

也可称为生产消费模型?比较经典的事件队列,只要找到注入的地方就行

发现是 task_runner.cpp

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void AdbOspTaskRunner::TaskExecutorWorker() {
// ***
CHECK(!running_tasks.empty());
std::packaged_task<int()> waitable_task([&] {
for (Task& task : running_tasks) {
// task_runner.cpp:113
LOG(INFO) << "task called";
task();
}
return 0;
});

fdevent_run_on_looper([&]() {
// task_runner.cpp:120
LOG(INFO) << "waitable_task called";
waitable_task();
});
// ***

fdevent_run_on_looper -> waitable_task -> task

而这个 task 又是一个任务队列,又得找是哪儿注入的 task,执行的时候崩掉的

所以找注入 task 的地方的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void AdbOspTaskRunner::PostPackagedTask(Task task) {
PostPackagedTaskWithDelay(std::move(task), openscreen::Clock::duration::zero());
}

void AdbOspTaskRunner::PostPackagedTaskWithDelay(Task task, Clock::duration delay) {
auto now = std::chrono::steady_clock::now();
auto target_time = now + delay;
{
std::lock_guard<std::mutex> lock(mutex_);
auto target_time_ms = std::chrono::duration_cast<std::chrono::milliseconds>(target_time.time_since_epoch()).count();
LOG(INFO) << "tasks_.emplace called now + delay -> " << target_time_ms << " ms since epoch";
tasks_.emplace(now + delay, std::move(task));
}
cv_.notify_one();
}

仍然二分日志找调用

根据日志和代码

1
2
3
I/flutter (12482): [I] 10-27 13:45:19.840 12598 12598 I adb     : main.cpp:143 init_mdns_transport_discovery invoke 
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : task_runner.cpp:57 tasks_.emplace called now + delay -> 3369119 ms since epoch
I/flutter (12482): [I] 10-27 13:45:19.841 12598 12598 I adb : main.cpp:145 init_mdns_transport_discovery invoke done
1
2
3
4
5
6
7
8
int adb_server_main(/**/) {
/**/
if (!getenv("ADB_MDNS") || strcmp(getenv("ADB_MDNS"), "0") != 0) {
LOG(INFO) << "init_mdns_transport_discovery invoke ";
init_mdns_transport_discovery();
LOG(INFO) << "init_mdns_transport_discovery invoke done";
}
/**/

位于 init_mdns_transport_discovery() 函数中

具体不知道什么原因这个函数崩了,但 adb 的 mdns 在 rokid 上本来也没用上

根据源代码,定义一个环境变量 ADB_MDNS=0 就可以关闭这个功能

OVER

安卓切换刷新率探索(失败)

调研资料

https://www.youtube.com/watch?v=YSDBKqoL0O8

这个视频中的设备,是能够直接在系统中切换外接显示器的刷新率的,后续我的小米如果能 Root 的话,我也想研究下 AOSP 官方镜像(就是刷 ROM)支不支持切换
也就是系统层,其实是有入口可以切换刷新率的,这个入口指 Android 比较底层的 API

而国内厂商嘛,任何 Google 本身系统有的东西,当然是全砍了,不然怎么能让你知道用的他们的手机

部分调研源码

https://android.googlesource.com/platform/frameworks/base/+/master/services/core/java/com/android/server/display/DisplayManagerShellCommand.java

https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/services/core/java/com/android/server/display/DisplayManagerService.java

https://android.googlesource.com/platform/frameworks/base/+/master/services/core/java/com/android/server/display/LocalDisplayAdapter.java

https://android.googlesource.com/platform/frameworks/base.git/+/master/core/java/android/view/SurfaceControl.java

前言

最后调研时间为2024.10.01

中途每每发现一些名称上看着可行的函数,便又会开始一波又一波大安卓源代码的阅读

结局都已失败告终

安卓 15 原生可支持切换副屏刷新率和大小,好像也能移动鼠标

https://www.androidauthority.com/android-15-external-display-settings-3474503/

目前我已知的是红魔和努比亚的设备可以切换刷新率和分辨率,这俩其实是一家的,技术应该是公用的

据朋友下载 ROM 加一系列的反编译,大概推断出是对内核做了支持

为什么要做这个调研? 如果成功,意义又是什么?

国内现在支持 USB3 的设备已经开始变多了,基本有 USB3 就会有 DP 输出视频的功能

虽然硬件支持了,但各个厂商,包括小米,对这个功能的软件适配就是一坨屎

鼠标不能再多个显示器间移动,不能设置外接显示器的刷新率,更甚者,像小米,把一个 Activity 在外接显示器上启动的时候(am start -n com.xxx.xxx/.xxxActivity --display x),

还不能占满全屏,真的是狗屎,换其他的支持 DP 输出的设备都没有这个问题

所以如果能有基于 SHELL 的方式,更改外接显示器的刷新率,外接显示器可用性也会大大提高

60hz -> 120hz,这个提升是非常明显的,并且像小米14,无线串流跑满 1080p 120hz 还是没问题的

其中最显著的例子是手机直连 AR 眼镜,很多 AR 眼镜都是支持 120hz 的,但是接上手机都只有 60hz

基本每一家 AR 眼镜都有对应的 App,他们可以通过硬件协议,快速的调整 AR 眼镜的刷新率,分辨率,例如 2D 切换到 3D 等,但这些 API 他们不愿意开放给开发者,目前 ROKID 是唯一一个愿意开放的,但是他们的 API 也不支持切换刷新率

更狗屎的是,类似于 XREAL,不愿意给你提供接口就算了,对开发者还及其的不尊重,后面我必贴完整聊天记录

几种方式总结

1.App 普通权限模式下设置

第一种,就是不借助任何的 SHELL 权限,直接在应用内通过 Android 给的方式进行切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

void changeDisplay() {
WindowManager windowManager = getWindowManager();
Display display = windowManager.getDefaultDisplay();
float[] supportedRefreshRates = display.getSupportedRefreshRates();
for (float rate : supportedRefreshRates) {
Log.d(TAG, "Supported refresh rate: " + rate + "Hz");
}
float desiredRefreshRate = 120.00001f;
boolean isSupported = false;
for (float rate : supportedRefreshRates) {
if (rate == desiredRefreshRate) {
isSupported = true;
break;
}
}
if (isSupported) {
WindowManager.LayoutParams layoutParams = getWindow().getAttributes();
layoutParams.preferredRefreshRate = desiredRefreshRate;
getWindow().setAttributes(layoutParams);
Log.d(TAG, "Refresh rate set to: " + desiredRefreshRate + "Hz");
} else {
Log.d(TAG, "Desired refresh rate is not supported.");
}

}

包括设置 layoutParams.preferredDisplayModeId ,都没有效果

2.SHELL 下反射 DisplayManager/DisplayManagerGlobal

这两个类是可以直接反射出来的,并且一些调用链路是 DisplayManager -> DisplayManagerGlobal

主要函数
下面都是反射能看到的函数

DisplayManagerGlobal.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android.view.DisplayInfo getDisplayInfoLocked(int arg0)
float getBrightness(int arg0)
int[] getDisplayIds()
int[] getDisplayIds(boolean arg0)
android.view.DisplayInfo getDisplayInfo(int arg0)
android.view.Display getRealDisplay(int arg0)
int getRefreshRateSwitchingType()
android.view.Display$Mode getSystemPreferredDisplayMode(int arg0)
android.view.Display$Mode getUserPreferredDisplayMode(int arg0)
boolean shouldAlwaysRespectAppRequestedMode()
void setBrightness(int arg0,float arg1)
void setRefreshRateSwitchingType(int arg0)
void setShouldAlwaysRespectAppRequestedMode(boolean arg0)
void setUserPreferredDisplayMode(int arg0,android.view.Display$Mode arg1)
void setVirtualDisplaySurface(interface android.hardware.display.IVirtualDisplayCallback arg0,android.view.Surface arg1)

DisplayManager.java

1
2
3
4
5
6
7
8
9
10
android.view.Display getOrCreateDisplay(int arg0,boolean arg1)
float getBrightness(int arg0)
android.view.Display getDisplay(int arg0)
android.view.Display[] getDisplays()
android.view.Display[] getDisplays(String arg0)
android.view.Display$Mode getGlobalUserPreferredDisplayMode()
int getMatchContentFrameRateUserPreference()
void setGlobalUserPreferredDisplayMode(android.view.Display$Mode arg0)
void setShouldAlwaysRespectAppRequestedMode(boolean arg0)
void setRefreshRateSwitchingType(int arg0)

一些调用链路

1
2
3
4
5
6
DisplayManager.createVirtualDisplay -> DisplayManagerGlobal.createVirtualDisplay
DisplayManager.setBrightness -> DisplayManagerGlobal.setBrightness
DisplayManager.getBrightness -> DisplayManagerGlobal.getBrightness
DisplayManager.setGlobalUserPreferredDisplayMode -> DisplayManagerGlobal.setUserPreferredDisplayMode
DisplayManager.setRefreshRateSwitchingType -> DisplayManagerGlobal.setRefreshRateSwitchingType
DisplayManager.setShouldAlwaysRespectAppRequestedMode -> DisplayManagerGlobal.setShouldAlwaysRespectAppRequestedMode

然后我把跟切换刷新率有用的调了个遍,没有效果~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public static Object invokeMethod(Object object, String methodName, Object... args) {
try {
Class[] classes = new Class[args.length];
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof Integer) {
classes[i] = int.class;
} else if (args[i] instanceof Boolean) {
classes[i] = boolean.class;
} else {
classes[i] = args[i].getClass();
}
}
Method method = object.getClass().getDeclaredMethod(methodName, classes);
method.setAccessible(true);
return method.invoke(object, args);
} catch (Exception e) {
e.printStackTrace();

}
return null;
}
/** @noinspection DataFlowIssue*/
@SuppressLint("PrivateApi")
void tryChangeDisplayConfig() {
L.d("bindServer invoke");
Class<?> clazz = null;
try {
clazz = Class.forName("android.hardware.display.DisplayManagerGlobal");
@SuppressLint("DiscouragedPrivateApi") java.lang.reflect.Method getInstanceMethod = clazz.getDeclaredMethod("getInstance");
Object dmg = getInstanceMethod.invoke(null);
ReflectUtil.invokeMethod(dmg, "setRefreshRateSwitchingType", 2);
//getRefreshRateSwitchingType
int type = (int) ReflectUtil.invokeMethod(dmg, "getRefreshRateSwitchingType");
L.d("RefreshRateSwitchingType -> " + type);
//noinspection JavaReflectionMemberAccess
DisplayManager displayManager = DisplayManager.class.getDeclaredConstructor(Context.class).newInstance(FakeContext.get());
int matchContentFrameRateUserPreference = (int) ReflectUtil.invokeMethod(displayManager, "getMatchContentFrameRateUserPreference");
L.d("matchContentFrameRateUserPreference -> " + matchContentFrameRateUserPreference);
// getSystemPreferredDisplayMode
Object systemMode = ReflectUtil.invokeMethod(dmg, "getSystemPreferredDisplayMode", 0);
L.d("SystemPreferredDisplayMode -> " + systemMode);
// same with adb shell cmd display get-user-preferred-display-mode 0
Object userMode = ReflectUtil.invokeMethod(dmg, "getUserPreferredDisplayMode", 0);
L.d("UserPreferredDisplayMode -> " + userMode);
// getGlobalUserPreferredDisplayMode
Object globalUserMode = ReflectUtil.invokeMethod(displayManager, "getGlobalUserPreferredDisplayMode");
L.d("GlobalUserPreferredDisplayMode -> " + globalUserMode);
ReflectUtil.invokeMethod(dmg, "setShouldAlwaysRespectAppRequestedMode", true);
boolean should = (boolean) ReflectUtil.invokeMethod(dmg, "shouldAlwaysRespectAppRequestedMode");
L.d("shouldAlwaysRespectAppRequestedMode -> " + should);
for (Display display : displayManager.getDisplays()) {
if (display.getDisplayId() != 0) {
L.d("display -> " + display);
L.d("");
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
Display.Mode[] supportedModes = display.getSupportedModes();
for (Display.Mode mode : supportedModes) {
L.d("mode -> " + mode);
L.d("");
if (mode.getRefreshRate() > 60) {
L.d("set mode -> " + mode);
ReflectUtil.invokeMethod(dmg, "setUserPreferredDisplayMode", display.getDisplayId(), mode);
ReflectUtil.invokeMethod(displayManager, "setGlobalUserPreferredDisplayMode", mode);
}
}
}
}
}

} catch (ClassNotFoundException | InvocationTargetException | NoSuchMethodException |
IllegalAccessException | InstantiationException e) {
throw new RuntimeException(e);
}
}

3.SHELL 下反射 DisplayManagerService

安卓上有一系列的 CMD,例如执行 adb shell cmd display 会得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
➜  ~ adb shell cmd display
Display manager commands:
help
Print this help text.

set-brightness BRIGHTNESS
Sets the current brightness to BRIGHTNESS (a number between 0 and 1).
reset-brightness-configuration
Reset the brightness to its default configuration.
ab-logging-enable
Enable auto-brightness logging.
ab-logging-disable
Disable auto-brightness logging.
dwb-logging-enable
Enable display white-balance logging.
dwb-logging-disable
Disable display white-balance logging.
dmd-logging-enable
Enable display mode director logging.
dmd-logging-disable
Disable display mode director logging.
dwb-set-cct CCT
Sets the ambient color temperature override to CCT (use -1 to disable).
set-user-preferred-display-mode WIDTH HEIGHT REFRESH-RATE DISPLAY_ID (optional)
Sets the user preferred display mode which has fields WIDTH, HEIGHT and REFRESH-RATE. If DISPLAY_ID is passed, the mode change is applied to displaywith id = DISPLAY_ID, else mode change is applied globally.
clear-user-preferred-display-mode DISPLAY_ID (optional)
Clears the user preferred display mode. If DISPLAY_ID is passed, the mode is cleared for display with id = DISPLAY_ID, else mode is cleared globally.
get-user-preferred-display-mode DISPLAY_ID (optional)
Returns the user preferred display mode or null if no mode is set by user.If DISPLAY_ID is passed, the mode for display with id = DISPLAY_ID is returned, else global display mode is returned.
get-active-display-mode-at-start DISPLAY_ID
Returns the display mode which was found at boot time of display with id = DISPLAY_ID
set-match-content-frame-rate-pref PREFERENCE
Sets the match content frame rate preference as PREFERENCE
adb shell cmd display get-match-content-frame-rate-pref
get-match-content-frame-rate-pref
Returns the match content frame rate preference
set-user-disabled-hdr-types TYPES...
Sets the user disabled HDR types as TYPES
get-user-disabled-hdr-types
Returns the user disabled HDR types
get-displays [CATEGORY]
Returns the current displays. Can specify string category among
DisplayManager.DISPLAY_CATEGORY_*; must use the actual string value.
dock
Sets brightness to docked + idle screen brightness mode
undock
Sets brightness to active (normal) screen brightness mode
...more
➜ ~

而这个命令的实现,就是来自 DisplayManagerShellCommand.java

像 setMatchContentFrameRateUserPreference 的调用链路是

DisplayManagerShellCommand.setMatchContentFrameRateUserPreference -> DisplayManager.setMatchContentFrameRateUserPreference

而其他的一些设置,则来自 DisplayManagerService.java

DisplayManagerService 有一个构造函数

1
2
3
public DisplayManagerService(Context context) {
this(context, new Injector());
}

但是这个类是在系统框架里面的,也就是对应 ROM 中的 /system/framework/services.jar

需要这样拿实例

1
2
3
4
5
6
7
8
9
10
11
Class<?> classLoaderFactoryClass = Class.forName("com.android.internal.os.ClassLoaderFactory");
Method createClassLoaderMethod = classLoaderFactoryClass.getDeclaredMethod(
"createClassLoader", String.class, String.class, String.class,ClassLoader.class, int.class, boolean.class, String.class
);
ClassLoader classLoader = (ClassLoader) createClassLoaderMethod.invoke(null, "/system/framework/services.jar", null, null,
ClassLoader.getSystemClassLoader(), 0, true, null);
Method loadMethod = Runtime.class.getDeclaredMethod("loadLibrary0", Class.class, String.class);
loadMethod.setAccessible(true);
Class cl = classLoader.loadClass("com.android.server.display.DisplayManagerService");
loadMethod.invoke(Runtime.getRuntime(), cl, "android_servers");
Object dms = cl.getDeclaredConstructor(android.content.Context.class).newInstance(FakeContext.get());

FakeContext 是来自 Scrcpy 的代码

其中一些有用的函数

1
2
3
4
5
6
7
8
9
10
11
android.view.Display$Mode getActiveDisplayModeAtStart(int arg0)
com.android.server.display.DisplayDeviceInfo getDisplayDeviceInfoInternal(int arg0)
int getRefreshRateSwitchingTypeInternal()
android.view.Display$Mode getSystemPreferredDisplayModeInternal(int arg0)
android.view.Display$Mode getUserPreferredDisplayModeInternal(int arg0)
void setUserPreferredModeForDisplayLocked(int arg0,android.view.Display$Mode arg1)
void setVirtualDisplayStateInternal(interface android.os.IBinder arg0,boolean arg1)
void setVirtualDisplaySurfaceInternal(interface android.os.IBinder arg0,android.view.Surface arg1)
void setRefreshRateSwitchingTypeInternal(int arg0)
void setShouldAlwaysRespectAppRequestedModeInternal(boolean arg0)
void setUserPreferredDisplayModeInternal(int arg0,android.view.Display$Mode arg1)

然后我又掉了个通,无果~

4.SHELL 下反射 SurfaceControl.setBootDisplayMode

上一节发现 DisplayManagerService 有 getActiveDisplayModeAtStart 函数,
我们也可以使用 DisplayManagerShellCommand 的 get-active-display-mode-at-start

1
2
$ adb shell cmd display get-active-display-mode-at-start 2
Boot display mode: 1920 1080 60.000004

这个结果即为 AR 眼镜连接到手机时的分辨率和刷新率
如果能更改这个值,是不是也是可行的?

然后发现 SurfaceControl 有一个 setBootDisplayMode 函数

函数原型

1
2
3
4
5
6
7
public static void setBootDisplayMode(IBinder displayToken, int displayModeId) {
if (displayToken == null) {
throw new IllegalArgumentException("displayToken must not be null");
}

nativeSetBootDisplayMode(displayToken, displayModeId);
}

要两个东西,一个是 Token,一个是 ModeId

这里也有坑,Token 要从 DisplayControl.getPhysicalDisplayToken 获取

DisplayControl 也来自框架层,不过 scrcpy 中有现成的代码

而后者的 displayModeId 也并不是 DisplayInfo 中的 Display.Mode [] 数组中的 ModeId

先来看 Token 的获取吧,参考 scrcpy 的代码,这个 token 非常好获取

注意传入的 ID 是 DisplayControl.getPhysicalDisplayIds() 得到的 ID,普通的 Display ID 是 int,这个数据类型是 long

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static Method getGetPhysicalDisplayTokenMethod() throws NoSuchMethodException {
if (getPhysicalDisplayTokenMethod == null) {
getPhysicalDisplayTokenMethod = CLASS.getMethod("getPhysicalDisplayToken", long.class);
}
return getPhysicalDisplayTokenMethod;
}

public static IBinder getPhysicalDisplayToken(long physicalDisplayId) {
try {
Method method = getGetPhysicalDisplayTokenMethod();
return (IBinder) method.invoke(null, physicalDisplayId);
} catch (ReflectiveOperationException e) {
Ln.e("Could not invoke method", e);
return null;
}
}

问题在于 displayModeId

需要先获取 Display 的 DynamicDisplayInfo

SurfaceControl.java

1
2
3
public static DynamicDisplayInfo getDynamicDisplayInfo(long displayId) {
return nativeGetDynamicDisplayInfo(displayId);
}

DynamicDisplayInfo 的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static final class DynamicDisplayInfo {
public DisplayMode[] supportedDisplayModes;
public int activeDisplayModeId;
public float renderFrameRate;

public int[] supportedColorModes;
public int activeColorMode;

public Display.HdrCapabilities hdrCapabilities;

public boolean autoLowLatencyModeSupported;
public boolean gameContentTypeSupported;

public int preferredBootDisplayMode;

// 省略一些代码
@Override
public int hashCode() {
return Objects.hash(Arrays.hashCode(supportedDisplayModes), activeDisplayModeId,
renderFrameRate, activeColorMode, hdrCapabilities);
}
}

DynamicDisplayInfo 的 supportedDisplayModes 中的 DisplayMode 的 id,才是我们调用 setBootDisplayMode 需要的 ModeId

然后调用后,无果~

adb shell cmd display get-active-display-mode-at-start 2 的结果也没有变化

ROKID API 调研结果

我手上有雷鸟的 AR 眼镜,有 XREAL 的 AR 眼镜

这些眼镜厂商都有自己的安卓 App,然后配合眼镜内的固件

插个题外话,这两家厂商都不开放 Android SDK,硬生生的把自己的生态给堵死

他们从未想过如果开放 API,会有开发者借助 API 开发出各种各样的可以加强他们眼镜功能的 App

也不知道他们怎么想的吧,程序员永远理解不了产品经理

觉得自己有几百人的研发,能把自己的生态弄得很好

大概是制定一些协议,通过串口通信的方式,来控制眼镜

从表现上来说,当接上对应的眼镜进入 App 时,眼镜会自动切换为 3D 模式

这个就是 AR 的 SDK 完成的

我问过 XREAL 要过接口,因为他们只有一些 Unity 的 SDK,可很多功能又不需要引入 Unity,不愿意给态度还及其高傲,声称就算他的 SDK 是汇编写的,也会有人用

抛开 AR 眼镜本身来看,他们的生态就是一坨屎,Beam Pro 的设计也是一坨屎,最基本的功能都没做就发布了

闭关锁国,取死之道

Rokid 是跟我合作的,唯一一个愿意提供 Android SDK 的,但是整个过程也不是很顺畅

通过这个 SDK 可以控制 Rokid 的 AR 眼镜

这个时候,我其实是有点期望的,以为通过 SDK 可以直接修改刷新率

还是由于各种信息差导致的

SDK 给到我后

并不能更改到 2D 1080P 90/120 hz 的刷新率

后来他把他们的研发拉了进来

沟通上也不是很顺畅

并且从研发到研发的沟通,都还有很多信息差

细节后面补上

他们的接口是隐藏的

并且很多参数是枚举类,只有到最底层,才通过枚举的值来调用 JNI 的函数

SDK 导入到 Android 工程后,根本无法看到下面的接口

经过我对 SDK 的逆向,才写出了这些代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
RKGlassDevice mRKGlassDevice = RKGlassDevice.getInstance();
Class<?> rkGlassDeviceClass = mRKGlassDevice.getClass();
// 获取mGlassControl字段
Field mGlassControlField = rkGlassDeviceClass.getDeclaredField("mGlassControl");
mGlassControlField.setAccessible(true);
// 获取字段值
Object mGlassControl = mGlassControlField.get(mRKGlassDevice);
Log.d(TAG, "mGlassControl:" + mGlassControl);

Field mGlassSDKField = mGlassControl.getClass().getDeclaredField("mGlassSDK");
mGlassSDKField.setAccessible(true);
Object mGlassSDK = mGlassSDKField.get(mGlassControl);

// 反射出mGlassSDK的NativeHandle(long)
Field mNativeHandleField = mGlassControl.getClass().getDeclaredField("NativeHandle");
mNativeHandleField.setAccessible(true);
long mNativeHandle = mNativeHandleField.getLong(mGlassControl);
Log.d(TAG, "mNativeHandle" + mNativeHandle);

// 反射出mGlassSDK的GlassSetDisplayMode方法,返回boolean,参数是long,int
Method glassSetDisplayModeMethod = mGlassSDK.getClass().getDeclaredMethod("GlassSetDisplayMode", long.class, int.class);
glassSetDisplayModeMethod.setAccessible(true);
boolean result = (boolean) glassSetDisplayModeMethod.invoke(mGlassSDK, mNativeHandle, 3);
Log.d(TAG, "result" + result);

// 反射出mGlassSDK的GetDisplayMode方法,返回int,参数是long
Method getDisplayModeMethod = mGlassSDK.getClass().getDeclaredMethod("GetDisplayMode", long.class);
getDisplayModeMethod.setAccessible(true);
int displayMode = (int) getDisplayModeMethod.invoke(mGlassSDK, mNativeHandle);
Log.d(TAG, "displayMode" + displayMode);

通过一系列的反射,终于把这个函数给调通了,但是我再通过 Android API 获取 Display 的刷新率,并没有任何变化

只发现有一些值不一样
adb shell cmd display get-displays

1
2
3
4
5
# 调用接口前
Display id 11: DisplayInfo{\"HDMI 屏幕\", displayId 11, displayGroupId 0, FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_PRESENTATION, FLAG_TRUSTED, real 1920 x 1080, largest app 1920 x 1920, smallest app 964 x 964, appVsyncOff 1000000, presDeadline 16666666, mode 6560.000004, defaultMode 65, modes [{id=65, width=1920, height=1080, fps=60.000004, alternativeRefreshRates=[], supportedHdrTypes=[1, 2, 3]}], hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[1, 2, 3], mMaxLuminance=500.0, mMaxAverageLuminance=250.0, mMinLuminance=0.0}, userDisabledHdrTypes [], minimalPostProcessingSupported false, rotation 0, state ON, committedState ON}, DisplayMetrics{density=1.3312501, width=1920, height=964, scaledDensity=1.3312501, xdpi=304.8, ydpi=304.8}, isValid=true

# 调用接口后
Display id 11: DisplayInfo{\"HDMI 屏幕\", displayId 11, displayGroupId 0, FLAG_SECURE, FLAG_SUPPORTS_PROTECTED_BUFFERS, FLAG_PRESENTATION, FLAG_TRUSTED, real 1920 x 1080, largest app 1920 x 1920, smallest app 964 x 964, appVsyncOff 1000000, presDeadline 16666666, mode 6160.000004, defaultMode 61, modes [{id=61, width=1920, height=1080, fps=60.000004, alternativeRefreshRates=[120.00001], supportedHdrTypes=[1, 2, 3]}, {id=62, width=1920, height=1200, fps=120.00001, alternativeRefreshRates=[60.000004], supportedHdrTypes=[1, 2, 3]}, {id=63, width=1920, height=1200, fps=60.000004, alternativeRefreshRates=[120.00001], supportedHdrTypes=[1, 2, 3]}, {id=64, width=1920, height=1080, fps=120.00001, alternativeRefreshRates=[60.000004], supportedHdrTypes=[1, 2, 3]}], hdrCapabilities HdrCapabilities{mSupportedHdrTypes=[1, 2, 3], mMaxLuminance=500.0, mMaxAverageLuminance=250.0, mMinLuminance=0.0}, userDisabledHdrTypes [], minimalPostProcessingSupported false, rotation 0, state ON, committedState ON}, DisplayMetrics{density=1.3312501, width=1920, height=964, scaledDensity=1.3312501, xdpi=304.8, ydpi=274.32}, isValid=true

啊这,调用接口后,眼镜 DisplayInfo 中的 Mode 变多了,仅此而已

而 XREAL 的眼镜默认就有多种 Mode,我调用接口后得到了其他家眼镜默认的状态?

也就是说,这个接口并不能切换刷新率

折腾半天,得出一个结论

调用这个接口和长按眼镜的音量+键,效果一样

我直接 What,那最初是不是就应该告知我这个信息

而为什么调用接口又能设置为 3D 3840x1080 90hz 呢

因为对于 3D 模式,通过串口通信到眼镜内的固件后,这个眼镜就变成了一个只有 3D 模式的眼镜

AR 眼镜对手机还是 PC 来说,都只是一个普通的显示器

如果这个显示器只支持 3840x1080 90hz,你接上手机,它应该亮还是不亮

如果亮的话,眼镜的显示信息也就必然是 3840x1080 90hz

这个对于 PC 来说也是一样

所以安卓自然而然的切换到了 3D,并且有高刷

所以关键是,如果要实现通过 AR SDK 修改刷新率,硬件那边就将设备设置为只有某个刷新率的 Mode 即可,但是很显然,这个就得给他们加需求了

也不知道他们愿不愿意配合,因为大部分 AR 厂商的任何人,包括产品,研发,运营,都会觉得这些都是伪需求,自己的生态已经无敌了,几句沟通就要把同行踩个遍(指XREAL)

所以,这条路,估计也难走通

Termux API深度学习与Termux USB问题解决

需要先理解的一些上下文

termux 为了能在命令行中使用 am 命令,https://github.com/termux/termuxAm

因为 Android 上的 am 会限制只能在 shell 中使用,termux-am 就是一个通用版本

背景

termux 为了能让用户能在 terminal 中调用 Android API,开发了 termux-api

可以看下 termux-api-package 编译会生成的脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
termux-api-start.in            termux-media-player.in         termux-sms-inbox.in
termux-api-stop.in termux-media-scan.in termux-sms-list.in
termux-audio-info.in termux-microphone-record.in termux-sms-send.in
termux-battery-status.in termux-nfc.in termux-speech-to-text.in
termux-brightness.in termux-notification-channel.in termux-storage-get.in
termux-call-log.in termux-notification-list.in termux-telephony-call.in
termux-camera-info.in termux-notification-remove.in termux-telephony-cellinfo.in
termux-camera-photo.in termux-notification.in termux-telephony-deviceinfo.in
termux-clipboard-get.in termux-saf-create.in termux-toast.in
termux-clipboard-set.in termux-saf-dirs.in termux-torch.in
termux-contact-list.in termux-saf-ls.in termux-tts-engines.in
termux-dialog.in termux-saf-managedir.in termux-tts-speak.in
termux-download.in termux-saf-mkdir.in termux-usb.in
termux-fingerprint.in termux-saf-read.in termux-vibrate.in
termux-infrared-frequencies.in termux-saf-rm.in termux-volume.in
termux-infrared-transmit.in termux-saf-stat.in termux-wallpaper.in
termux-job-scheduler.in termux-saf-write.in termux-wifi-connectioninfo.in
termux-keystore.in termux-sensor.in termux-wifi-enable.in
termux-location.in termux-share.in termux-wifi-scaninfo.in

以 termux-toast 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!@TERMUX_PREFIX@/bin/bash
set -e -u

SCRIPTNAME=termux-toast
show_usage () {
echo "Usage: termux-toast [-b bgcolor] [-c color] [-g gravity] [-s] [text]"
echo "Show text in a Toast (a transient popup)."
echo "The toast text is either supplied as arguments or read from stdin"
echo "if no arguments are given. Arguments will take precedence over stdin."
echo "If toast text is not passed as arguments or with stdin, then there will"
echo "be a 3s delay."
echo " -h show this help"
echo " -b set background color (default: gray)"
echo " -c set text color (default: white)"
echo " -g set position of toast: [top, middle, or bottom] (default: middle)"
echo " -s only show the toast for a short while"
echo "NOTE: color can be a standard name (i.e. red) or 6 / 8 digit hex value (i.e. \"#FF0000\" or \"#FFFF0000\") where order is (AA)RRGGBB. Invalid color will revert to default value"
exit 0
}

PARAMS=""
while getopts :hsc:b:g: option
do
case "$option" in
h) show_usage;;
s) PARAMS+=" --ez short true";;
c) PARAMS+=" --es text_color $OPTARG";;
b) PARAMS+=" --es background $OPTARG";;
g) PARAMS+=" --es gravity $OPTARG";;
?) echo "$SCRIPTNAME: illegal option -$OPTARG"; exit 1;
esac
done
shift $((OPTIND-1))

CMD="@TERMUX_PREFIX@/libexec/termux-api Toast $PARAMS"

# If toast text was not passed as an argument, then attempt to read from STDIN with a 3s timeout
# Toast text arguments takes precedence over STDIN
if [ $# = 0 ]; then
set +e; IFS= read -t 3 -r -d '' TOAST_TEXT; set -e;
else
TOAST_TEXT="$*"
fi

# Trim trailing newlines
TOAST_TEXT="$(echo "$TOAST_TEXT")"

echo "$TOAST_TEXT" | $CMD

其中关键的调用是 CMD="@TERMUX_PREFIX@/libexec/termux-api Toast $PARAMS"

也就是所有的这一系列的脚本都会调用 termux-api 这个命令

一些命令

1
2
3
4
5
6
$ termux-usb -l
[
"/dev/bus/usb/001/002",
]
$ termux-usb -r /dev/bus/usb/001/002
Access granted.

一些更神奇的命令

1
2
$ termux-usb -e echo -r /dev/bus/usb/001/002
7

没错,这行命令运行后,会先弹出一个对话框,询问是否允许访问 USB 设备,用户按下是后,会在 terminal 中输出 7

这个7就是文件描述符,后续的任意进程拿到这个文件描述符,就能够读写了

对应的 java 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
private static int open(final @NonNull UsbDevice device, final Context context) {
final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
UsbDeviceConnection connection = usbManager.openDevice(device);
if (connection == null)
return -2;
int fd = connection.getFileDescriptor();
if (fd == -1) {
connection.close();
return -1;
}
openDevices.put(fd, connection);
return fd;
}

adb 不能在 Android 本地运行并且能够访问 OTG 的本质原因是,对 Linux/macOS/Windows 的平台而言,普通应用也是可以读取 USB 串口设备的,但是安卓上访问需要权限

而有了这种方式,我如果把上述的代码换成

1
$ termux-usb -e custom_adb -r /dev/bus/usb/001/002

交给一个特殊的 adb 一个文件描述符,adb 在 Andoird 本机运行且可以访问 OTG 就不是问题了

Uncon、ADB KIT 也是这样做的

研究 termux-api

https://github.com/termux/termux-api-package

里面包含了上述的脚本,还有两个 C 代码

termux-api-broadcast.c 和 termux-api.c

实际在 termux 终端中执行的 termux-api 二进制是由 termux-api-broadcast.c 编译而来

termux-api 有两种模式,一种是通过 broadcast 方式,一种是通过 socket 方式

整体架构设计图

接下来我们分析这两种模式

Socket

socket 方式需要先启动

App 启动 -> SocketListener.createSocketListener -> Bind Address

随即会监听 com.termux.api://listen 这个 unix socket

执行 termux-api 这个命令的时候,会将参数都传递到 socket 中

然后由 SocketListener 来解析参数,再发送一个广播到 TermuxApiReceiver

TermuxApiReceiver 会解析第一个参数以匹配需要调用的 API 类型,例如 Toast、USB

部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
switch (apiMethod) {
case "Clipboard":
ClipboardAPI.onReceive(this, context, intent);
break;
case "Dialog":
DialogAPI.onReceive(context, intent);
break;
case "Download":
DownloadAPI.onReceive(this, context, intent);
case "Notification":
NotificationAPI.onReceiveShowNotification(this, context, intent);
break;
case "SAF":
SAFAPI.onReceive(this, context, intent);
break;
case "Toast":
ToastAPI.onReceive(context, intent);
break;
case "Usb":
UsbAPI.onReceive(this, context, intent);
break;
case "Volume":
VolumeAPI.onReceive(this, context, intent);
break;
default:
Logger.logError(LOG_TAG, "Unrecognized 'api_method' extra: '" + apiMethod + "'");
}

此时数据也通过套接字回传到 termux-api

Broadcast

termux-api 直接调用 termux-am,然后直接发送广播到 TermuxApiReceiver,同时会告知 TermuxApiReceiver 往哪个 address 回传返回的数据,但是这个在 Android 14上失效了,不知道为啥
并且 termux-api 在代码中

1
2
3
if (android_get_device_api_level() < 34) {
listenfd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0);
}

也就是只有当 Android 版本小于14的时候,才会使用 socket 方式

大于等于 14 的时候,就会使用 broadcast 方式

而 boardcast 在 Android 14上又是失效的,这就是个大坑

termux 之所以不会遇到这个问题,是因为另一个究极无敌破坏性的改动,导致他们的 targetSdk 一直是 28,升上28以后,termux 就不能用了,所以 Termux 的 targetSdk 一直是 28,并且不能再上架 Google Play

解决

直接把这段 if 代码注释掉就行了,不知为何会加这个限制

1
2
3
if (android_get_device_api_level() < 34) {
listenfd = socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC, 0);
}

源码的注释是

1
2
3
4
5
6
7
8
9
// Try to connect over the listen socket first if running on Android `< 14`.
// On Android `>= 14`, if termux-api app process was started previously
// and it started the socket server, but later Android froze the
// process, the socket will still be connectable, but no response
// will be received until the app process is unfrozen agin and
// `read()` call below will hang indefinitely until that happens,
// so use legacy `am broadcast` command, which will also unfreeze
// the app process to deliver the intent.
// - https://github.com/termux/termux-api/issues/638#issuecomment-1813233924

第二个Android14的问题

这是在研究 Android Local ADB 的时候,遇到的问题

解决了上面的问题后,还有一个新的问题

先看 termux-usb 源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/system/bin/sh
# set -e -u

SCRIPTNAME=termux-usb
show_usage () {
echo "Usage: $SCRIPTNAME [-l | [-r] [-E] [-e command] [device | vendorId productId]]"
echo "List or access USB devices. Devices cannot be accessed directly,"
echo " only using $SCRIPTNAME."
echo " -l list available devices"
echo " -r show permission request dialog if necessary"
echo " -e command execute the specified command with a file descriptor"
echo " referring to the device as an argument (unless -E"
echo " argument is given)"
echo " -E transfer file descriptor as env var instead of as"
echo " command line argument"
exit 0
}

ACTION="permission"
PARAMS=""
while getopts :hlre:E option
do
case "$option" in
h) show_usage;;
l) ACTION="list";;
r) PARAMS="$PARAMS --ez request true";;
e) ACTION="open"; export TERMUX_CALLBACK="$OPTARG";;
E) export TERMUX_EXPORT_FD=true;;
?) echo "$SCRIPTNAME: illegal option -$OPTARG"; exit 1;
esac
done
shift $((OPTIND-1))

if [ "$ACTION" == "list" ]
then
if [ $# -gt 0 ]; then echo "$SCRIPTNAME: too many arguments"; exit 1; fi
else
if [ $# -lt 1 ]; then
echo "$SCRIPTNAME: missing -l or device path"
exit 1
elif [ $# -eq 1 ]; then
# The device's usbfs path has been provided
PARAMS="$PARAMS --es device $1"
elif [ $# -eq 2 ]; then
# A vendorId and ProductId of the device has been provided
PARAMS="$PARAMS --es vendorId $1"
PARAMS="$PARAMS --es productId $2"
else
echo "$SCRIPTNAME: too many arguments"
exit 1
fi
fi
echo "TERMUX_CALLBACK:$TERMUX_CALLBACK"
echo "TERMUX_EXPORT_FD:$TERMUX_EXPORT_FD"
CMD="termux-api Usb -a $ACTION $PARAMS"
termux-toast "CMD:$CMD"
echo "CMD:$CMD"

if [ "$ACTION" == "permission" ]
then
if [ "$($CMD)" == "yes" ]
then
echo "Access granted."
exit 0
else
echo "Access denied."
exit 1
fi
else
$CMD
fi

当我在 Android 14上运行 termux-usb -l 的时候,一切正常

但是把命令换成 termux-usb -r /dev/bus/usb/001/002 的时候,窗口能正常弹出,权限能正常授予,但是进程却没有返回结果

一直卡住,直到 Android 系统提示应用无响应,再等待一会,进程才返回

这就是第二个坑

还是 termux target sdk 是 28 的问题,他们不会遇到这个情况

关键问题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private static boolean requestPermission(final @NonNull UsbDevice device, final Context context) {
Looper.prepare();
Looper looper = Looper.myLooper();
final boolean[] result = new boolean[1];

final String ACTION_USB_PERMISSION = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".USB_PERMISSION";
final BroadcastReceiver usbReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context usbContext, final Intent usbIntent) {
String action = usbIntent.getAction();
if (ACTION_USB_PERMISSION.equals(action)) {
synchronized (this) {
UsbDevice device = usbIntent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
if (usbIntent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
if (device != null) {
result[0] = true;
if (looper != null) looper.quit();
}
} else {
result[0] = false;
if (looper != null) looper.quit();
}
}

}
}
};

final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
PendingIntent permissionIntent = PendingIntent.getBroadcast(context, 0,
new Intent(ACTION_USB_PERMISSION), 0);
IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION);
context.getApplicationContext().registerReceiver(usbReceiver, filter);
usbManager.requestPermission(device, permissionIntent);
Looper.loop();
return result[0];
}

按理说,PendingIntent 请求的弹出,我点击确认后,就会发出一个广播,这个内部的 usbReceiver 就能立马接收到,然后返回结果

但并不会

后来我尝试将 PendingIntent 的构造换成 PendingIntent.getActivity,授予权限后,Activity 马上就被打开了,通过 UsbManager.EXTRA_PERMISSION_GRANTED 字段,也可以知道权限是否被授予

但是整个流程就会有新的 Activity 打开

于是我再将 PendingIntent 的构造换成 PendingIntent.getService,这也是最后的解决方法

关键的修改代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

public class LocalService extends Service {
private final IBinder binder = new LocalBinder();
private ServiceCallback callback;

public interface ServiceCallback {
void onServiceStarted(boolean state);
}

public class LocalBinder extends Binder {
public LocalService getService() {
return LocalService.this;
}
}

@Override
public IBinder onBind(Intent intent) {
Logger.logError("LocalService", "onBind");
return binder;
}

@Override
public void onCreate() {
super.onCreate();
Logger.logError("LocalService", "Service Created");
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final String ACTION_USB_PERMISSION = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".USB_PERMISSION";
Logger.logError("LocalService", "Service Started");
String action = intent.getAction();
Logger.logInfo("action -> " + action);
if (ACTION_USB_PERMISSION.equals(action)) {
synchronized (this) {
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
if (device != null) {
callback.onServiceStarted(true);
}
} else {
callback.onServiceStarted(false);
}
}

}
return START_STICKY;
}

public void setCallback(ServiceCallback callback) {
this.callback = callback;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private static boolean requestPermission(final @NonNull UsbDevice device, final Context context) {
Looper.prepare();
Looper looper = Looper.myLooper();
final boolean[] result = new boolean[1];
Intent intent = new Intent(context, LocalService.class);
final String ACTION_USB_PERMISSION = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".USB_PERMISSION";
final ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName className, IBinder service) {
Logger.logInfo("onServiceConnected");
LocalService localService;
LocalService.LocalBinder binder = (LocalService.LocalBinder) service;
localService = binder.getService();
localService.setCallback(new LocalService.ServiceCallback() {
@Override
public void onServiceStarted(boolean state) {
result[0] = true;
if (looper != null) looper.quit();
Logger.logInfo(">onServiceStarted");
}
});
final UsbManager usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE);
intent.setAction(ACTION_USB_PERMISSION);
PendingIntent permissionIntent = PendingIntent.getService(context, 0, intent, 0);
usbManager.requestPermission(device, permissionIntent);
}

@Override
public void onServiceDisconnected(ComponentName arg0) {
}
};
Logger.logInfo("bindService");
context.getApplicationContext().bindService(intent, connection, Context.BIND_AUTO_CREATE);
Logger.logInfo("bindService done");
Looper.loop();
return result[0];
}

这个代码实现后,ADB KIT 和 Uncon 就能极快的获取到连接的 OTG 设备了

当然文中还有一个地方没有展开,就是那个 custom_adb,到底是如何设计的,也许会再有一篇文章来讲解

玲珑开发学习记录

个人介绍

梦魇兽,大家叫我小梦即可

  • 本科毕业入职滴滴半年升职,滴滴高级架构研发工程师
  • 两年绩效最优
  • 现裸辞,找寻生命的意义
  • 个人有比较多的软件,比如速享、无界、ADBKIT、Code FA,我个人软件涉及的技术栈远比我在公司所接触的多得多

所以我经常会说,技术本身会是各种过程中,最小的问题

再见滴滴研发,你好梦魇兽

这个博客记录我的一些想法和学习的一些资料

玲珑后台服务

需求场景是,控制风扇转速和设置 TDP 功耗都需要管理员权限,所以玲珑管家在编译的时候就有设置请求管理员权限
这是通过 cmakefile 来实现的

1
SET_TARGET_PROPERTIES(${BINARY_NAME} PROPERTIES LINK_FLAGS    "/MANIFESTUAC:\"level='requireAdministrator' uiAccess='false'\" /SUBSYSTEM:WINDOWS")

所以管理员权限是玲珑的一个必备条件,但是刚开机的时候,用户是没有空间给到玲珑管家管理员权限的

所以我编写一个玲珑服务

这个服务依赖 Windows 的任务计划,开机后不用进入系统,就能把服务启起来,并且能够带有管理员权限

这个目前在玲珑打开的时候就会执行,不用用户手动创建任务计划

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 获取脚本所在目录
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path

# 要启动的程序路径
$programPath = Join-Path $scriptDir "tiny_server.exe"

# 任务名称
$taskName = "TinyManagerAutoStart"

# 检查任务是否存在
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue

if ($task) {
# 如果任务存在,先删除任务
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
Write-Output "任务已删除。"
}

# 创建新的任务
$action = New-ScheduledTaskAction -Execute $programPath
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable

# 注册任务计划
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings
Write-Output "任务已创建并设置为开机启动。"

后台服务隐藏终端

两种方式

一种是默认配置 VS 工程,调用 FreeConsole 函数隐藏终端,再设置一个循环,让程序不退出

1
2
3
4
FreeConsole();
while (running) {
std::this_thread::sleep_for(std::chrono::seconds(10));
}

另一种是直接更改程序子系统为 /SUBSYSTEM:WINDOWS,这样就不会有终端出现,但是这种方案需要改代码入口

1
2
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
}

Windows 共享库

我尝试编写可以交叉编译的 Cmakefile,但是失败了,后面的编译都是通过 Visual Studio 来编译的

需要有些配置

共享库开发好,后面不管是使用任何的上层框架都可以方便的调用

两种方法,一种是设置宏,一种是 .def 文件

def

所以底层对外的输出应该是一个 dll 和一个 .h 文件

例如 dart,可以直接根据 .h 文件生成 dart 文件,可以很方便的调用 dll 中的函数

简单说就是,.h 文件维护好,我就能以很好的代码在各个地方调用

风扇控制

这个我得到的输入是一大坨 C# 代码和这样的文本消息,有点难理解,不过后面代码已经可以控制了,

1
2
3
4
5
6
7
8
9
ARB33
Fan 控制权:0xF02
写0x6C:手动控制转速
写0x00:自动转速

控制Fan :0x1809
写0~0xB8(0xB8对应100%)

读转速:0x0218~0x0219

https://github.com/GermanAizek/WinRing0

然后调研到更改内存的 WingRing0 库

最后经过好些折腾才写出了对应的 dart 代码,不仅是 dart,其他代码要实现控制风扇也比较麻烦

更优解是做成类似于 parsec 的驱动,需要控制风扇的程序根据驱动协议来调用

功耗设置

这个我没有得到可用的输入,自己找到一个是 ryzenadj-win64
对玲珑也是可用的

虚拟显示器

这个目前找到两个关键的库,一个是 parsec-vdd,一个是 Virtual-Display-Driver
https://github.com/nomi-san/parsec-vdd
https://github.com/itsmikethetech/Virtual-Display-Driver

后者我目前没有学会如何使用,所以目前都是用的 parsec-vdd

parsec-vdd 需要安装驱动,但是这个后面也可以自动安装

目前已经实现了开机不需要进入系统就能创建虚拟显示器。

串流软件相关

我个人 Moonlight+Sunshine,或者 Parsec 都是比较大量使用的,我其实不理解为什么 Sunshine 和 Moonlight 会有这么多的分支

但我后面会再详细了解一些这些原因,例如 Sunshine 有阿姨版本,为什么不能往官方提 PR,因为我自己就是一个极度热爱开源的人

者我通常用来玩儿游戏,后者我通常是非游戏需求,因为他支持剪切板共享

Parsec 使用成本其实相对要更低一点

并且我在玲珑上装了 Parsec 后,另一台电脑 A、B 或者 Android 装了 Parsec

不仅 A、B 可以控制玲珑

玲珑是可以直接控制电脑 A、B 的

所以一部分用户肯定是要使用 Parsec 的,并且使用 Moonlight 和 Parsec 的用户都要同等看待

不能直接说 Parsec 的用户就有解决问题的能力,玲珑管家都不适配 Parsec 了

关于玲珑的一点思考

也是我在使用玲珑的一个感受,我家里有 ITX 主机,平时 Mac 也是不离身的,也就是说,我出门,Mac 是一定会带的

那么玲珑对于我这类用户,就是一个比较尴尬的定位

但是经过一些时间,我发现了他的一些使用场景

就是作为串流机

也就是借助玲珑连接我家里的 ITX,这样我不管在哪儿,都能以家里的性能玩儿到游戏

为什么这个设备不是 Mac 呢,因为我手上测试过 5 款手柄,包括飞智,这些手柄都不支持有线连接 Mac

唯一一款支持有线 Mac 的是 Xbox 手柄,但是 Xbox 手柄有没有其他手柄的震感、陀螺仪等

有线可以让我有一个更低的延迟

所以我经常用玲珑去串流我家里的设备

不要再给用户发任何的demo了