diff --git a/.gitignore b/.gitignore index 8f87dc6..e457359 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ server/go/web/assets/index.html server/go/users.json server/go/build/ server/go/.claude/settings.json +android/app/.cxx/ diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..7690429 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,21 @@ +# Build output +build/ +app/build/ + +# Gradle cache and generated configs +.gradle/ +gradle/gradle-daemon-jvm.properties + +# Local config (contains SDK path, machine-specific) +local.properties + +# Android Studio project files +.idea/ +*.iml + +# Signing config +*.jks +*.keystore + +# Force-track prebuilt static libs (overrides root .gitignore *.a rule) +!app/src/main/cpp/lib/**/*.a diff --git a/android/PLAN.md b/android/PLAN.md new file mode 100644 index 0000000..605cca0 --- /dev/null +++ b/android/PLAN.md @@ -0,0 +1,266 @@ +# 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、内核注入) diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..d3f164b --- /dev/null +++ b/android/README.md @@ -0,0 +1,172 @@ +# YAMA Android 客户端 + +YAMA 的 Android 客户端,允许服务端将 Android 设备作为受控主机进行远程查看和操控。功能包括: + +- 屏幕实时截图并推流给服务端 +- 接收服务端的触控/按键指令并注入到系统 +- 后台保持心跳连接,支持长期驻留 + +--- + +## 编译前:配置服务端地址 + +服务端 IP 和端口在源码中硬编码,编译前必须修改: + +文件:`app/src/main/java/com/yama/client/MainActivity.kt` + +```kotlin +private val serverIp = "91.99.165.207" // 改为你的服务端 IP +private val serverPort = 443 // 改为你的服务端端口 +``` + +改完再执行编译,否则客户端连不上服务端。 + +--- + +## 编译环境 + +在 **WSL(Ubuntu)或原生 Linux** 上编译,Windows 原生环境不支持。 + +| 依赖 | 版本要求 | 安装命令 | +|---|---|---| +| Java | 17+ | `sudo apt install openjdk-17-jdk` | +| CMake | 3.22+ | `sudo apt install cmake` | +| Ninja | 任意 | `sudo apt install ninja-build` | +| Android SDK | 含 build-tools | 见下方说明 | +| Android NDK | 30.x(Linux 版) | 见下方说明 | + +### Android SDK + +推荐通过 Android Studio 安装,安装后 SDK 默认在: + +- Windows:`%USERPROFILE%\AppData\Local\Android\Sdk` +- Linux:`~/Android/Sdk` + +WSL 环境下脚本会自动检测 Windows 侧的 SDK,也可以手动指定: + +```bash +export ANDROID_HOME=/mnt/c/Users/<用户名>/AppData/Local/Android/Sdk +``` + +### Android NDK(Linux 版) + +> **重要**:必须使用 **Linux 版 NDK**,Windows NDK 的 `.exe` 工具无法在 WSL 内执行。 + +```bash +# 下载 Linux NDK(约 600 MB) +wget https://dl.google.com/android/repository/android-ndk-r30b-linux.zip +unzip android-ndk-r30b-linux.zip -d $HOME + +# 解压目录名可能带字母后缀(r30、r30b 等),统一重命名 +mv $HOME/android-ndk-r30* $HOME/android-ndk +``` + +脚本会自动检测 `~/android-ndk`,也可以通过环境变量指定: + +```bash +export ANDROID_NDK_HOME=$HOME/android-ndk +``` + +--- + +## 签名密钥(一次性准备) + +Release APK 需要签名密钥。密钥库文件 `yama-release.jks` 已在仓库中,**密码需向仓库维护者获取**,或通过以下方式传入: + +```bash +# 方式一:环境变量(推荐,避免每次输入) +export YAMA_PWD=密钥库密码 + +# 方式二:编译时脚本会交互提示输入 +``` + +如需重新生成密钥库(密码遗失等情况): + +```bash +cd android/ +keytool -genkeypair -keystore yama-release.jks \ + -alias yama -keyalg RSA -keysize 2048 -validity 10000 \ + -dname 'CN=YAMA,O=Internal,C=CN' +``` + +> 密钥库文件本身不含私密信息,可以提交到 git;密码不要提交。 +> 重新生成密钥库后,已安装旧版本的设备需要先卸载再安装。 + +--- + +## 编译 + +在 WSL 或 Linux 终端中,进入 `android/` 目录: + +```bash +cd android/ + +# 编译 Release(默认,约 1.7 MB,推荐部署用) +./build_apk.sh + +# 编译 Debug(约 4 MB,含调试符号,开发排查用) +./build_apk.sh debug + +# 清理所有编译产物(切换配置或遇到奇怪错误时使用) +./build_apk.sh clean +``` + +编译成功后产物: + +| 类型 | 路径 | +|---|---| +| Release | `ghost.apk` | +| Debug | `ghost-debug.apk` | + +--- + +## 安装到设备 + +### 1. 传输 APK + +将 `ghost.apk` 拷贝到手机(USB 传输、微信文件传输等均可)。 + +### 2. 安装 + +在手机上用文件管理器找到 APK,点击安装。 + +如提示"禁止安装未知来源应用",需先开启允许: + +- **Android 8+**:设置 → 应用 → 找到你使用的文件管理器 → 允许安装未知应用 +- **MIUI/ColorOS 等**:设置 → 隐私/安全 → 安装未知应用 + +### 3. 解除受限设置(Android 13+ 必须) + +Android 13 及以上系统对手动安装的 APK 有额外限制,若不解除,无法授权 Accessibility Service: + +1. 打开 **设置 → 应用 → YAMA** +2. 点击右上角 **三点菜单** +3. 选择 **"允许受限设置"**(Allow restricted settings) +4. 使用 PIN 或指纹确认 + +> 此步骤每次重新安装 APK 后都需要重复执行一次。 + +### 4. 授权必要权限 + +**Accessibility Service**(必须,用于注入触控/按键) + +设置 → 无障碍 → 已安装的应用 → YAMA → 开启 + +> 重装 APK 后此权限会被重置,需重新授权。 + +### 5. 启动 + +点击桌面 YAMA 图标启动,系统会弹出屏幕录制确认框,点击 **"立即开始"** 授权。 + +授权后应用进入后台运行,通知栏会显示一条持久通知表示服务正在运行。此后服务端即可在主机列表中看到该设备。 + +> 屏幕录制确认在每次应用重启后都会弹出,这是 Android 系统的强制要求,无法跳过。 + +--- + +## 注意事项 + +- 建议在电池优化设置中将 YAMA 设为 **"不限制"**(或"不优化"),防止系统在后台将其杀掉 + - 路径:设置 → 应用 → YAMA → 电池 → 不限制 +- 心跳间隔最小 30 秒,长期后台运行耗电量与微信后台相当 +- 服务端可在设置中调大上报间隔(建议不超过 60 秒)以进一步降低耗电 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..58c892c --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.yama.client' + compileSdk 35 + ndkVersion "30.0.14904198" + + defaultConfig { + applicationId "com.yama.client" + minSdk 21 + targetSdk 35 + versionCode 1 + versionName "1.0" + + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a' + } + + externalNativeBuild { + cmake { + cppFlags '-std=c++17' + arguments '-DANDROID_STL=c++_static' + } + } + } + + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + version "3.22.1" + } + } + + signingConfigs { + release { + storeFile file("../yama-release.jks") + storePassword System.getenv("YAMA_PWD") ?: "" + keyAlias 'yama' + keyPassword System.getenv("YAMA_PWD") ?: "" + } + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + signingConfig signingConfigs.release + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.appcompat:appcompat:1.7.0' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..7d3b41b --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Keep all app classes — JNI method names must match exactly +-keep class com.yama.client.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..efe62b5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..504b0e0 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,56 @@ +cmake_minimum_required(VERSION 3.22) +project(yama_client LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# YAMA 根目录(相对于本文件:cpp/ → main/ → src/ → app/ → android/ → YAMA/) +get_filename_component(YAMA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../" ABSOLUTE) + +# ---- 头文件搜索路径(与 linux/CMakeLists.txt 同构)---- +include_directories( + ${YAMA_ROOT} + ${YAMA_ROOT}/client + ${YAMA_ROOT}/compress + ${CMAKE_CURRENT_SOURCE_DIR} # android_compat.h +) + +# ---- 强制注入兼容头(替代修改共用源文件)---- +add_compile_options(-include "${CMAKE_CURRENT_SOURCE_DIR}/android_compat.h") + +# ---- 核心源文件(与 linux/CMakeLists.txt 相同)---- +set(CORE_SOURCES + ${YAMA_ROOT}/client/Buffer.cpp + ${YAMA_ROOT}/client/IOCPClient.cpp + ${YAMA_ROOT}/client/sign_shim_unix.cpp + ${YAMA_ROOT}/common/ikcp.c +) + +# ---- Android NDK 入口 ---- +set(ANDROID_SOURCES + main.cpp +) + +add_library(yama SHARED ${CORE_SOURCES} ${ANDROID_SOURCES}) + +# ---- 预编译静态库(由 SimplePlugins/sign_lib/build_android.sh 产出)---- +target_link_libraries(yama PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libsign.a" +) + +# ---- zstd(由 android/build_zstd_android.sh 产出)---- +target_link_libraries(yama PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libzstd.a" +) + +# ---- Android 系统库 ---- +target_link_libraries(yama PRIVATE android log) + +# ---- 编译选项 ---- +target_compile_options(yama PRIVATE + -O2 + -fvisibility=hidden + -Wno-unused-parameter + -Wno-missing-field-initializers +) diff --git a/android/app/src/main/cpp/ScreenHandler.h b/android/app/src/main/cpp/ScreenHandler.h new file mode 100644 index 0000000..5d26dfa --- /dev/null +++ b/android/app/src/main/cpp/ScreenHandler.h @@ -0,0 +1,200 @@ +#pragma once +// Android 屏幕处理器:管理截屏子连接,接收 Java 侧 MediaCodec 输出的 H.264 NALU, +// 按协议封装后通过子连接发送给服务端。 +#include "common/commands.h" +#include "client/IOCPClient.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOGI_SH(...) __android_log_print(ANDROID_LOG_INFO, "YAMA_SCR", __VA_ARGS__) +#define LOGE_SH(...) __android_log_print(ANDROID_LOG_ERROR, "YAMA_SCR", __VA_ARGS__) + +extern uint64_t g_myClientID; +// 定义在 main.cpp,供子连接的 OnReceive 调用 +extern void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY); + +// Linux/macOS 共用的 BITMAPINFOHEADER 布局(与 Windows 完全一致) +#pragma pack(push, 1) +struct BmpInfoHeader { + uint32_t biSize; + int32_t biWidth; + int32_t biHeight; + uint16_t biPlanes; + uint16_t biBitCount; + uint32_t biCompression; + uint32_t biSizeImage; + int32_t biXPelsPerMeter; + int32_t biYPelsPerMeter; + uint32_t biClrUsed; + uint32_t biClrImportant; +}; +#pragma pack(pop) + +class AndroidScreenHandler : public IOCPManager { +public: + AndroidScreenHandler(IOCPClient* client, int width, int height) + : m_client(client), m_width(width), m_height(height), + m_started(false), m_running(true), m_firstSent(false) + { + memset(&m_bmpHdr, 0, sizeof(m_bmpHdr)); + m_bmpHdr.biSize = sizeof(BmpInfoHeader); + m_bmpHdr.biWidth = width; + m_bmpHdr.biHeight = height; + m_bmpHdr.biPlanes = 1; + m_bmpHdr.biBitCount = 32; + m_bmpHdr.biCompression = 0; + m_bmpHdr.biSizeImage = (uint32_t)(width * height * 4); + // 1 = top-down H264(Android MediaCodec 标准编码顺序); + // 服务端据此用负步长写 DIB,抵消 GDI bottom-up 翻转 + m_bmpHdr.biClrImportant = 1; + + m_sendThread = std::thread(&AndroidScreenHandler::SendLoop, this); + } + + ~AndroidScreenHandler() { + m_running = false; + m_cond.notify_all(); + if (m_sendThread.joinable()) + m_sendThread.join(); + } + + // 子连接建立后立即调用,告知服务端屏幕尺寸和编码格式 + void SendBitmapInfo() { + const uint32_t total = 1 + sizeof(BmpInfoHeader) + 2 * sizeof(uint64_t) + sizeof(ScreenSettings); + std::vector buf(total, 0); + + buf[0] = TOKEN_BITMAPINFO; + memcpy(&buf[1], &m_bmpHdr, sizeof(BmpInfoHeader)); + + uint64_t clientID = g_myClientID; + uint64_t zero = 0; + size_t off = 1 + sizeof(BmpInfoHeader); + memcpy(&buf[off], &clientID, 8); + memcpy(&buf[off + 8], &zero, 8); + + ScreenSettings ss = {}; + ss.MaxFPS = 10; + ss.ScreenWidth = m_width; + ss.ScreenHeight = m_height; + ss.QualityLevel = QUALITY_GOOD; // 告知服务端使用 H264 解码器 + ss.ScreenType = USING_VIRTUAL; // 虚拟显示 + memcpy(&buf[off + 16], &ss, sizeof(ss)); + + m_client->Send2Server((char*)buf.data(), total); + LOGI_SH("SendBitmapInfo %dx%d clientID=%" PRIu64, m_width, m_height, clientID); + + // H.264 是推送模式,不需要等 COMMAND_NEXT 才开始发帧。 + // 服务端在 auth 通过瞬间发 COMMAND_NEXT,此时 setManagerCallBack 尚未注册, + // 消息被 WorkThread 丢弃,m_started 永远 false 导致帧全部卡在队列里。 + m_started = true; + m_cond.notify_all(); + } + + // 由 JNI 线程调用,投递 MediaCodec 输出的 NALU + void OnFrameData(const uint8_t* data, uint32_t size, bool isKeyframe) { + if (!m_running || size == 0) return; + { + std::lock_guard lk(m_mutex); + while (m_queue.size() >= 6) m_queue.pop(); + m_queue.push({std::vector(data, data + size), isKeyframe}); + } + m_cond.notify_one(); + } + + virtual VOID OnReceive(PBYTE data, ULONG size) override { + if (!size) return; + switch (data[0]) { + case COMMAND_NEXT: + // 推送模式下已在 SendBitmapInfo 里开始推流;此处保留兼容,无副作用。 + LOGI_SH("COMMAND_NEXT received"); + m_started = true; + m_cond.notify_all(); + break; + case CMD_QUALITY_LEVEL: + if (size >= 2) LOGI_SH("QualityLevel=%d", (int)(int8_t)data[1]); + break; + case COMMAND_SCREEN_CONTROL: { + // 服务端通过子连接下发鼠标/键盘控制包(MSG64 固定 48 字节) + // 坐标从 lParam(offset 24)读取,与 Windows/Linux/macOS 客户端一致: + // lParam = MAKELPARAM(enc_x, enc_y),低16位=x,高16位=y。 + // 不读 pt.x/pt.y (offset 40/44):64-bit MSG 的 pt 在 offset 36, + // 与 MSG64 offset 40 不同,直接读会得到错误坐标。 + if ((ULONG)size < 1 + 48u) break; + const uint8_t* p = data + 1; + uint64_t msgVal = 0, wParam = 0, lParam = 0; + memcpy(&msgVal, p + 8, 8); + memcpy(&wParam, p + 16, 8); + memcpy(&lParam, p + 24, 8); + int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF); + int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF); + DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY); + break; + } + default: + break; + } + } + +private: + struct Frame { std::vector data; bool isKeyframe; }; + + IOCPClient* m_client; + int m_width, m_height; + std::atomic m_started; + std::atomic m_running; + std::atomic m_firstSent; + std::atomic m_skipLogged{false}; + + BmpInfoHeader m_bmpHdr; + + std::queue m_queue; + std::mutex m_mutex; + std::condition_variable m_cond; + std::thread m_sendThread; + + void SendLoop() { + while (m_running) { + std::unique_lock lk(m_mutex); + m_cond.wait(lk, [&]{ return (!m_queue.empty() && m_started) || !m_running; }); + if (!m_running) break; + + Frame f = std::move(m_queue.front()); + m_queue.pop(); + lk.unlock(); + + // 第一帧必须是关键帧 + if (!m_firstSent && !f.isKeyframe) { + if (!m_skipLogged.exchange(true)) + LOGI_SH("SendLoop: waiting for first IDR, skipping P-frames"); + continue; + } + + if (!m_firstSent) + LOGI_SH("SendH264 first IDR size=%u", (uint32_t)f.data.size()); + SendH264(f.data.data(), (uint32_t)f.data.size()); + m_firstSent = true; + } + } + + // 格式: [TOKEN_NEXTSCREEN:1][ALGORITHM_H264:1][cursorX:4][cursorY:4][cursorType:1][NALU:N] + void SendH264(const uint8_t* nalu, uint32_t naluSize) { + const uint32_t hdrSize = 1 + 1 + 4 + 4 + 1; + std::vector pkt(hdrSize + naluSize); + + pkt[0] = TOKEN_NEXTSCREEN; + pkt[1] = ALGORITHM_H264; + // cursor: (0,0), type: IDC_ARROW=1 + memset(&pkt[2], 0, 8); + pkt[10] = 1; + memcpy(&pkt[hdrSize], nalu, naluSize); + + m_client->Send2Server((char*)pkt.data(), pkt.size()); + } +}; diff --git a/android/app/src/main/cpp/android_compat.h b/android/app/src/main/cpp/android_compat.h new file mode 100644 index 0000000..2b676b9 --- /dev/null +++ b/android/app/src/main/cpp/android_compat.h @@ -0,0 +1,24 @@ +// android_compat.h - Android NDK 兼容垫片,通过 CMake force-include 注入每个源文件 +// 不修改任何共用代码(client/、common/) +#pragma once +#ifdef __ANDROID__ +#include +#include + +#ifdef __cplusplus +#include +#include + +// strcpy_s(dest, src) — MSVC 2-arg 扩展,Android NDK 无此签名 +template +inline int strcpy_s(char (&dest)[N], const char* src) { + strncpy(dest, src ? src : "", N - 1); + dest[N - 1] = '\0'; + return 0; +} +#endif // __cplusplus + +// rand_s — Windows 安全随机数,Android 用 arc4random 替代 +#define rand_s(p) (*(p) = (unsigned int)arc4random(), 0) + +#endif // __ANDROID__ diff --git a/android/app/src/main/cpp/lib/arm64-v8a/libsign.a b/android/app/src/main/cpp/lib/arm64-v8a/libsign.a new file mode 100644 index 0000000..bf10b5c Binary files /dev/null and b/android/app/src/main/cpp/lib/arm64-v8a/libsign.a differ diff --git a/android/app/src/main/cpp/lib/arm64-v8a/libzstd.a b/android/app/src/main/cpp/lib/arm64-v8a/libzstd.a new file mode 100644 index 0000000..9592dc4 Binary files /dev/null and b/android/app/src/main/cpp/lib/arm64-v8a/libzstd.a differ diff --git a/android/app/src/main/cpp/lib/armeabi-v7a/libsign.a b/android/app/src/main/cpp/lib/armeabi-v7a/libsign.a new file mode 100644 index 0000000..1550c15 Binary files /dev/null and b/android/app/src/main/cpp/lib/armeabi-v7a/libsign.a differ diff --git a/android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a b/android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a new file mode 100644 index 0000000..6b18641 Binary files /dev/null and b/android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a differ diff --git a/android/app/src/main/cpp/main.cpp b/android/app/src/main/cpp/main.cpp new file mode 100644 index 0000000..8fa378a --- /dev/null +++ b/android/app/src/main/cpp/main.cpp @@ -0,0 +1,662 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/commands.h" +#include "common/client_auth_state.h" +#include "common/rtt_estimator.h" +#include "client/IOCPClient.h" +#include "ScreenHandler.h" +#define XXH_INLINE_ALL +#include "common/xxhash.h" + +#define LOG_TAG "YAMA" +#undef Mprintf +#define Mprintf(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +// ---- 设备信息 helper(登录时填充,仅调用一次)---- + +static int GetCpuCores() { + long n = sysconf(_SC_NPROCESSORS_ONLN); + return (n > 0) ? (int)n : 1; +} + +static double GetMemoryGB() { + FILE* f = fopen("/proc/meminfo", "r"); + if (!f) return 0.0; + char line[128]; + unsigned long kb = 0; + while (fgets(line, sizeof(line), f)) { + if (sscanf(line, "MemTotal: %lu kB", &kb) == 1) break; + } + fclose(f); + return kb / (1024.0 * 1024.0); +} + +// 遍历 cpu0-cpu7,取 cpuinfo_max_freq 最大值(大核频率) +static int GetCpuMHz() { + // 优先从 sysfs 读取(真机有效) + unsigned long maxKhz = 0; + char path[80]; + for (int i = 0; i < 8; i++) { + snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); + FILE* f = fopen(path, "r"); + if (!f) continue; + unsigned long khz = 0; + if (fscanf(f, "%lu", &khz) == 1 && khz > maxKhz) maxKhz = khz; + fclose(f); + } + if (maxKhz > 0) return (int)(maxKhz / 1000); + + // 回退:解析 /proc/cpuinfo 中的 "BogoMIPS"(模拟器可用) + FILE* f = fopen("/proc/cpuinfo", "r"); + if (!f) return 0; + char line[128]; + double bogomips = 0; + while (fgets(line, sizeof(line), f)) { + if (sscanf(line, "BogoMIPS : %lf", &bogomips) == 1 || + sscanf(line, "bogomips : %lf", &bogomips) == 1) break; + } + fclose(f); + return bogomips > 0 ? (int)bogomips : 0; +} + +static long GetFileSize(const std::string& path) { + if (path.empty()) return 0; + struct stat st; + return (stat(path.c_str(), &st) == 0) ? (long)st.st_size : 0; +} + +static std::string FormatFileSize(long bytes) { + char buf[32]; + if (bytes >= 1024 * 1024) + snprintf(buf, sizeof(buf), "%.1fM", bytes / (1024.0 * 1024.0)); + else if (bytes >= 1024) + snprintf(buf, sizeof(buf), "%.1fK", bytes / 1024.0); + else + snprintf(buf, sizeof(buf), "%ldB", bytes); + return buf; +} + +// 从 JSON 字符串中提取字符串字段值 +static std::string JsonGetStr(const std::string& json, const char* key) { + std::string needle = std::string("\"") + key + "\":"; + auto pos = json.find(needle); + if (pos == std::string::npos) return ""; + pos += needle.size(); + while (pos < json.size() && json[pos] == ' ') pos++; + if (pos >= json.size() || json[pos] != '"') return ""; + pos++; + auto end = json.find('"', pos); + return (end == std::string::npos) ? "" : json.substr(pos, end - pos); +} + +// 查询 ip-api.com,一次请求同时获取公网 IP 和地理位置(与 Windows 客户端同源) +// 在连接线程中同步调用,超时 3 秒 +static bool FetchGeoInfo(std::string& pubIp, std::string& location) { + pubIp.clear(); location.clear(); + + struct addrinfo hints = {}, *res = nullptr; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + if (getaddrinfo("ip-api.com", "80", &hints, &res) != 0 || !res) return false; + + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { freeaddrinfo(res); return false; } + + struct timeval tv = {3, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + + bool ok = false; + if (connect(fd, res->ai_addr, res->ai_addrlen) == 0) { + const char* req = "GET /json/?fields=status,query,country,city HTTP/1.0\r\n" + "Host: ip-api.com\r\n" + "Connection: close\r\n\r\n"; + send(fd, req, strlen(req), 0); + + std::string resp; + char buf[512]; + ssize_t n; + while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) resp.append(buf, (size_t)n); + + auto sep = resp.find("\r\n\r\n"); + if (sep != std::string::npos) { + const std::string body = resp.substr(sep + 4); + if (JsonGetStr(body, "status") == "success") { + pubIp = JsonGetStr(body, "query"); + std::string city = JsonGetStr(body, "city"); + std::string country = JsonGetStr(body, "country"); + if (!city.empty() && !country.empty()) location = city + ", " + country; + else if (!country.empty()) location = country; + else if (!city.empty()) location = city; + ok = true; + } + } + } + close(fd); + freeaddrinfo(res); + return ok; +} + +// ---- 全局状态 ---- +// CPP-06: g_bExit 是全局变量,跨调用边界不会被编译器缓存进寄存器; +// IOCPClient 构造接受 const State& 持有引用,不能改为 atomic/volatile, +// 直接用 State;ARM 4 字节对齐写入硬件层面原子,实际安全。 +State g_bExit{S_CLIENT_NORMAL}; +uint64_t g_myClientID = 0; // sub_conn_thread.h 需要的外部符号 +static std::atomic g_running{false}; +static std::atomic g_lastHeartbeatAckMs{0}; + +// ---- JNI 反向调用(DataProcess → ControlService / CaptureService)---- +static JavaVM* g_jvm = nullptr; +static jclass g_ctrlClass = nullptr; +static jmethodID g_ctrlMethod = nullptr; +static jmethodID g_getActiveWindowMid = nullptr; +// CaptureService.requestIdr() / forceFirstFrame() — ScreenSpyThread 子连接建立后触发 +static jclass g_captureClass = nullptr; +static jmethodID g_requestIdrMid = nullptr; +static jmethodID g_forceFirstFrameMid = nullptr; + +// 心跳日志限流:每 60 秒最多打一次 +static std::atomic g_lastHbLogMs{0}; + +static constexpr uint64_t HB_LOG_INTERVAL_MS = 60000; + +// 供主连接和子连接共同调用:把 COMMAND_SCREEN_CONTROL 路由到 ControlService.onControlEvent() +// 定义在全局函数区;ScreenHandler.h 通过 extern 声明使用。 +void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY) +{ + if (!g_jvm || !g_ctrlClass || !g_ctrlMethod) { + LOGE("SCREEN_CONTROL: JNI not ready (ctrl=%p method=%p)", g_ctrlClass, g_ctrlMethod); + return; + } + if (msgVal != 0x200u) + LOGI("SCREEN_CONTROL: msg=0x%X pt=(%d,%d)", msgVal, ptX, ptY); + + JNIEnv* jenv = nullptr; + bool attached = false; + jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6); + if (st == JNI_EDETACHED) { + // CPP-03 fix: 检查 AttachCurrentThread 返回值,失败时 jenv 仍为 null + if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return; + attached = true; + } else if (st != JNI_OK || !jenv) { + return; + } + jenv->CallStaticVoidMethod(g_ctrlClass, g_ctrlMethod, + (jint)msgVal, (jlong)wParam, (jint)ptX, (jint)ptY); + if (jenv->ExceptionOccurred()) jenv->ExceptionClear(); + if (attached) g_jvm->DetachCurrentThread(); +} + +static std::string GetActiveWindowFromJava() +{ + if (!g_jvm || !g_ctrlClass || !g_getActiveWindowMid) return "Android"; + JNIEnv* jenv = nullptr; + bool attached = false; + jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6); + if (st == JNI_EDETACHED) { + if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return "Android"; + attached = true; + } else if (st != JNI_OK || !jenv) { + return "Android"; + } + jstring js = (jstring)jenv->CallStaticObjectMethod(g_ctrlClass, g_getActiveWindowMid); + std::string result = "Android"; + if (js && !jenv->ExceptionOccurred()) { + const char* c = jenv->GetStringUTFChars(js, nullptr); + if (c) { result = c; jenv->ReleaseStringUTFChars(js, c); } + jenv->DeleteLocalRef(js); + } + if (jenv->ExceptionOccurred()) jenv->ExceptionClear(); + if (attached) g_jvm->DetachCurrentThread(); + return result; +} + +// 服务器连接参数 +static std::string g_serverIp; +static int g_serverPort = 443; + +// 设备信息(nativeInit 传入) +static std::string g_androidId; +static std::string g_deviceModel; +static std::string g_androidVersion; +static std::string g_screenRes; +static std::string g_username; +static std::string g_apkPath; + +// 屏幕尺寸(Java 侧 MediaCodec 配置后通过 nativeSetScreenSize 设置) +// 初始化为 0;ScreenSpyThread 等到非零后再读,避免在 nativeSetScreenSize 之前 +// 拿到默认值 1280×720 发给服务端导致 decoder 尺寸错误黑屏。 +static std::atomic g_screenWidth{0}; +static std::atomic g_screenHeight{0}; + +// 活跃子连接的 handler 集合(受 g_shMutex 保护);支持多个浏览者/控制者同时连接 +static std::set g_screenHandlers; +static std::mutex g_shMutex; + +// ------------------------------------------------------------------ 屏幕子连接线程 +// 通过 JNI 调用 CaptureService 的静态方法,复用同一套 attach/detach 模板。 +static void CallCaptureStaticVoid(jmethodID mid) +{ + if (!g_jvm || !g_captureClass || !mid) return; + JNIEnv* jenv = nullptr; + bool attached = false; + jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6); + if (st == JNI_EDETACHED) { + if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return; + attached = true; + } else if (st != JNI_OK || !jenv) { + return; + } + jenv->CallStaticVoidMethod(g_captureClass, mid); + if (jenv->ExceptionOccurred()) jenv->ExceptionClear(); + if (attached) g_jvm->DetachCurrentThread(); +} + +// 触发编码器输出关键帧(setParameters REQUEST_SYNC_FRAME) +static void RequestIdrFromJava() { CallCaptureStaticVoid(g_requestIdrMid); } + +// 强制 VirtualDisplay 推一帧(静止屏幕绕过 SurfaceFlinger 空闲优化) +static void ForceFirstFrameFromJava() { CallCaptureStaticVoid(g_forceFirstFrameMid); } + +static void ScreenSpyThread() +{ + // 等待 Java 侧 startCapture() 调用 nativeSetScreenSize 设置真实分辨率。 + // 若在此之前读到默认值 0,SendBitmapInfo 会发错误尺寸给服务端, + // 导致浏览器 initDecoder 尺寸与 H.264 SPS 不一致 → decode error → 黑屏。 + for (int i = 0; i < 100 && g_screenWidth.load() == 0 && g_running.load(); ++i) + Sleep(50); // 最多等 5 秒 + + int w = g_screenWidth.load(); + int h = g_screenHeight.load(); + if (w == 0 || h == 0) { + LOGE("ScreenSpyThread: screen size not set after 5s, abort"); + return; + } + LOGI("ScreenSpyThread start → %s:%d size=%dx%d", g_serverIp.c_str(), g_serverPort, w, h); + + // 服务端只发一次 COMMAND_SCREEN_SPY 就等子连接,子连接失败必须自己重试 + for (int attempt = 1; attempt <= 20 && S_CLIENT_NORMAL == g_bExit && g_running.load(); ++attempt) { + // 声明在 try 外部,确保 catch 中 handler 仍有效,可安全从 g_screenHandlers 移除 + std::unique_ptr client; + std::unique_ptr handler; + try { + client = std::make_unique(g_bExit, true); + client->EnableSubConnAuth(true, g_myClientID); + + if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) { + LOGI("ScreenSpyThread: connect failed (attempt %d/20), retry 2s", attempt); + Sleep(2000); + continue; + } + + handler = std::make_unique(client.get(), w, h); + { + std::lock_guard lk(g_shMutex); + g_screenHandlers.insert(handler.get()); + } + + client->setManagerCallBack(handler.get(), + IOCPManager::DataProcess, + IOCPManager::ReconnectProcess); + handler->SendBitmapInfo(); + // 子连接刚建立,浏览器 initDecoder 完成后需要关键帧才能开始显示。 + // ForceFirstFrameFromJava:短暂 resize VirtualDisplay 强制 SurfaceFlinger + // 推一帧给编码器——静止屏幕时 SurfaceFlinger 有空闲优化不主动推帧, + // 导致 REQUEST_SYNC_FRAME 排队等待,造成 10-60 秒黑屏。 + // RequestIdrFromJava 确保此帧被编码为 IDR。 + ForceFirstFrameFromJava(); + RequestIdrFromJava(); + + while (client->IsRunning() && client->IsConnected() && S_CLIENT_NORMAL == g_bExit) + Sleep(200); + + client->setManagerCallBack(nullptr, nullptr, nullptr); + { + std::lock_guard lk(g_shMutex); + g_screenHandlers.erase(handler.get()); + } + } catch (const std::exception& e) { + LOGE("ScreenSpyThread exception (attempt %d): %s", attempt, e.what()); + // 先清除回调,再析构 handler:防止 client 在 handler 析构后仍持有其指针 + if (client) client->setManagerCallBack(nullptr, nullptr, nullptr); + if (handler) { + std::lock_guard lk(g_shMutex); + g_screenHandlers.erase(handler.get()); + } + Sleep(2000); + continue; + } + break; // 正常结束,不再重试 + } + LOGI("ScreenSpyThread exit"); +} + +// ------------------------------------------------------------------ DataProcess +int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength) +{ + if (!szBuffer || !ulLength) return TRUE; + + if (!ClientAuth::IsCommandAllowed(szBuffer[0])) + return TRUE; + + switch (szBuffer[0]) { + case COMMAND_BYE: + LOGI("COMMAND_BYE"); + g_bExit = S_CLIENT_EXIT; + break; + + case CMD_HEARTBEAT_ACK: + if (ulLength >= 1 + (ULONG)sizeof(HeartbeatACK)) { + HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1); + uint64_t now = GetUnixMs(); + g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed); + int64_t rtt = (int64_t)now - (int64_t)ack->Time; + if (ack->ProcessingMs > 0 && (int64_t)ack->ProcessingMs < rtt) + rtt -= ack->ProcessingMs; + g_rttEstimator.update_from_sample((double)rtt); + if (now - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) { + g_lastHbLogMs.store(now, std::memory_order_relaxed); + LOGI("HeartbeatACK RTT=%" PRId64 "ms SRTT=%.1fms", rtt, g_rttEstimator.srtt * 1000.0); + } + } + break; + + case CMD_MASTERSETTING: { + MasterSettings settings; + if (ClientAuth::HandleMasterSettings(szBuffer + 1, (int)ulLength - 1, &settings)) { + if (settings.ReportInterval > 0) + g_heartbeatInterval = std::max(settings.ReportInterval, 30); + LOGI("MasterSettings OK interval=%ds (server=%d)", g_heartbeatInterval, settings.ReportInterval); + } + break; + } + + case COMMAND_SCREEN_SPY: { + // 每个 COMMAND_SCREEN_SPY 对应一个独立子连接,支持多人同时观看/控制 + size_t active; + { std::lock_guard lk(g_shMutex); active = g_screenHandlers.size(); } + LOGI("COMMAND_SCREEN_SPY → ScreenSpyThread (active=%zu)", active); + std::thread(ScreenSpyThread).detach(); + break; + } + + case COMMAND_SCREEN_CONTROL: { + if (ulLength < 1 + 48u) { LOGI("SCREEN_CONTROL(main): too short %u", ulLength); break; } + const uint8_t* p = szBuffer + 1; + uint64_t msgVal = 0, wParam = 0, lParam = 0; + memcpy(&msgVal, p + 8, 8); + memcpy(&wParam, p + 16, 8); + memcpy(&lParam, p + 24, 8); + int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF); + int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF); + DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY); + break; + } + + case COMMAND_SHELL: + LOGI("COMMAND_SHELL (not implemented)"); + break; + + case COMMAND_SYSTEM: + LOGI("COMMAND_SYSTEM (not implemented)"); + break; + + default: + LOGI("Unhandled cmd=%d", (int)szBuffer[0]); + break; + } + return TRUE; +} + +// ------------------------------------------------------------------ 网络主线程 +static void ConnectionThread() +{ + LOGI("ConnectionThread → %s:%d", g_serverIp.c_str(), g_serverPort); + + std::string g_pubIp, g_location; + FetchGeoInfo(g_pubIp, g_location); + // 失败时留空串——服务端检测到空串会回落到自己的 IP 地理查询; + // 发 "?" 会被服务端当作有效值缓存,导致永远显示问号 + LOGI("GeoInfo: ip=%s loc=%s", g_pubIp.c_str(), g_location.c_str()); + + LOGIN_INFOR logInfo; + strncpy(logInfo.szPCName, g_deviceModel.c_str(), sizeof(logInfo.szPCName) - 1); + strncpy(logInfo.OsVerInfoEx, g_androidVersion.c_str(), sizeof(logInfo.OsVerInfoEx) - 1); + strncpy(logInfo.szStartTime, ToPekingTimeAsString(nullptr).c_str(), sizeof(logInfo.szStartTime) - 1); + logInfo.dwCPUMHz = GetCpuMHz(); + logInfo.bWebCamIsExist = 0; + + g_myClientID = XXH64(g_androidId.c_str(), g_androidId.size(), 0); + + logInfo.AddReserved("APK"); + logInfo.AddReserved(64); // OS bits + logInfo.AddReserved(GetCpuCores()); // CPU 核数 + logInfo.AddReserved(GetMemoryGB()); // 内存 GB + logInfo.AddReserved(g_apkPath.empty() ? "/data/app/com.yama.client" + : g_apkPath.c_str()); // 文件路径 + logInfo.AddReserved("?"); + logInfo.AddReserved(logInfo.szStartTime); + logInfo.AddReserved("?"); + logInfo.AddReserved(64); // 程序位数 + logInfo.AddReserved(""); + logInfo.AddReserved(g_location.c_str()); // [10] 地理位置 + logInfo.AddReserved(g_pubIp.c_str()); // [11] 公网 IP + logInfo.AddReserved("v1.0.0"); + logInfo.AddReserved(g_username.c_str()); + logInfo.AddReserved(0); // IsRunningAsAdmin + logInfo.AddReserved(g_screenRes.c_str()); + logInfo.AddReserved(std::to_string(g_myClientID).c_str()); + logInfo.AddReserved((int)getpid()); // PID + logInfo.AddReserved(FormatFileSize(GetFileSize(g_apkPath)).c_str()); // 文件大小 + + ClientAuth::g_loginMsg = std::string(logInfo.szStartTime) + "|" + std::to_string(g_myClientID); + LOGI("ClientID=%" PRIu64, g_myClientID); + + std::unique_ptr client(new IOCPClient(g_bExit, false)); + client->setManagerCallBack(nullptr, DataProcess, nullptr); + + while (S_CLIENT_NORMAL == g_bExit && g_running.load()) { + clock_t c = clock(); + if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) { + LOGI("Connect failed, retry 5s"); + Sleep(5000); + continue; + } + + ClientAuth::OnNewConnection(); + client->SendLoginInfo(logInfo.Speed(clock() - c)); + g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed); + LOGI("Connected & login sent"); + + while (client->IsRunning() && client->IsConnected() + && S_CLIENT_NORMAL == g_bExit && g_running.load()) + { + int interval = g_heartbeatInterval > 0 ? g_heartbeatInterval : 30; + for (int i = 0; i < interval; ++i) { + if (!client->IsRunning() || !client->IsConnected() + || g_bExit != S_CLIENT_NORMAL || !g_running.load()) break; + Sleep(1000); + } + if (!client->IsRunning() || !client->IsConnected() + || g_bExit != S_CLIENT_NORMAL || !g_running.load()) break; + + if (ClientAuth::IsTimedOut()) { + LOGI("MasterSettings timeout → reconnect"); + client->Disconnect(); + break; + } + + { + int ackTO = (interval * 3 > 60) ? interval * 3 : 60; + uint64_t last = g_lastHeartbeatAckMs.load(std::memory_order_relaxed); + uint64_t now = GetUnixMs(); + if (last > 0 && now > last && now - last > (uint64_t)ackTO * 1000ULL) { + LOGI("ACK timeout → reconnect"); + client->Disconnect(); + break; + } + } + + Heartbeat hb; + hb.Time = GetUnixMs(); + hb.Ping = (int)(g_rttEstimator.srtt * 1000.0); + std::string aw = GetActiveWindowFromJava(); + strncpy(hb.ActiveWnd, aw.c_str(), sizeof(hb.ActiveWnd) - 1); + + BYTE buf[sizeof(Heartbeat) + 1]; + buf[0] = TOKEN_HEARTBEAT; + memcpy(buf + 1, &hb, sizeof(Heartbeat)); + client->Send2Server((char*)buf, sizeof(buf)); + { + uint64_t now2 = GetUnixMs(); + if (now2 - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) { + // ACK 分支会更新 g_lastHbLogMs,这里仅在没有 ACK 时兜底打印 + LOGI("Heartbeat Ping=%dms", hb.Ping); + } + } + } + LOGI("Disconnected"); + } + + g_running.store(false); + LOGI("ConnectionThread exit"); +} + +// ------------------------------------------------------------------ JNI +extern "C" { + +JNIEXPORT jint JNICALL +Java_com_yama_client_YamaBridge_nativeInit( + JNIEnv* env, jobject, + jstring serverIp, jint serverPort, + jstring androidId, jstring deviceModel, + jstring androidVersion, jstring screenRes, + jstring username, jstring apkPath) +{ + // CPP-01 fix: CAS 原子地完成"检查+设置",消除 check-then-act 竞争 + bool expected = false; + if (!g_running.compare_exchange_strong(expected, true)) { + LOGI("already running"); return -1; + } + + auto toStr = [&](jstring js) -> std::string { + if (!js) return ""; + const char* c = env->GetStringUTFChars(js, nullptr); + std::string s = c ? c : ""; + env->ReleaseStringUTFChars(js, c); + return s; + }; + + g_serverIp = toStr(serverIp); + g_serverPort = (int)serverPort; + g_androidId = toStr(androidId); + g_deviceModel = toStr(deviceModel); + g_androidVersion = toStr(androidVersion); + g_screenRes = toStr(screenRes); + g_username = toStr(username); + g_apkPath = toStr(apkPath); + + // 缓存 JVM 和 Class/Method 引用。必须在 Java 线程(nativeInit 调用栈) + // 里做 FindClass,否则 AttachCurrentThread 的后台线程只有系统 ClassLoader, + // 找不到 App 类,GetStaticMethodID 拿到 null 导致后续调用崩溃。 + if (g_jvm == nullptr) { + if (env->GetJavaVM(&g_jvm) == JNI_OK) { + jclass cls = env->FindClass("com/yama/client/ControlService"); + if (cls) { + g_ctrlClass = (jclass)env->NewGlobalRef(cls); + g_ctrlMethod = env->GetStaticMethodID(g_ctrlClass, "onControlEvent", "(IJII)V"); + if (!g_ctrlMethod) LOGE("ControlService.onControlEvent not found"); + g_getActiveWindowMid = env->GetStaticMethodID(g_ctrlClass, "getActiveWindow", "()Ljava/lang/String;"); + if (env->ExceptionCheck()) { env->ExceptionClear(); g_getActiveWindowMid = nullptr; } + if (!g_getActiveWindowMid) LOGE("ControlService.getActiveWindow not found"); + } else { + LOGE("ControlService class not found"); + } + jclass capCls = env->FindClass("com/yama/client/CaptureService"); + if (env->ExceptionCheck()) { env->ExceptionClear(); capCls = nullptr; } + if (capCls) { + g_captureClass = (jclass)env->NewGlobalRef(capCls); + g_requestIdrMid = env->GetStaticMethodID(g_captureClass, "requestIdr", "()V"); + if (env->ExceptionCheck()) { env->ExceptionClear(); g_requestIdrMid = nullptr; } + if (!g_requestIdrMid) LOGE("CaptureService.requestIdr not found"); + // GetStaticMethodID 找不到方法时会挂起 JNI 异常;必须清除, + // 否则 nativeInit 返回时 Java 层会抛出 NoSuchMethodError 崩溃。 + g_forceFirstFrameMid = env->GetStaticMethodID(g_captureClass, "forceFirstFrame", "()V"); + if (env->ExceptionCheck()) { env->ExceptionClear(); g_forceFirstFrameMid = nullptr; } + if (!g_forceFirstFrameMid) LOGE("CaptureService.forceFirstFrame not found"); + } else { + LOGE("CaptureService class not found"); + } + } + } + + g_bExit = S_CLIENT_NORMAL; + // g_running 已在 CAS 中设为 true,不重复 store + std::thread(ConnectionThread).detach(); + LOGI("nativeInit OK server=%s:%d", g_serverIp.c_str(), g_serverPort); + return 0; +} + +JNIEXPORT void JNICALL +Java_com_yama_client_YamaBridge_nativeStop(JNIEnv*, jobject) +{ + LOGI("nativeStop"); + g_bExit = S_CLIENT_EXIT; + g_running.store(false); +} + +// 由 CaptureService 在 MediaCodec 配置完成后调用,告知 C++ 侧实际捕获尺寸 +JNIEXPORT void JNICALL +Java_com_yama_client_YamaBridge_nativeSetScreenSize(JNIEnv*, jobject, jint width, jint height) +{ + g_screenWidth.store(width); + g_screenHeight.store(height); + LOGI("ScreenSize=%dx%d", width, height); +} + +// 由 CaptureService 的 MediaCodec.Callback 调用,投递 H.264 NALU +JNIEXPORT void JNICALL +Java_com_yama_client_YamaBridge_nativeOnH264Frame( + JNIEnv* env, jobject, + jbyteArray data, jint offset, jint size, jboolean isKeyframe) +{ + // CPP-04 fix: 验证 offset/size 边界,防止越界读写导致崩溃 + if (!data || offset < 0 || size <= 0) return; + jsize arrLen = env->GetArrayLength(data); + if ((jlong)offset + size > arrLen) { + LOGE("nativeOnH264Frame: bounds violation offset=%d size=%d arrLen=%d", offset, size, arrLen); + return; + } + + jbyte* buf = env->GetByteArrayElements(data, nullptr); + if (!buf) return; + + { + std::lock_guard lk(g_shMutex); + for (auto* h : g_screenHandlers) + h->OnFrameData((const uint8_t*)buf + offset, (uint32_t)size, (bool)isKeyframe); + } + + env->ReleaseByteArrayElements(data, buf, JNI_ABORT); +} + +} // extern "C" diff --git a/android/app/src/main/java/com/yama/client/CaptureService.kt b/android/app/src/main/java/com/yama/client/CaptureService.kt new file mode 100644 index 0000000..c6c5e4c --- /dev/null +++ b/android/app/src/main/java/com/yama/client/CaptureService.kt @@ -0,0 +1,360 @@ +package com.yama.client + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.util.DisplayMetrics +import android.util.Log +import android.view.WindowManager + +class CaptureService : Service() { + + companion object { + const val TAG = "YAMA" + const val CHANNEL_ID = "yama_service" + const val NOTIF_ID = 1 + + const val EXTRA_SERVER_IP = "server_ip" + const val EXTRA_SERVER_PORT = "server_port" + const val EXTRA_PROJECTION_RESULT = "projection_result" + const val EXTRA_PROJECTION_DATA = "projection_data" + + private const val MAX_LONG_SIDE = 1080 + + // CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争 + @Volatile var instance: CaptureService? = null + + @JvmStatic + fun requestIdr() { + val svc = instance ?: return + svc.idrHandler.post { + runCatching { + val p = Bundle() + p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + svc.mediaCodec?.setParameters(p) + } + } + } + + /** + * 静止屏幕黑屏修复:SurfaceFlinger 在无脏区时不向 VirtualDisplay 推帧, + * 导致编码器无输入,REQUEST_SYNC_FRAME 悬空排队,浏览器等 10-60 秒才见画面。 + * 解决方法:将 VirtualDisplay 宽度临时改变 2px,触发一次强制合成, + * 编码器随即得到输入帧并消费已排队的 IDR 请求。150ms 后恢复原始尺寸。 + */ + @JvmStatic + fun forceFirstFrame() { + val svc = instance ?: return + svc.idrHandler.post { + runCatching { + val vd = svc.virtualDisplay ?: return@runCatching + val w = ControlService.encW + val h = ControlService.encH + val dpi = svc.resources.displayMetrics.densityDpi + Log.d(TAG, "forceFirstFrame: resize ${w}x${h} → ${w+2}x${h} to trigger composition") + vd.resize(w + 2, h, dpi) + svc.idrHandler.postDelayed({ + runCatching { vd.resize(w, h, dpi) } + Log.d(TAG, "forceFirstFrame: restored ${w}x${h}") + }, 150) + } + } + } + } + + private var mediaProjection: MediaProjection? = null + private var virtualDisplay: VirtualDisplay? = null + private var mediaCodec: MediaCodec? = null + + private val idrHandler = Handler(Looper.getMainLooper()) + // CS-04 fix: runCatching 包住 setParameters,防止 codec 已停止时抛异常 + private val idrRunnable = object : Runnable { + override fun run() { + runCatching { + val p = Bundle() + p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + mediaCodec?.setParameters(p) + } + idrHandler.postDelayed(this, 1000) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + instance = this + createNotificationChannel() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIF_ID, buildNotification(), + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + } else { + startForeground(NOTIF_ID, buildNotification()) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // CS-03 fix: START_STICKY 重启时 intent 为 null,必须在最前面检查 + if (intent == null) return START_NOT_STICKY + val ip = intent.getStringExtra(EXTRA_SERVER_IP) ?: return START_NOT_STICKY + val port = intent.getIntExtra(EXTRA_SERVER_PORT, 443) + + val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: "" + val model = "${Build.MANUFACTURER} ${Build.MODEL}" + val osVer = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + val (sw, sh) = getPhysicalResolution() + val res = "1:${sw}*${sh}" + val user = Build.USER + + Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res") + + // YB-01 fix: so 库加载失败是 Error,不被 catch(Exception) 捕获,需单独处理 + try { + YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "") + } catch (e: UnsatisfiedLinkError) { + Log.e(TAG, "Native library load failed: $e") + stopSelf() + return START_NOT_STICKY + } + + val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1) + val projData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent.getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java) + else + @Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA) + + if (projResult == android.app.Activity.RESULT_OK && projData != null) { + val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val mp = pm.getMediaProjection(projResult, projData) + mediaProjection = mp + // CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃 + if (sw > 0 && sh > 0) { + startCapture(mp, sw, sh) + } else { + Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture") + } + } else { + Log.w(TAG, "No MediaProjection token, skipping screen capture") + } + + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + instance = null + stopCapture() + YamaBridge.nativeStop() + Log.i(TAG, "CaptureService destroyed") + } + + // ---------------------------------------------------------------- MediaProjection + MediaCodec + + private fun startCapture(mp: MediaProjection, physW: Int, physH: Int) { + val (encW, encH) = scaleDown(physW, physH, MAX_LONG_SIDE) + val dpi = resources.displayMetrics.densityDpi + + Log.i(TAG, "Capture: phys=${physW}x${physH} enc=${encW}x${encH} dpi=$dpi") + + val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + val surface = configureEncoder(codec, encW, encH) + + var configData: ByteArray? = null + + codec.setCallback(object : MediaCodec.Callback() { + override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { /* Surface 模式不用 */ } + + override fun onOutputBufferAvailable(mc: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { + val buf = mc.getOutputBuffer(index) + if (buf == null || info.size == 0) { + mc.releaseOutputBuffer(index, false) + return + } + buf.position(info.offset) + buf.limit(info.offset + info.size) + + if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + configData = ByteArray(info.size).also { buf.get(it) } + mc.releaseOutputBuffer(index, false) + return + } + + val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 + val frame = ByteArray(info.size).also { buf.get(it) } + mc.releaseOutputBuffer(index, false) + + // 部分安卓硬件编码器(如某些高通实现)对 REQUEST_SYNC_FRAME 强制产生的 + // IDR 帧不设置 BUFFER_FLAG_KEY_FRAME,导致 SendLoop 将所有帧视为 P 帧丢弃。 + // 补充扫描 NALU 字节来识别真正的关键帧,确保首帧能顺利发出。 + val actualIsKey = isKey || isNaluKeyframe(frame) + val payload = if (actualIsKey && configData != null) configData!! + frame else frame + YamaBridge.nativeOnH264Frame(payload, 0, payload.size, actualIsKey) + } + + override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) { + Log.e(TAG, "MediaCodec error: $e") + } + + override fun onOutputFormatChanged(mc: MediaCodec, format: MediaFormat) { + Log.i(TAG, "Output format: $format") + } + }) + + codec.start() + mediaCodec = codec + + idrHandler.postDelayed(idrRunnable, 500) + + // Android 14+ requires callback registered before createVirtualDisplay() + mp.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + Log.i(TAG, "MediaProjection stopped") + stopCapture() + stopSelf() + } + }, null) + + virtualDisplay = mp.createVirtualDisplay( + "YAMA", + encW, encH, dpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, null, null + ) + + YamaBridge.nativeSetScreenSize(encW, encH) + + ControlService.physW = physW + ControlService.physH = physH + ControlService.encW = encW + ControlService.encH = encH + + Log.i(TAG, "MediaCodec + VirtualDisplay started ${encW}x${encH}") + } + + private fun buildFmt(w: Int, h: Int, lowLatency: Boolean, baseline: Boolean) = + MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h).apply { + setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) + setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000) + setInteger(MediaFormat.KEY_FRAME_RATE, 30) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + setInteger(MediaFormat.KEY_PRIORITY, 0) + setInteger(MediaFormat.KEY_OPERATING_RATE, 30) + if (lowLatency) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + setInteger(MediaFormat.KEY_LATENCY, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + setInteger(MediaFormat.KEY_LOW_LATENCY, 1) + } + if (baseline) + setInteger(MediaFormat.KEY_PROFILE, + MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) + } + + // 逐级降级:低延迟+Baseline → 低延迟 → 默认,确保真机兼容 + private fun configureEncoder(codec: MediaCodec, w: Int, h: Int): android.view.Surface { + val configs = listOf( + true to true, + true to false, + false to false, + ) + for ((ll, bl) in configs) { + try { + val fmt = buildFmt(w, h, ll, bl) + codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + Log.i(TAG, "Encoder configured: lowLatency=$ll baseline=$bl") + return codec.createInputSurface() + } catch (e: Exception) { + Log.w(TAG, "Encoder config failed (lowLatency=$ll baseline=$bl): $e, retrying…") + try { codec.reset() } catch (_: Exception) {} + } + } + throw RuntimeException("MediaCodec: all encoder configs failed") + } + + private fun stopCapture() { + idrHandler.removeCallbacks(idrRunnable) + runCatching { virtualDisplay?.release() } + // CS-07 fix: stop 和 release 分开,stop 失败不会跳过 release 导致资源泄漏 + runCatching { mediaCodec?.stop() } + runCatching { mediaCodec?.release() } + runCatching { mediaProjection?.stop() } + virtualDisplay = null + mediaCodec = null + mediaProjection = null + } + + // ---------------------------------------------------------------- 辅助函数 + + private fun getPhysicalResolution(): Pair { + val wm = getSystemService(WINDOW_SERVICE) as WindowManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val b = wm.currentWindowMetrics.bounds + Pair(b.width(), b.height()) + } else { + @Suppress("DEPRECATION") + val dm = DisplayMetrics() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealMetrics(dm) + Pair(dm.widthPixels, dm.heightPixels) + } + } + + /** 扫描 Annex-B NALU 流,遇到 NAL type 5(IDR)、7(SPS)、8(PPS) 即判定为关键帧 */ + private fun isNaluKeyframe(data: ByteArray): Boolean { + var i = 0 + while (i + 4 < data.size) { + if (data[i] == 0.toByte() && data[i+1] == 0.toByte() && + data[i+2] == 0.toByte() && data[i+3] == 1.toByte()) { + val nalType = data[i + 4].toInt() and 0x1F + if (nalType == 5 || nalType == 7 || nalType == 8) return true + i += 5 + } else { + i++ + } + } + return false + } + + /** 按长边上限等比缩小,宽高各自 2 对齐(H.264 要求) */ + private fun scaleDown(w: Int, h: Int, maxLong: Int): Pair { + val longSide = maxOf(w, h) + if (longSide <= maxLong) return Pair(w and -2, h and -2) + val scale = maxLong.toDouble() / longSide + return Pair((w * scale).toInt() and -2, (h * scale).toInt() and -2) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel(CHANNEL_ID, "YAMA Service", + NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch) + } + } + + private fun buildNotification(): Notification { + val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + Notification.Builder(this, CHANNEL_ID) + else + @Suppress("DEPRECATION") Notification.Builder(this) + return b.setContentTitle("YAMA") + .setContentText("Screen monitoring active") + .setSmallIcon(android.R.drawable.ic_menu_camera) + .build() + } +} diff --git a/android/app/src/main/java/com/yama/client/ControlService.kt b/android/app/src/main/java/com/yama/client/ControlService.kt new file mode 100644 index 0000000..407b9f3 --- /dev/null +++ b/android/app/src/main/java/com/yama/client/ControlService.kt @@ -0,0 +1,290 @@ +package com.yama.client + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Path +import android.graphics.PointF +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.accessibility.AccessibilityEvent + +/** + * AccessibilityService that injects touch gestures and global key actions + * dispatched by the server (COMMAND_SCREEN_CONTROL / MSG64). + * + * Requires API 24+ for dispatchGesture(); guarded at runtime so the APK + * installs on API 21+ but gesture injection only runs on 24+. + * + * Activation: Settings → Accessibility → YAMA → enable. + */ +@SuppressLint("NewApi") +class ControlService : AccessibilityService() { + + companion object { + const val TAG = "YAMA_CTRL" + + // ── Service instance ───────────────────────────────────────────── + @Volatile var instance: ControlService? = null + + // ── Active foreground window ───────────────────────────────────── + @JvmStatic + @Volatile var activeWindow: String = "Android" + + // ── Screen geometry (set by CaptureService) ────────────────────── + // Server sends coords in encoding space [0, encW) × [0, encH). + // We map them to physical screen space before dispatching. + @Volatile var physW = 1080 + @Volatile var physH = 1920 + @Volatile var encW = 1080 + @Volatile var encH = 1920 + + // ── Windows message constants ──────────────────────────────────── + const val WM_MOUSEMOVE = 0x0200 + const val WM_LBUTTONDOWN = 0x0201 + const val WM_LBUTTONUP = 0x0202 + const val WM_LBUTTONDBLCLK = 0x0203 + const val WM_RBUTTONDOWN = 0x0204 + const val WM_MBUTTONDOWN = 0x0207 + const val WM_MOUSEWHEEL = 0x020A + const val WM_KEYDOWN = 0x0100 + const val WM_SYSKEYDOWN = 0x0104 + + /** + * Called from C++ DataProcess() via JNI on the IO thread. + * Parameters: + * message – Windows WM_* constant (low 16 bits of MSG64.message) + * wParam – MSG64.wParam (key/button flags; high word of MOUSEWHEEL = delta) + * ptX/ptY – MSG64.pt in encoding-space pixels + */ + @JvmStatic + fun onControlEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) { + val svc = instance ?: run { + Log.w(TAG, "ControlService inactive — event 0x${message.toString(16)} dropped") + return + } + // All gesture state lives on the main thread; post there. + svc.handler.post { svc.handleEvent(message, wParam, ptX, ptY) } + } + } + + // ── Screen state receiver ───────────────────────────────────────────── + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> activeWindow = "Locked" + Intent.ACTION_USER_PRESENT -> activeWindow = "Android" + } + } + } + + // ── Main-thread handler ─────────────────────────────────────────────── + private val handler = Handler(Looper.getMainLooper()) + + // ── Left-button drag accumulator ───────────────────────────────────── + private var lbuttonDown = false + private var gestureStart = 0L + // Pairs of (millisecond offset from gesture start, screen point) + private val strokePoints = mutableListOf>() + + // ───────────────────────────────────────────────────────────────────── + // AccessibilityService lifecycle + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + } + registerReceiver(screenReceiver, filter) + Log.i(TAG, "ControlService connected phys=${physW}x${physH} enc=${encW}x${encH}") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + val pkg = event.packageName?.toString() + if (!pkg.isNullOrEmpty()) activeWindow = pkg + } + } + override fun onInterrupt() {} + + override fun onDestroy() { + super.onDestroy() + runCatching { unregisterReceiver(screenReceiver) } + instance = null + Log.i(TAG, "ControlService destroyed") + } + + // ───────────────────────────────────────────────────────────────────── + // Event routing (runs on main thread) + + private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) { + // Scale encoding-space coords to physical screen + val x = if (encW > 0) ptX.toFloat() * physW / encW else ptX.toFloat() + val y = if (encH > 0) ptY.toFloat() * physH / encH else ptY.toFloat() + if (message != WM_MOUSEMOVE) + Log.d(TAG, "event 0x${message.toString(16)} enc=($ptX,$ptY) phys=(${x.toInt()},${y.toInt()})") + + when (message) { + + // ── Left button: accumulate drag path, dispatch on up ───────── + WM_LBUTTONDOWN -> { + lbuttonDown = true + gestureStart = SystemClock.uptimeMillis() + strokePoints.clear() + strokePoints.add(0L to PointF(x, y)) + } + WM_MOUSEMOVE -> { + if (lbuttonDown) { + strokePoints.add((SystemClock.uptimeMillis() - gestureStart) to PointF(x, y)) + } + } + WM_LBUTTONUP -> { + if (!lbuttonDown) return + lbuttonDown = false + val elapsed = SystemClock.uptimeMillis() - gestureStart + strokePoints.add(elapsed to PointF(x, y)) + val down = strokePoints[0].second + val dx = x - down.x + val dy = y - down.y + + // ── System edge-gesture detection ──────────────────────────── + // dispatchGesture() is unreliable for system gestures; use performGlobalAction. + val hEdge = physH * 0.08f // 8% of screen height = top/bottom trigger zone + val wEdge = physW * 0.08f // 8% of screen width = left/right trigger zone + val minH = physH * 0.15f // minimum vertical travel + val minW = physW * 0.20f // minimum horizontal travel + + val fromLeft = down.x < wEdge && dx > minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L + val fromRight = down.x > physW - wEdge && dx < -minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L + val fromTop = down.y < hEdge && dy > minH && Math.abs(dy) > Math.abs(dx) + val fromBottom = down.y > physH - hEdge && dy < -minH && Math.abs(dy) > Math.abs(dx) + + when { + fromLeft || fromRight -> { + Log.d(TAG, "Edge-swipe back (fromLeft=$fromLeft)") + performGlobalAction(GLOBAL_ACTION_BACK) + } + fromTop -> { + // Right half of screen → quick settings; left half → notifications + if (down.x > physW * 0.5f) { + Log.d(TAG, "Top-swipe quick settings") + performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS) + } else { + Log.d(TAG, "Top-swipe notifications") + performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS) + } + } + fromBottom -> { + // Slow swipe (≥400 ms) or very long travel → Recents; quick swipe → Home + if (elapsed >= 400L || Math.abs(dy) > physH * 0.40f) { + Log.d(TAG, "Bottom-swipe recents (elapsed=${elapsed}ms)") + performGlobalAction(GLOBAL_ACTION_RECENTS) + } else { + Log.d(TAG, "Bottom-swipe home (elapsed=${elapsed}ms)") + performGlobalAction(GLOBAL_ACTION_HOME) + } + } + dx * dx + dy * dy < 100f -> // 移动 < 10px → 单击 + dispatchTap(down.x, down.y) + else -> + dispatchTouchGesture(strokePoints) + } + // 手势执行后立即请求关键帧,让 decoder 尽快看到屏幕变化 + CaptureService.requestIdr() + strokePoints.clear() + } + + // ── Double-click → two rapid taps ──────────────────────────── + WM_LBUTTONDBLCLK -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + val p = singlePointPath(x, y) + val gesture = GestureDescription.Builder() + .addStroke(GestureDescription.StrokeDescription(p, 0L, 80L)) + .addStroke(GestureDescription.StrokeDescription(p, 200L, 80L)) + .build() + dispatchGesture(gesture, null, null) + } + + // ── Right-click → long press (600 ms) ──────────────────────── + WM_RBUTTONDOWN -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + val stroke = GestureDescription.StrokeDescription(singlePointPath(x, y), 0L, 600L) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + } + + // ── Scroll wheel → vertical swipe ───────────────────────────── + WM_MOUSEWHEEL -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + // High 16 bits of wParam hold the signed delta (+120 = up, -120 = down) + val delta = (wParam.toInt() ushr 16).toShort().toInt() + // Wheel-up (+delta) = user wants content above = finger swipes DOWN (+y) + val dy = if (delta > 0) 400f else -400f + val path = Path().apply { moveTo(x, y); lineTo(x, y + dy) } + val stroke = GestureDescription.StrokeDescription(path, 0L, 300L) + dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null) + CaptureService.requestIdr() + } + + // ── Keyboard → global actions only ─────────────────────────── + WM_KEYDOWN, WM_SYSKEYDOWN -> handleKeyDown(wParam.toInt() and 0xFFFF) + } + } + + // ───────────────────────────────────────────────────────────────────── + // Helpers + + private fun dispatchTouchGesture(points: List>) { + if (points.isEmpty()) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + + val path = Path() + path.moveTo(points[0].second.x, points[0].second.y) + for (i in 1 until points.size) { + path.lineTo(points[i].second.x, points[i].second.y) + } + // Minimum 50 ms so Android registers the gesture; use actual elapsed for drags. + val duration = maxOf(points.last().first, 50L) + val stroke = GestureDescription.StrokeDescription(path, 0L, duration) + val ok = dispatchGesture( + GestureDescription.Builder().addStroke(stroke).build(), + object : GestureResultCallback() { + override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Gesture ok") } + override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Gesture cancelled") } + }, + null + ) + if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)") + } + + private fun handleKeyDown(vk: Int) { + when (vk) { + 0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc + 0x24 -> performGlobalAction(GLOBAL_ACTION_HOME) // VK_HOME + 0x5D -> performGlobalAction(GLOBAL_ACTION_RECENTS) // VK_APPS + 0x2C -> performGlobalAction(GLOBAL_ACTION_TAKE_SCREENSHOT) // PrtSc + } + } + + private fun dispatchTap(x: Float, y: Float) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y) } + val stroke = GestureDescription.StrokeDescription(path, 0L, 100L) + val ok = dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), + object : GestureResultCallback() { + override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Tap ok") } + override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Tap cancelled") } + }, null) + if (!ok) Log.e(TAG, "dispatchTap returned false") + } + + private fun singlePointPath(x: Float, y: Float) = Path().apply { moveTo(x, y); lineTo(x + 1f, y) } +} diff --git a/android/app/src/main/java/com/yama/client/MainActivity.kt b/android/app/src/main/java/com/yama/client/MainActivity.kt new file mode 100644 index 0000000..7bd4d83 --- /dev/null +++ b/android/app/src/main/java/com/yama/client/MainActivity.kt @@ -0,0 +1,65 @@ +package com.yama.client + +import android.app.Activity +import android.content.Intent +import android.media.projection.MediaProjectionManager +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity + +class MainActivity : AppCompatActivity() { + + private val serverIp = "91.99.165.207" + private val serverPort = 443 + + private lateinit var projectionManager: MediaProjectionManager + + companion object { + const val REQUEST_MEDIA_PROJECTION = 1001 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // MA-01 fix: 系统重建 Activity 时 savedInstanceState != null, + // 此时 CaptureService 可能已在运行,不重复弹权限对话框 + if (savedInstanceState != null) { finish(); return } + projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_MEDIA_PROJECTION) { + if (resultCode == Activity.RESULT_OK && data != null) { + Log.i(CaptureService.TAG, "MediaProjection permission granted") + val intent = Intent(this, CaptureService::class.java).apply { + putExtra(CaptureService.EXTRA_SERVER_IP, serverIp) + putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort) + putExtra(CaptureService.EXTRA_PROJECTION_RESULT, resultCode) + putExtra(CaptureService.EXTRA_PROJECTION_DATA, data) + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } else { + Log.w(CaptureService.TAG, "MediaProjection permission denied, starting without capture") + val intent = Intent(this, CaptureService::class.java).apply { + putExtra(CaptureService.EXTRA_SERVER_IP, serverIp) + putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort) + } + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + } + finish() + } + } + + override fun onDestroy() { + super.onDestroy() + } +} diff --git a/android/app/src/main/java/com/yama/client/YamaBridge.kt b/android/app/src/main/java/com/yama/client/YamaBridge.kt new file mode 100644 index 0000000..37e5d6d --- /dev/null +++ b/android/app/src/main/java/com/yama/client/YamaBridge.kt @@ -0,0 +1,26 @@ +package com.yama.client + +object YamaBridge { + init { + System.loadLibrary("yama") + } + + external fun nativeInit( + serverIp: String, + serverPort: Int, + androidId: String, + deviceModel: String, + androidVersion: String, + screenRes: String, + username: String, + apkPath: String + ): Int + + external fun nativeStop() + + /** MediaCodec 配置完成后调用,告知 C++ 实际捕获分辨率 */ + external fun nativeSetScreenSize(width: Int, height: Int) + + /** MediaCodec 每输出一帧 H.264 NALU 时调用 */ + external fun nativeOnH264Frame(data: ByteArray, offset: Int, size: Int, isKeyframe: Boolean) +} diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80b730f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..6a8fbfd Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4f910de Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..972a59c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..57694a9 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..563dd6a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fc7d1d1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..0cbcb3d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2f87567 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4147c74 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fbe1dec Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..88f4a40 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #F2F2F2 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c835da1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + YAMA + YAMA remote control service + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9dcb78f --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..8cc6e68 --- /dev/null +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,7 @@ + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..105d385 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,4 @@ +plugins { + id 'com.android.application' version '8.10.1' apply false + id 'org.jetbrains.kotlin.android' version '2.1.21' apply false +} diff --git a/android/build_apk.sh b/android/build_apk.sh new file mode 100644 index 0000000..1f8691d --- /dev/null +++ b/android/build_apk.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# Build YAMA Android APK +# Works on WSL, native Linux, macOS +# Usage: ./build_apk.sh [debug|release] (default: debug) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ── Detect WSL ─────────────────────────────────────────────────────────────── +IS_WSL=false +grep -qi microsoft /proc/version 2>/dev/null && IS_WSL=true + +# ── Find Android SDK ───────────────────────────────────────────────────────── +if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then + SDK_DIR="$ANDROID_HOME" +elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then + SDK_DIR="$ANDROID_SDK_ROOT" +elif $IS_WSL; then + # Auto-detect from Windows %USERPROFILE% + WIN_USER=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d '\r') + WSL_USER=$(wslpath "$WIN_USER" 2>/dev/null || \ + echo "$WIN_USER" | sed 's|\\|/|g; s|^\([A-Za-z]\):|/mnt/\L\1|') + SDK_DIR="${WSL_USER}/AppData/Local/Android/Sdk" +elif [ -d "$HOME/Android/Sdk" ]; then + SDK_DIR="$HOME/Android/Sdk" +elif [ -d "$HOME/Library/Android/sdk" ]; then + SDK_DIR="$HOME/Library/Android/sdk" +else + SDK_DIR="" +fi + +if [ -z "${SDK_DIR:-}" ] || [ ! -d "$SDK_DIR" ]; then + echo "ERROR: Android SDK not found." + echo "" + echo " WSL: auto-detected from Windows %USERPROFILE%" + echo " or set: export ANDROID_HOME=/mnt/c/Users//AppData/Local/Android/Sdk" + echo " Native Linux: install SDK to ~/Android/Sdk or set ANDROID_HOME" + exit 1 +fi +echo "SDK : $SDK_DIR" + +# ── Check Java 17+ ─────────────────────────────────────────────────────────── +if ! command -v java &>/dev/null; then + echo "ERROR: Java not found." + echo " sudo apt install openjdk-17-jdk" + exit 1 +fi +JAVA_VER=$(java -version 2>&1 | grep -oP '(?<=version ")[0-9]+' | head -1) +if [ "${JAVA_VER:-0}" -lt 17 ]; then + echo "ERROR: Java 17+ required (found Java ${JAVA_VER:-unknown})." + echo " sudo apt install openjdk-17-jdk" + exit 1 +fi +echo "Java: $(java -version 2>&1 | head -1)" + +# ── Check cmake ─────────────────────────────────────────────────────────────── +# The SDK ships cmake.exe (Windows-only); on Linux/WSL we need system cmake. +if ! command -v cmake &>/dev/null; then + echo "ERROR: cmake not found." + echo " sudo apt install cmake" + echo "" + echo " The SDK's cmake is a Windows .exe and cannot run on Linux/WSL." + exit 1 +fi +echo "CMake: $(cmake --version | head -1)" + +# ── Check ninja ────────────────────────────────────────────────────────────── +if ! command -v ninja &>/dev/null; then + echo "ERROR: ninja not found." + echo " sudo apt install ninja-build" + exit 1 +fi + +# ── WSL: create Linux stubs for Windows build-tools ────────────────────────── +# Windows SDK ships .exe files only; Linux Gradle needs plain-name wrappers. +# WSL binfmt_misc executes .exe files transparently from bash. +# We FORCE-WRITE stubs (don't rely on prior state) so broken/dir artifacts +# from previous attempts don't block the check. +if $IS_WSL; then + BT_DIR=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \ + | sort -V | tail -1) + # Stub every installed build-tools version — the project may reference any of them + BT_VERSIONS=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \ + | sort -V) + LATEST_BT="" + if [ -z "$BT_VERSIONS" ]; then + echo "WARNING: build-tools directory not found under $SDK_DIR/build-tools" + else + for BT_DIR in $BT_VERSIONS; do + LATEST_BT="$BT_DIR" # last in sorted list = newest + VER=$(basename "$BT_DIR") + + # 1. Wrap every .exe found — covers aapt, aapt2, aidl, split-select, etc. + for EXE in "${BT_DIR}"/*.exe; do + [ -f "$EXE" ] || continue + TOOL=$(basename "$EXE" .exe) + BIN="${BT_DIR}/${TOOL}" + [ -e "$BIN" ] && rm -rf "$BIN" + printf '#!/bin/sh\nexec "%s" "$@"\n' "$EXE" > "$BIN" + chmod +x "$BIN" + echo "Stubbed: ${VER}/${TOOL} → $(basename $EXE)" + done + + # 2. Ensure tools AGP validates that may not have .exe versions + for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do + BIN="${BT_DIR}/${TOOL}" + if [ ! -f "$BIN" ]; then + printf '#!/bin/sh\nexit 0\n' > "$BIN" + chmod +x "$BIN" + echo "Noop: ${VER}/${TOOL}" + fi + done + done + fi + + # If build.gradle requests a buildToolsVersion that isn't installed, create a + # stub directory pointing all tools to the latest installed version. + if [ -n "$LATEST_BT" ]; then + REQ_VER=$(grep -oP '(?<=buildToolsVersion\s")[^"]+' \ + "$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true) + if [ -n "$REQ_VER" ]; then + REQ_DIR="$SDK_DIR/build-tools/$REQ_VER" + if [ ! -d "$REQ_DIR" ]; then + echo "build-tools $REQ_VER not installed — creating stub dir from $(basename $LATEST_BT)" + mkdir -p "$REQ_DIR" + # Copy source.properties so AGP recognises the version + if [ -f "${LATEST_BT}/source.properties" ]; then + sed "s/Pkg.Revision=.*/Pkg.Revision=$REQ_VER/" \ + "${LATEST_BT}/source.properties" > "${REQ_DIR}/source.properties" + else + printf 'Pkg.Desc=Android SDK Build-tools\nPkg.Revision=%s\n' \ + "$REQ_VER" > "${REQ_DIR}/source.properties" + fi + for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do + BIN="${REQ_DIR}/${TOOL}" + SRC="${LATEST_BT}/${TOOL}" # already stubbed above + if [ -f "$SRC" ]; then + cp "$SRC" "$BIN" + chmod +x "$BIN" + else + printf '#!/bin/sh\nexit 0\n' > "$BIN" + chmod +x "$BIN" + fi + echo "Forwarded: ${REQ_VER}/${TOOL} → $(basename $LATEST_BT)" + done + fi + fi + fi + + # ── WSL: stub SDK cmake ──────────────────────────────────────────────────── + # AGP resolves cmake from SDK cmake//bin/cmake (a Windows .exe). + # Create plain-name stubs that call system cmake/ninja instead. + SYS_CMAKE=$(command -v cmake 2>/dev/null || true) + SYS_NINJA=$(command -v ninja 2>/dev/null || true) + CMAKE_VERSIONS=$(find "$SDK_DIR/cmake" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \ + | sort -V || true) + for SDK_CMAKE_DIR in $CMAKE_VERSIONS; do + CVER=$(basename "$SDK_CMAKE_DIR") + CBIN="${SDK_CMAKE_DIR}/bin" + mkdir -p "$CBIN" + for TOOL in cmake ninja; do + BIN="${CBIN}/${TOOL}" + [ -e "$BIN" ] && rm -rf "$BIN" + if [ "$TOOL" = "cmake" ] && [ -n "$SYS_CMAKE" ]; then + printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_CMAKE" > "$BIN" + echo "CMake stub: ${CVER}/cmake → $SYS_CMAKE" + elif [ "$TOOL" = "ninja" ] && [ -n "$SYS_NINJA" ]; then + printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_NINJA" > "$BIN" + echo "CMake stub: ${CVER}/ninja → $SYS_NINJA" + else + printf '#!/bin/sh\nexit 0\n' > "$BIN" + echo "CMake noop: ${CVER}/${TOOL}" + fi + chmod +x "$BIN" + done + done + + # ── WSL: verify Linux NDK ───────────────────────────────────────────────── + # Windows NDK (.exe tools) cannot compile from WSL — clang.exe doesn't + # understand /mnt/c/ Linux paths. A real Linux NDK is required. + NDK_DIR="" + # 1. Honour ANDROID_NDK_HOME if set + if [ -n "${ANDROID_NDK_HOME:-}" ] && \ + [ -f "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" ]; then + NDK_DIR="$ANDROID_NDK_HOME" + fi + # 2. Scan SDK ndk/ for a version that has real Linux ELF binaries + if [ -z "$NDK_DIR" ]; then + for CANDIDATE in $(find "$SDK_DIR/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V); do + CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" + if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then + NDK_DIR="$CANDIDATE" + fi + done + fi + # 3. Check common install location ~/android-ndk/ + if [ -z "$NDK_DIR" ]; then + for CANDIDATE in "$HOME/android-ndk" "$HOME/android-ndk-linux"; do + CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" + if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then + NDK_DIR="$CANDIDATE" + break + fi + done + fi + if [ -z "$NDK_DIR" ]; then + # Determine the required NDK version from build.gradle for the download hint + REQ_NDK=$(grep -oP '(?<=ndkVersion\s")[^"]+' \ + "$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true) + NDK_MAJ=${REQ_NDK%%.*} + echo "" + echo "ERROR: Linux NDK not found." + echo " The Windows NDK cannot compile native code in WSL." + echo "" + # Look up the exact Linux zip name from the repository manifest + ZIP_NAME=$(curl -s 'https://dl.google.com/android/repository/repository2-3.xml' 2>/dev/null \ + | grep -A 10 "ndk;${REQ_NDK}" | grep 'linux\.zip' | grep -oP '(?<=)[^<]+' | head -1) + ZIP_NAME="${ZIP_NAME:-android-ndk-r${NDK_MAJ:-30}-linux.zip}" + echo " Install the Linux NDK (requires ~600 MB):" + echo " wget https://dl.google.com/android/repository/${ZIP_NAME}" + echo " unzip ${ZIP_NAME} -d \$HOME" + echo " mv \$HOME/\$(basename ${ZIP_NAME} .zip) \$HOME/android-ndk" + echo "" + echo " Then re-run ./build_apk.sh" + echo " (Or set ANDROID_NDK_HOME to your Linux NDK path)" + exit 1 + fi + echo "NDK : $NDK_DIR" +fi + +# ── Write local.properties (backup & restore to avoid clobbering Android Studio) ── +PROPS="$SCRIPT_DIR/local.properties" +PROPS_BAK="$SCRIPT_DIR/local.properties.bak" +[ -f "$PROPS" ] && cp "$PROPS" "$PROPS_BAK" + +cat > "$PROPS" <> "$PROPS" +fi +echo "local.properties written (original backed up to local.properties.bak)" + +restore_props() { + if [ -f "$PROPS_BAK" ]; then + mv "$PROPS_BAK" "$PROPS" + echo "local.properties restored" + else + rm -f "$PROPS" + fi +} +trap restore_props EXIT + +# ── Build ───────────────────────────────────────────────────────────────────── +BUILD_TYPE="${1:-release}" +case "$BUILD_TYPE" in + release) TASK="assembleRelease" ;; + debug) TASK="assembleDebug" ;; + clean) + chmod +x gradlew + ./gradlew clean + rm -rf "$SCRIPT_DIR/app/.cxx" + rm -f "$SCRIPT_DIR/ghost.apk" "$SCRIPT_DIR/ghost-debug.apk" + echo "Clean done." + exit 0 + ;; + *) echo "Usage: $0 [release|debug|clean]"; exit 1 ;; +esac + +# ── Release: keystore check ─────────────────────────────────────────────────── +if [ "$BUILD_TYPE" = "release" ]; then + JKS="$SCRIPT_DIR/yama-release.jks" + if [ ! -f "$JKS" ]; then + echo "ERROR: $JKS not found." + echo " Generate once with:" + echo " keytool -genkeypair -keystore android/yama-release.jks \\" + echo " -alias yama -keyalg RSA -keysize 2048 -validity 10000 \\" + echo " -dname 'CN=YAMA,O=Internal,C=CN'" + exit 1 + fi + if [ -z "${YAMA_PWD:-}" ]; then + read -rsp "Keystore password: " YAMA_PWD + echo + export YAMA_PWD + fi +fi + +echo "" +echo "Building $BUILD_TYPE APK..." +chmod +x gradlew +./gradlew "$TASK" + +# ── Output ──────────────────────────────────────────────────────────────────── +APK=$(find "app/build/outputs/apk/${BUILD_TYPE}" -name "*.apk" 2>/dev/null | head -1 || true) +if [ -n "$APK" ]; then + [ "$BUILD_TYPE" = "release" ] && OUT="$SCRIPT_DIR/ghost.apk" || OUT="$SCRIPT_DIR/ghost-${BUILD_TYPE}.apk" + cp "$APK" "$OUT" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "APK: $OUT" + if $IS_WSL; then + WIN=$(wslpath -w "$OUT" 2>/dev/null || true) + [ -n "$WIN" ] && echo "WIN: $WIN" + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +fi diff --git a/android/build_zstd_android.sh b/android/build_zstd_android.sh new file mode 100644 index 0000000..afcd1aa --- /dev/null +++ b/android/build_zstd_android.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# build_zstd_android.sh - 为 Android 交叉编译 libzstd.a +# 与 linux/lib/libzstd.a 版本对齐(1.5.6) +# +# 用法: +# ./build_zstd_android.sh # 自动查找 NDK +# ANDROID_NDK=/path/to/ndk ./build_zstd_android.sh +# +# 产出: +# app/src/main/cpp/lib/arm64-v8a/libzstd.a +# app/src/main/cpp/lib/armeabi-v7a/libzstd.a + +set -euo pipefail +cd "$(dirname "$0")" + +ZSTD_VERSION="1.5.6" +ZSTD_TAR="zstd-${ZSTD_VERSION}.tar.gz" +ZSTD_URL="https://github.com/facebook/zstd/releases/download/v${ZSTD_VERSION}/${ZSTD_TAR}" +ZSTD_DIR="zstd-${ZSTD_VERSION}" + +# --- 定位 NDK --- +if [[ -z "${ANDROID_NDK:-}" ]]; then + for candidate in \ + "$HOME/android-ndk-r27c" \ + "$HOME/Library/Android/sdk/ndk/$(ls "$HOME/Library/Android/sdk/ndk" 2>/dev/null | sort -V | tail -1)" \ + "$HOME/Android/Sdk/ndk/$(ls "$HOME/Android/Sdk/ndk" 2>/dev/null | sort -V | tail -1)"; do + if [[ -f "${candidate}/build/cmake/android.toolchain.cmake" ]]; then + ANDROID_NDK="$candidate"; break + fi + done +fi +[[ -z "${ANDROID_NDK:-}" ]] && { echo "Error: ANDROID_NDK not set"; exit 1; } +TOOLCHAIN="$ANDROID_NDK/build/cmake/android.toolchain.cmake" +echo "NDK: $ANDROID_NDK" + +# --- 下载 zstd 源码 --- +if [[ ! -d "$ZSTD_DIR" ]]; then + echo "Downloading zstd $ZSTD_VERSION..." + wget -q "$ZSTD_URL" -O "$ZSTD_TAR" + tar xf "$ZSTD_TAR" + rm "$ZSTD_TAR" +fi + +OUT_BASE="app/src/main/cpp/lib" +ABIS=("arm64-v8a" "armeabi-v7a") +for ABI in "${ABIS[@]}"; do + BUILD_DIR="zstd_build/$ABI" + OUT_DIR="$OUT_BASE/$ABI" + rm -rf "$BUILD_DIR" + echo + echo "===== zstd $ABI =====" + cmake \ + -B "$BUILD_DIR" -S "$ZSTD_DIR/build/cmake" \ + -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \ + -DANDROID_ABI="$ABI" \ + -DANDROID_PLATFORM=android-21 \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_FLAGS="-Os -g0" \ + -DZSTD_BUILD_STATIC=ON \ + -DZSTD_BUILD_SHARED=OFF \ + -DZSTD_BUILD_PROGRAMS=OFF \ + -DZSTD_BUILD_TESTS=OFF \ + -DZSTD_LEGACY_SUPPORT=0 + cmake --build "$BUILD_DIR" --config Release -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu)" + mkdir -p "$OUT_DIR" + cp "$BUILD_DIR/lib/libzstd.a" "$OUT_DIR/libzstd.a" + # strip residual debug symbols from the archive + STRIP="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip" + [[ -f "$STRIP" ]] && "$STRIP" --strip-debug "$OUT_DIR/libzstd.a" + ls -lh "$OUT_DIR/libzstd.a" +done + +rm -rf zstd_build "$ZSTD_DIR" + +echo +echo "===== Done =====" +echo "libzstd.a 已写入 app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/" diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2a55399 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.enableJetifier=true +org.gradle.jvmargs=-Xmx2048m diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..19a6bde --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..adff685 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..ea0b6af --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "YAMA" +include(":app") diff --git a/android/yama-release.jks b/android/yama-release.jks new file mode 100644 index 0000000..95cc4dc Binary files /dev/null and b/android/yama-release.jks differ diff --git a/client/IOCPClient.cpp b/client/IOCPClient.cpp index 525a262..4c659b6 100644 --- a/client/IOCPClient.cpp +++ b/client/IOCPClient.cpp @@ -30,10 +30,12 @@ inline int WSAGetLastError() #define Z_SUCCESS(p) (!Z_FAILED(p)) #else #include "common/zstd_wrapper.h" -#ifdef _WIN64 -#pragma comment(lib, "zstd/zstd_x64.lib") -#else -#pragma comment(lib, "zstd/zstd.lib") +#ifdef _WIN32 +# ifdef _WIN64 +# pragma comment(lib, "zstd/zstd_x64.lib") +# else +# pragma comment(lib, "zstd/zstd.lib") +# endif #endif #define Z_FAILED(p) ZSTD_isError(p) #define Z_SUCCESS(p) (!Z_FAILED(p)) diff --git a/common/commands.h b/common/commands.h index e96bef5..eebe1de 100644 --- a/common/commands.h +++ b/common/commands.h @@ -34,7 +34,14 @@ #define AUTO_TICK(p, q) #define STOP_TICK #define OutputDebugStringA(p) printf(p) - +#define decrypt_v7 decrypt_v1 +#define decrypt_v8 decrypt_v2 +#define decrypt_v9 decrypt_v3 +#define decrypt_v10 decrypt_v4 +#define encrypt_v7 encrypt_v1 +#define encrypt_v8 encrypt_v2 +#define encrypt_v9 encrypt_v3 +#define encrypt_v10 encrypt_v4 #include #define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000)) @@ -653,6 +660,7 @@ enum { CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行 CLIENT_TYPE_LINUX = 6, // LINUX 客户端 CLIENT_TYPE_MACOS = 7, // MACOS 客户端 + CLIENT_TYPE_ANDROID = 8, // ANDROID 客户端 }; enum { @@ -680,6 +688,8 @@ inline const char* GetClientType(int typ) return "LNX"; case CLIENT_TYPE_MACOS: return "MAC"; + case CLIENT_TYPE_ANDROID: + return "APK"; default: return "DLL"; } diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index 032d801..59386b3 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -1485,13 +1485,19 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName if (TryMigrateClientMetadata(id, strPCName, path)) { modify = true; } - CString loc = m_ClientMap->GetClientMapData(id, MAP_LOCATION); - if (loc.IsEmpty()) { - loc = v[RES_CLIENT_LOC].c_str(); - if (loc.IsEmpty()) { - loc = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString()).c_str(); - needConvert = !m_HasLocDB; - } + // 优先级:① 客户端刚发来的有效地理位置(非空非"?") + // ② 服务端缓存(排除历史遗留的"?"无效值) + // ③ 服务端按连接 IP 自行查询 + CString clientLoc = v[RES_CLIENT_LOC].c_str(); + CString cachedLoc = m_ClientMap->GetClientMapData(id, MAP_LOCATION); + CString loc; + if (!clientLoc.IsEmpty() && clientLoc != "?") { + loc = clientLoc; + } else if (!cachedLoc.IsEmpty() && cachedLoc != "?") { + loc = cachedLoc; + } else { + loc = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString()).c_str(); + needConvert = !m_HasLocDB; } // TODO: Remove SafeUtf8ToAnsi after migrating to UTF-8 if (needConvert) @@ -3478,7 +3484,7 @@ void CMy2015RemoteDlg::CheckHeartbeat() { CLock lock(m_cs); auto now = time(0); - int HEARTBEAT_TIMEOUT = max(60, m_settings.ReportInterval * 3); + int HEARTBEAT_TIMEOUT = max(120, m_settings.ReportInterval * 3); // 收集需要删除的 context(避免遍历时修改 vector) std::vector toRemove; @@ -10304,6 +10310,17 @@ context* CMy2015RemoteDlg::FindHostByIP(const std::string& ip) return NULL; } +context* CMy2015RemoteDlg::FindHostByClientID(uint64_t clientID) +{ + EnterCriticalSection(&m_cs); + auto it = m_ClientIndex.find(clientID); + context* ctx = nullptr; + if (it != m_ClientIndex.end() && it->second < m_HostList.size()) + ctx = m_HostList[it->second]; + LeaveCriticalSection(&m_cs); + return ctx; +} + uint64_t CMy2015RemoteDlg::FindClientIDByIP(const std::string& ip) { CString clientIP(ip.c_str()); diff --git a/server/2015Remote/2015RemoteDlg.h b/server/2015Remote/2015RemoteDlg.h index 5a2e071..a493fa2 100644 --- a/server/2015Remote/2015RemoteDlg.h +++ b/server/2015Remote/2015RemoteDlg.h @@ -368,6 +368,7 @@ public: CGridDialog * m_gridDlg = NULL; std::vector m_DllList; context* FindHostByIP(const std::string& ip); + context* FindHostByClientID(uint64_t clientID); // 线程安全:通过 clientID 精确查主连接 uint64_t FindClientIDByIP(const std::string& ip); // 线程安全:在锁内获取ID void InjectTinyRunDll(const std::string& ip, int pid); NOTIFYICONDATA m_Nid; diff --git a/server/2015Remote/ScreenSpyDlg.cpp b/server/2015Remote/ScreenSpyDlg.cpp index 3366e9e..7198477 100644 --- a/server/2015Remote/ScreenSpyDlg.cpp +++ b/server/2015Remote/ScreenSpyDlg.cpp @@ -18,6 +18,8 @@ #include #include // for uint16_t +extern CMy2015RemoteDlg* g_2015RemoteDlg; + extern "C" uint32_t licenseGetBuildTag() { volatile uint32_t tag = 0xC0DE2026u; return tag; } #include #include // for std::mutex, std::lock_guard @@ -225,7 +227,15 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE if (WebService().IsRunning() && !WebService().IsMfcTriggered(m_ClientID)) { int width = m_BitmapInfor_Full->bmiHeader.biWidth; int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight); - WebService().NotifyResolutionChange(m_ClientID, width, height); + bool topDown = (m_BitmapInfor_Full->bmiHeader.biClrImportant == 1); + // m_ContextObject is the screen sub-connection; client_type lives on the + // main login connection. Look it up via the peer IP (same pattern as GetClientEncoding). + std::string clientType; + if (g_2015RemoteDlg) { + context* main = g_2015RemoteDlg->FindHostByClientID(m_ClientID); + if (main) clientType = main->GetAdditionalData(RES_CLIENT_TYPE).GetString(); + } + WebService().NotifyResolutionChange(m_ClientID, width, height, topDown, clientType); // 透传客户端初始的音频开/关状态给 web,让前端按钮显示正确 WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0); } @@ -953,7 +963,6 @@ VOID CScreenSpyDlg::OnClose() CWnd* parent = GetParent(); if (parent) parent->SendMessage(WM_CHILD_CLOSED, (WPARAM)this, 0); - extern CMy2015RemoteDlg *g_2015RemoteDlg; if(g_2015RemoteDlg) g_2015RemoteDlg->RemoveRemoteWindow(GetSafeHwnd()); @@ -1126,7 +1135,13 @@ VOID CScreenSpyDlg::OnReceiveComplete() if (m_bIsWebSession && WebService().IsRunning()) { int width = m_BitmapInfor_Full->bmiHeader.biWidth; int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight); - WebService().NotifyResolutionChange(m_ClientID, width, height); + bool topDown = (m_BitmapInfor_Full->bmiHeader.biClrImportant == 1); + std::string clientType; + if (g_2015RemoteDlg) { + context* main = g_2015RemoteDlg->FindHostByClientID(m_ClientID); + if (main) clientType = main->GetAdditionalData(RES_CLIENT_TYPE).GetString(); + } + WebService().NotifyResolutionChange(m_ClientID, width, height, topDown, clientType); } break; } diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 5b4a92c..870d96a 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -694,13 +694,17 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t // Get screen dimensions + audio state from device info cache (may not be ready) int width = 0, height = 0; int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底) + bool top_down = false; + std::string client_type; { std::lock_guard lock(m_DeviceCacheMutex); auto it = m_DeviceCache.find(device_id); if (it != m_DeviceCache.end()) { - width = it->second->screen_width; - height = it->second->screen_height; + width = it->second->screen_width; + height = it->second->screen_height; audio_enabled = it->second->audio_enabled; + top_down = it->second->top_down; + client_type = it->second->client_type; } } @@ -713,6 +717,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t if (width > 0 && height > 0) { res["width"] = width; res["height"] = height; + res["top_down"] = top_down; + if (!client_type.empty()) res["client_type"] = client_type; } if (audio_enabled >= 0) { res["audio_enabled"] = (audio_enabled != 0); @@ -1712,7 +1718,7 @@ void CWebService::BroadcastH264Frame(uint64_t device_id, const uint8_t* data, si } } -void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int height) { +void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int height, bool top_down, const std::string& client_type) { if (m_bStopping) return; // Update cache @@ -1723,8 +1729,10 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig m_DeviceCache[device_id] = std::make_shared(); it = m_DeviceCache.find(device_id); } - it->second->screen_width = width; + it->second->screen_width = width; it->second->screen_height = height; + it->second->top_down = top_down; + if (!client_type.empty()) it->second->client_type = client_type; } // Notify watching clients @@ -1733,6 +1741,8 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig res["id"] = device_id; res["width"] = width; res["height"] = height; + res["top_down"] = top_down; + if (!client_type.empty()) res["client_type"] = client_type; Json::StreamWriterBuilder builder; builder["indentation"] = ""; diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index 3da10b9..4e664cf 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -55,6 +55,8 @@ struct WebDeviceInfo { int screen_width; int screen_height; bool online; + bool top_down = false; // true = Android/top-down H.264, no flip needed in browser + std::string client_type; // RES_CLIENT_TYPE: "APK"=Android, "EXE"=Windows, "LNX"=Linux, "MAC"=macOS // 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来),0=关,1=开 int audio_enabled = -1; @@ -98,7 +100,7 @@ public: void CacheKeyframe(uint64_t device_id, const uint8_t* data, size_t len); // Resolution change notification - void NotifyResolutionChange(uint64_t device_id, int width, int height); + void NotifyResolutionChange(uint64_t device_id, int width, int height, bool top_down = false, const std::string& client_type = ""); // Audio enable/disable notification — pushes current state to all web // clients watching this device and caches it for newcomers. diff --git a/server/web/index.html b/server/web/index.html index 545352e..07da027 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -783,6 +783,21 @@ #screen-page:-webkit-full-screen .toolbar-toggle { display: flex; } + /* Android portrait mode: full-viewport canvas, no top toolbar */ + #screen-page.android-portrait { overflow: hidden; } + #screen-page.android-portrait .screen-toolbar { display: none !important; } + #screen-page.android-portrait .canvas-container { + height: 100dvh !important; height: 100vh !important; + padding-top: 0 !important; + align-items: center !important; + } + #screen-page.android-portrait #screen-canvas { + max-width: 100vw !important; + max-height: 100dvh !important; max-height: 100vh !important; + } + #screen-page.android-portrait .toolbar-toggle { display: flex !important; } + #screen-page.android-portrait .quick-controls { display: none !important; } + .compat-warning { background: linear-gradient(90deg, #ff9800, #f57c00); color: #000; @@ -1063,6 +1078,33 @@ } .input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); } .input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); } + + /* Android virtual navigation bar - overlays bottom of canvas when controlling Android */ + .android-navbar { + position: absolute; + bottom: 0; left: 0; right: 0; + height: 44px; + display: none; + align-items: center; + justify-content: center; + gap: 56px; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 102; + user-select: none; + } + .android-navbar.visible { display: flex; } + .android-navbar .nav-btn { + width: 44px; height: 44px; + display: flex; align-items: center; justify-content: center; + border: none; background: transparent; + cursor: pointer; opacity: 0.85; + transition: opacity 0.15s; + -webkit-tap-highlight-color: transparent; + padding: 0; + } + .android-navbar .nav-btn:active { opacity: 0.4; } /* Mobile responsive */ @media (max-width: 768px) { .page { @@ -1195,6 +1237,18 @@
+ +
+ + + +
@@ -1310,6 +1364,8 @@ let bwBytesAccum = 0; // current-second byte accumulator let bwBytesPerSec = 0; // last second's throughput (bytes/sec) let currentWidth = 0, currentHeight = 0; // captured at frame decode time + let isTopDown = false; // true = Android/MediaCodec top-down H.264, skip vertical flip + let isAndroidRemote = false; // true = remote is Android (client_type==="APK") → direct-touch on mobile const canvas = document.getElementById('screen-canvas'); const ctx = canvas.getContext('2d'); @@ -1564,6 +1620,10 @@ // Resolution may not be available yet (first connection) if (msg.width && msg.height) { updateScreenStatus('connected'); + isTopDown = msg.top_down === true; + isAndroidRemote = msg.client_type === 'APK'; + updateAndroidNavbar(); + if (isTouchDevice) updateUIForOrientation(); initDecoder(msg.width, msg.height); } else { // Wait for resolution_changed message @@ -1609,6 +1669,15 @@ break; case 'resolution_changed': updateScreenStatus('connected'); + isTopDown = msg.top_down === true; + if (msg.client_type !== undefined) { + isAndroidRemote = msg.client_type === 'APK'; + updateAndroidNavbar(); + // Refresh cursor overlay and immersive layout + document.getElementById('cursor-overlay').classList.toggle( + 'active', controlEnabled && isTouchDevice && !isAndroidRemote); + if (isTouchDevice) updateUIForOrientation(); + } initDecoder(msg.width, msg.height); break; case 'cursor': @@ -1694,8 +1763,13 @@ // Update input shortcuts position after canvas resize requestAnimationFrame(updateInputShortcutsPosition); - // Set up vertical flip transform once (BMP is bottom-up) - ctx.setTransform(1, 0, 0, -1, 0, height); + // Windows/Linux/macOS clients send bottom-up H.264 (BMP convention) → flip. + // Android MediaCodec sends top-down H.264 → no flip needed. + if (isTopDown) { + ctx.setTransform(1, 0, 0, 1, 0, 0); + } else { + ctx.setTransform(1, 0, 0, -1, 0, height); + } if (decoder) { try { decoder.close(); } catch(e) {} } // Reset FPS sliding window on decoder (re)init so a resolution // change or codec switch doesn't carry over stale counts. @@ -2923,7 +2997,8 @@ btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements; if (isLandscape()) { - // Landscape mode + // Landscape mode: always clear android-portrait immersive class + document.getElementById('screen-page').classList.remove('android-portrait'); quickControls.classList.remove('visible'); inputShortcuts.classList.remove('visible'); @@ -2942,8 +3017,21 @@ btnMouseBar.classList.remove('ui-hidden'); btnKeyboardBar.classList.remove('ui-hidden'); } + } else if (isAndroidRemote && isFullscreen()) { + // Android portrait fullscreen: immersive mode — hide top toolbar, show three-dot toggle. + // Canvas fills the full viewport via .android-portrait CSS class. + document.getElementById('screen-page').classList.add('android-portrait'); + quickControls.classList.remove('visible'); + inputShortcuts.classList.remove('visible'); + toolbarToggle.classList.remove('ui-hidden'); + floatingToolbar.classList.remove('visible'); + toolbarVisible = false; + btnRdpResetBar.classList.add('ui-hidden'); + btnMouseBar.classList.add('ui-hidden'); + btnKeyboardBar.classList.add('ui-hidden'); } else { - // Portrait mode: show quick controls + // Portrait mode (non-Android): show quick controls + document.getElementById('screen-page').classList.remove('android-portrait'); quickControls.classList.add('visible'); // Input shortcuts only visible when keyboard is open const keyboardOpen = document.activeElement === mobileKeyboard; @@ -3038,7 +3126,8 @@ } else if (!controlEnabled) { canvas.style.cursor = 'default'; } - cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice); + // Android direct mode: no cursor overlay (finger IS the pointer) + cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice && !isAndroidRemote); if (controlEnabled) { applyRemoteCursor(currentCursorIndex); } @@ -3278,6 +3367,83 @@ const touchIndicator = document.getElementById('touch-indicator'); const mobileKeyboard = document.getElementById('mobile-keyboard'); + // ── Android direct-touch state ──────────────────────────────────────── + // Active when isAndroidRemote && isTouchDevice. + // Single finger maps directly to remote screen coordinates. + let ad = { + active: false, + startX: 0, startY: 0, + lastX: 0, lastY: 0, + hasMoved: false, + longPressTimer: null, + lastTapX: 0, lastTapY: 0, + lastTapTime: 0 + }; + + function adTouchStart(e) { + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + const pos = getTouchPos(touch); + const now = Date.now(); + + if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; } + + // Double-tap detection + const dx = pos.x - ad.lastTapX, dy = pos.y - ad.lastTapY; + if (now - ad.lastTapTime < 300 && dx*dx + dy*dy < 1600) { + ad.lastTapTime = 0; + ad.active = false; + sendMouse('dblclick', pos.x, pos.y, 0); + return; + } + + ad.active = true; + ad.startX = ad.lastX = pos.x; + ad.startY = ad.lastY = pos.y; + ad.hasMoved = false; + + sendMouse('down', pos.x, pos.y, 0); + + // Long press → right-click (Android long-press) + ad.longPressTimer = setTimeout(function() { + if (ad.active && !ad.hasMoved) { + sendMouse('up', ad.lastX, ad.lastY, 0); + sendMouse('down', ad.lastX, ad.lastY, 2); + sendMouse('up', ad.lastX, ad.lastY, 2); + ad.active = false; + showTouchIndicator(touch.clientX, touch.clientY); + } + ad.longPressTimer = null; + }, 600); + } + + function adTouchMove(e) { + if (e.touches.length !== 1 || !ad.active) return; + const pos = getTouchPos(e.touches[0]); + const dx = pos.x - ad.startX, dy = pos.y - ad.startY; + + if (!ad.hasMoved && dx*dx + dy*dy > 64) { // 8px threshold + ad.hasMoved = true; + if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; } + } + if (ad.hasMoved) sendMouse('move', pos.x, pos.y, 0); + ad.lastX = pos.x; + ad.lastY = pos.y; + } + + function adTouchEnd() { + if (!ad.active) return; + if (ad.longPressTimer) { clearTimeout(ad.longPressTimer); ad.longPressTimer = null; } + ad.active = false; + if (!ad.hasMoved) { + ad.lastTapTime = Date.now(); + ad.lastTapX = ad.lastX; + ad.lastTapY = ad.lastY; + } + sendMouse('up', ad.lastX, ad.lastY, 0); + } + // ───────────────────────────────────────────────────────────────────── + // Initialize cursor to center of screen function initCursor() { if (!cursorState.initialized && canvas.width > 0) { @@ -3337,6 +3503,17 @@ } } + function sendAndroidNav(keyCode) { + sendKey(keyCode, true); + setTimeout(() => sendKey(keyCode, false), 50); + } + + function updateAndroidNavbar() { + const nb = document.getElementById('android-navbar'); + if (!nb) return; + nb.classList.toggle('visible', !!isAndroidRemote); + } + function sendKey(keyCode, isDown, altKey) { if (!controlEnabled) return; // Control mode required // Filter Windows keys (handled locally, not sent to remote) @@ -3391,7 +3568,9 @@ return; } - // Single finger touch + // Single finger touch — Android direct mode bypasses the touchpad state machine + if (isAndroidRemote && isTouchDevice) { adTouchStart(e); return; } + initCursor(); const touch = e.touches[0]; const oldStartX = touchState.startX; @@ -3560,6 +3739,9 @@ return; } + // Single finger move — Android direct mode + if (isAndroidRemote && isTouchDevice) { adTouchMove(e); return; } + // Single finger move - state machine based const touch = e.touches[0]; const dx = touch.clientX - touchState.lastX; @@ -3646,6 +3828,9 @@ return; } + // Android direct mode touchend + if (isAndroidRemote && isTouchDevice) { adTouchEnd(); return; } + // State machine based touchend const overlay = document.getElementById('cursor-overlay'); const x = Math.round(cursorState.x); @@ -3925,6 +4110,9 @@ ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) })); } currentDevice = null; // Clear current device + isAndroidRemote = false; + updateAndroidNavbar(); + document.getElementById('screen-page').classList.remove('android-portrait'); showPage('devices-page'); getDevices(); }