# Android 客户端开发计划书 > 目标功能:屏幕浏览 + 操作控制(触控/键盘输入注入) > 参考实现:`linux/` 与 `macos/`,两者已验证的共用策略同样适用于 Android --- ## 一、代码复用分析 ### 1.1 可 100% 直接复用(NDK 无改动) | 文件 | 用途 | 依赖 | |------|------|------| | `common/ikcp.c/.h` | KCP 可靠 UDP 传输 | 纯 C,无 OS 依赖 | | `common/aes.c/.h` | AES 加密 | 纯 C | | `common/commands.h` | 协议定义(全平台共享) | 无 | | `common/client_auth_state.h` | 认证状态机 | 无 | | `common/posix_net_helpers.h` | POSIX socket 辅助函数 | POSIX(Android NDK 支持) | | `common/sub_conn_thread.h` | 子连接线程 | POSIX | | `common/rtt_estimator.h` | RTT 估计器 | 无 | | `common/logger.h` | 日志 | 无 | | `common/locker.h` | 互斥锁 | `std::mutex` | | `common/FileTransferV2.h` | V2 文件传输协议 | 无 | | `common/xxhash.h` | xxHash 校验 | 纯头文件 | | `client/IOCPClient.cpp/.h` | TCP/KCP 连接管理(Linux 已复用) | POSIX socket | | `client/Buffer.cpp/.h` | 数据缓冲区 | 无 | | `client/sign_shim_unix.cpp` | 签名垫片(Linux/macOS 已复用) | `libsign.a` | ### 1.2 可参考逻辑、重新实现 Android 版本 | 现有文件 | Android 对应实现 | 原因 | |----------|-----------------|------| | `linux/ScreenHandler.h` | `android/cpp/ScreenHandler.h` | 捕获 API 不同(MediaProjection vs X11) | | `macos/InputHandler.mm` | `ControlService.kt` + `main.cpp::DispatchControlEvent` | 注入方式不同(AccessibilityService vs CGEvent) | | `linux/SystemManager.h` | `android/cpp/SystemManager.cpp`(Phase 4) | 系统 API 不同 | | `macos/H264Encoder.mm` | Android MediaCodec 路径(Java 侧) | 硬件编码器 API 不同 | | `linux/main.cpp` | `android/cpp/main.cpp` | 参考连接逻辑,逐段移植 | ### 1.3 不复用(Windows 专属) - `client/ScreenCapturerDXGI.h`、`client/IOCPBase.h`、`client/ScreenSpy.cpp`(Windows GDI/DXGI) - `client/KeyboardManager.cpp`(SendInput API) - `client/KernelManager.cpp`、`client/ServicesManager.cpp` --- ## 二、技术选型 ### 2.1 屏幕捕获 **方案**:`MediaProjection` API(Android 5.0+) ``` MediaProjection └── VirtualDisplay (Surface) └── MediaCodec (Surface input,零拷贝) └── H.264 NALU → JNI → C++ ScreenHandler → 网络发送 ``` - 无需 root,官方公开 API - 通过 `ForegroundService` + `FOREGROUND_SERVICE_MEDIA_PROJECTION` 维持后台运行 - **实际采用零拷贝路径(Path A)**:VirtualDisplay 的 Surface 直接绑定 `MediaCodec` 输入 Surface,无 ImageReader 中间环节 ### 2.2 视频编码 **已采用**:Android `MediaCodec`(硬件 H.264 加速)✅ - VirtualDisplay Surface → MediaCodec Surface input → H.264 NALU,零拷贝 - 关键帧前自动拼接 SPS+PPS,确保解码器可初始化 - 每 2 秒强制触发一次 IDR(兼容忽略 `KEY_I_FRAME_INTERVAL` 的软编码器) - 编码分辨率:长边限制 1080,保持宽高比,宽高各自 2 对齐(H.264 要求) **编码流水线**: ``` VirtualDisplay → MediaCodec(Surface input) → CODEC_CONFIG(SPS/PPS) + IDR+P 帧 → onOutputBufferAvailable → JNI nativeOnH264Frame → C++ g_screenHandlers (broadcast) → TCP ``` ### 2.3 输入控制 **已采用**:`AccessibilityService`(`ControlService.kt`)✅ | 事件 | 映射 | | --- | --- | | `WM_LBUTTONDOWN/MOVE/UP` | 累积路径,UP 时判断:位移 < 10px → 单击(dispatchTap),否则 → 拖拽(dispatchTouchGesture) | | `WM_LBUTTONDBLCLK` | 双击:两次 80ms 手势,间隔 200ms | | `WM_RBUTTONDOWN` | 长按 600ms | | `WM_MOUSEWHEEL` | delta > 0(滚轮上)→ 手指下划 +400px;delta < 0 → 手指上划 -400px | | `WM_KEYDOWN` Backspace/ESC | `GLOBAL_ACTION_BACK` | | `WM_KEYDOWN` VK_HOME (0x24) | `GLOBAL_ACTION_HOME` | | `WM_KEYDOWN` VK_APPS (0x5D) | `GLOBAL_ACTION_RECENTS` | | `WM_KEYDOWN` PrtSc (0x2C) | `GLOBAL_ACTION_TAKE_SCREENSHOT` | **关键实现细节**: - 协议:`COMMAND_SCREEN_CONTROL` + MSG64(固定 48 字节),**坐标从 lParam(offset 24)读取**,与 Windows/Linux/macOS 客户端完全一致;不读 pt.x/pt.y(64-bit MSG 与 MSG64 的 pt 偏移不同,直接读会得到错误 Y 坐标) - 坐标映射:`phys = enc * physSize / encSize`(编码空间 → 物理屏幕空间) - 单击路径非退化:`moveTo(x,y); lineTo(x+1,y)`,避免 Android 静默拒绝零长度手势路径 - C++ → Kotlin 回调:`DispatchControlEvent()` 全局函数通过 JNI AttachCurrentThread → `ControlService.onControlEvent(@JvmStatic)` → post 到主线程 Handler ### 2.4 网络传输 与 Linux 完全相同的 POSIX socket 路径: - TCP 控制通道(`IOCPClient.cpp` 已在 Linux 验证) - KCP over UDP 视频流(`ikcp.c` 纯 C,NDK 直接编译,当前未启用) - TLS/AES 加密复用 `common/aes.c` --- ## 三、目录结构(实际) ``` android/ ├── PLAN.md # 本文件 ├── README.md # 编译与安装说明 ├── build_apk.sh # 编译脚本(release/debug/clean) ├── yama-release.jks # 签名密钥库(密码通过 YAMA_PWD 环境变量传入) ├── app/ │ ├── build.gradle │ ├── proguard-rules.pro # 保留 JNI 方法名 │ └── src/main/ │ ├── AndroidManifest.xml │ ├── java/com/yama/client/ │ │ ├── MainActivity.kt # 权限申请 + 启动 Service │ │ ├── CaptureService.kt # ForegroundService:MediaProjection + MediaCodec │ │ ├── ControlService.kt # AccessibilityService:手势注入 + 全局键 + 活动窗口上报 │ │ └── YamaBridge.kt # JNI 声明(nativeInit/Stop/SetScreenSize/OnH264Frame) │ ├── cpp/ │ │ ├── CMakeLists.txt │ │ ├── main.cpp # NDK 入口:连接/心跳/DataProcess/DispatchControlEvent │ │ ├── ScreenHandler.h # 子连接管理:发送 BitmapInfo/H264 帧,接收 SCREEN_CONTROL │ │ ├── android_compat.h # 平台兼容头(强制注入替代修改共用源文件) │ │ ├── common/ → ../../common/ # 符号链接(commands.h 等) │ │ ├── client/ → ../../client/ # 符号链接(IOCPClient 等) │ │ └── lib/ │ │ ├── arm64-v8a/libsign.a │ │ ├── arm64-v8a/libzstd.a │ │ ├── armeabi-v7a/libsign.a │ │ └── armeabi-v7a/libzstd.a │ └── res/ │ ├── mipmap-*/ic_launcher.png # 传统图标(各密度) │ ├── mipmap-*/ic_launcher_foreground.png # Adaptive Icon 前景层 │ ├── mipmap-anydpi-v26/ic_launcher.xml # Adaptive Icon 定义 │ ├── values/colors.xml # ic_launcher_background (#F2F2F2) │ ├── values/strings.xml │ └── xml/accessibility_service_config.xml ├── build.gradle (project-level) └── settings.gradle ``` > **注**:原计划中的 `InputHandler.cpp` 已合并至 `main.cpp`(`DispatchControlEvent` 全局函数)和 `ControlService.kt`;`SystemManager.cpp` 留待 Phase 4。 --- ## 四、开发阶段 ### Phase 0:工程初始化 ✅ 已完成 - 创建 Android Studio 项目,配置 NDK + CMake - `CMakeLists.txt` 引入 `common/`、`client/` 源文件(与 `linux/CMakeLists.txt` 同构) - 编译验证 `IOCPClient.cpp + Buffer.cpp + sign_shim_unix.cpp + ikcp.c` 在 NDK 下无错误 - `SimplePlugins/sign_lib/` 新增 `sha256_portable.h`(纯 C,零外部依赖)和 `build_android.sh`,在 WSL + NDK r27c 下成功交叉编译,产物已复制至 `android/app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/libsign.a` ### Phase 1:连接与协议 ✅ 已完成 - 移植 `linux/main.cpp` 的连接逻辑到 `android/cpp/main.cpp` - `CaptureService.kt` 通过 `YamaBridge.nativeInit()` 启动 C++ 网络线程 - 实现登录握手(`LOGIN_INFOR`)、心跳(`TOKEN_HEARTBEAT` + `CMD_HEARTBEAT_ACK` RTT 估算)、认证状态机(`client_auth_state.h`) - 设备信息上报:ANDROID_ID(XXH64 作为 ClientID)、型号、OS 版本、分辨率 - 心跳日志限流:60 秒最多打印一次,避免 logcat 刷屏 ### Phase 2:屏幕捕获与编码 ✅ 已完成 - `CaptureService.kt`:申请 `MediaProjection`,建立 `VirtualDisplay`,Surface 直连 `MediaCodec` - SPS/PPS 缓存,IDR 帧发送前自动拼接,子连接 `COMMAND_NEXT` 后开始推流 - `ScreenHandler.h`:发送 `TOKEN_BITMAPINFO`(含 `ScreenSettings.QualityLevel=H264`),`SendH264` 封装帧头 - 服务端 H264 解码器正常显示 Android 屏幕 ✅ - 服务端适配:`ScreenSpyDlg.cpp` 新增 `ComputeAdaptiveLayout()` 自适应缩放和信箱黑边,`m_offsetX/Y` 修正坐标映射(已合入 `main` 分支) ### Phase 3:操作控制 ✅ 已完成(branch: feature/android-remote-control) - `ControlService.kt`(`AccessibilityService`):手势注入、全局键、双击/长按/滚轮 - `main.cpp::DispatchControlEvent()`:JNI 反向调用,将 `COMMAND_SCREEN_CONTROL` 路由到 Kotlin - `ScreenHandler.h::OnReceive`:子连接也可接收 `COMMAND_SCREEN_CONTROL`,统一调用 `DispatchControlEvent` - 坐标 Bug 修复:64-bit MSG 与 MSG64 的 `pt.x/pt.y` 偏移不同(MSG: offset 36,MSG64: offset 40);改为从 lParam(两者均在 offset 24)提取坐标,与其他所有客户端保持一致 - 单击无效 Bug 修复:零长度手势路径被 Android 静默拒绝;tap 改用 `lineTo(x+1, y)` 非退化路径 - 代码审查修复:JNI `ExceptionClear()` ### Phase 3 后期修复 ✅ 已完成 发现并修复的生产问题: | 问题 | 根因 | 修复文件 | |------|------|---------- | | 静止屏幕首帧黑屏 10-60 秒 | SurfaceFlinger 空闲优化:无脏区时不向 VirtualDisplay 推帧,`REQUEST_SYNC_FRAME` 永远排队等不到输入帧 | `CaptureService.forceFirstFrame()`:VirtualDisplay 临时 resize +2px 触发强制合成;`main.cpp` 在 `SendBitmapInfo` 后调用 `ForceFirstFrameFromJava()` | | 部分硬件编码器 IDR 帧未被识别 | 高通等 SoC 对强制 IDR 不设置 `BUFFER_FLAG_KEY_FRAME`,`SendLoop` 将其当作 P 帧丢弃,首帧永远发不出 | `CaptureService.isNaluKeyframe()`:扫描 Annex-B NALU type 5/7/8 补充判断 | | H.264 推流在 `SendBitmapInfo` 前卡住 | 推送模式下服务端 `COMMAND_NEXT` 到达时 `setManagerCallBack` 尚未注册消息被丢弃,`m_started` 永远为 `false` | `ScreenHandler.h::SendBitmapInfo()` 末尾直接 `m_started=true; m_cond.notify_all()` | | 多用户不能同时观看/控制 | `g_screenHandler` 单指针 + `g_screenSpyRunning` 互斥锁,只允许一条子连接 | `main.cpp`:改为 `std::set g_screenHandlers`,`nativeOnH264Frame` 广播给全部 handler;每个 `COMMAND_SCREEN_SPY` 独立建立子连接,对齐 Windows/Linux/macOS 客户端行为 | ### Phase 4:系统信息与稳定性(进行中) **已完成:** | 功能 | 说明 | |------|------| | 活动窗口上报 | `ControlService` 监听 `TYPE_WINDOW_STATE_CHANGED`,通过 JNI `getActiveWindow()` 上报当前前台包名;锁屏时上报 `"Locked"` | | 心跳间隔下限 | Android 侧将服务端下发的 `ReportInterval` 强制限制为 `max(收到值, 30)`,防止 5s 高频心跳耗电 | | 服务端心跳超时延长 | `CheckHeartbeat()` 超时阈值从 `max(60, interval*3)` 改为 `max(120, interval*3)`,与 30s 心跳间隔留出 4 倍余量 | | CPU 频率上报 | `GetCpuMHz()` 优先读 `/sys/devices/system/cpu/*/cpufreq/cpuinfo_max_freq`,虚拟机无此节点时回退读 `/proc/cpuinfo` 的 `BogoMIPS` | | 后台保活 | `ForegroundService` + 持久通知(Phase 2 已完成),豁免 Doze 模式网络限制 | | 应用图标 | 自定义眼睛图标,支持 Android 8+ Adaptive Icon(前景层 + 浅灰背景),兼容各厂商形状裁切 | | Release 签名 | `yama-release.jks` 随仓库分发,密码通过 `YAMA_PWD` 环境变量或交互输入,保证多机一致签名 | **待完成:** - 处理 `MediaProjection` 被用户撤销的情况(弹出通知,引导重授权) - 连接断线自动重连后子连接同步重建(目前 `ScreenSpyThread` 有 20 次重试,但主连接重连后子连接未同步恢复) - `WM_MBUTTONDOWN`(中键)手势映射(目前静默丢弃) - `SystemManager` 封装:将 Java 层已上报的设备信息(型号、版本、IP)统一封装为独立模块 --- ## 五、关键约束与风险 | 风险 | 影响 | 状态 | |------|------|------| | ~~`libsign.a` 无 ARM 构建~~ | ~~Phase 0 阻塞~~ | ✅ 已解决:`sha256_portable.h` + `build_android.sh` | | Android 12+ `MediaProjection` 需每次重新申请 | 后台录屏被中断 | `ForegroundService` 保活 + 通知引导重授权 | | `AccessibilityService` 用户需手动开启 | 操作控制功能受限 | UI 引导流程,说明开启步骤 | | 某些厂商 ROM 限制后台 Service | 连接断开 | 电池优化白名单申请 | | H.264 Baseline Level 对齐 | 服务端解码兼容性 | `MediaCodec` 输出指定 `profile=Baseline`,与 x264 现有配置一致 | | `AccessibilityService` 无法向系统设置页面注入手势 | 设置界面无法远程操作 | Android 安全限制,无解,提示用户 | --- ## 六、代码审查注意事项 ### 最高原则:既有功能无破坏 增加 Android 客户端支持时,Windows/Linux/macOS 客户端的全部既有功能必须保持正常。每次改动上线前须验证: | 检查项 | 说明 | | --- | --- | | `isAndroidRemote` 初始值为 `false` | 非 Android 客户端走原有代码路径,不受影响 | | `updateUIForOrientation()` Android 分支有 `isAndroidRemote &&` 守卫 | 非 Android 客户端不会进入沉浸模式分支 | | `NotifyResolutionChange` 新参数有默认值 `= ""` | 现有调用点即便漏传也能正常编译和运行 | | `FindHostByClientID` 与 `FindHostByIP` 一致的指针返回约定 | ScreenSpyDlg 在 UI 线程(OnInitDialog)调用,OnReceiveComplete 在 IO 线程调用,后者读 `additonalInfo[]` 为只读操作,低概率竞争 | | Web 端坐标映射 `getTouchPos` 使用 `getBoundingClientRect()` | 动态取 canvas 实际显示尺寸,CSS 拉伸不影响坐标精度 | | Android 边缘手势检测有时间和距离双重门槛 | 普通慢速拖拽不会被误识别为系统手势 | ### 审查流程 1. `git diff HEAD` 逐文件审查,重点关注非 Android 代码路径的改动 2. 确认所有新增条件分支均以 `isAndroidRemote`、`client_type === 'APK'` 等 Android 专有标志守卫 3. 共享协议结构体(`commands.h`、`LOGIN_INFOR`、`szReserved`)改动需确认向前/向后兼容 4. 服务端 C++ 改动在 Windows 下编译,前端 `index.html` 在桌面和移动浏览器两端验证 --- ## 七、不在本期范围 - 音频监听(`AudioRecord` + Opus 编码) - 文件管理(已有 `FileTransferV2`,后期可直接接入) - 摄像头(前/后摄) - Shell 终端(`/system/bin/sh` + PTY,可参考 `PTYHandler.h`) - Root 特权功能(uinput、内核注入)