Feat: Android client Phase 0-3 full implementation
1
.gitignore
vendored
@@ -95,3 +95,4 @@ server/go/web/assets/index.html
|
|||||||
server/go/users.json
|
server/go/users.json
|
||||||
server/go/build/
|
server/go/build/
|
||||||
server/go/.claude/settings.json
|
server/go/.claude/settings.json
|
||||||
|
android/app/.cxx/
|
||||||
|
|||||||
21
android/.gitignore
vendored
Normal file
@@ -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
|
||||||
266
android/PLAN.md
Normal file
@@ -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<AndroidScreenHandler*> 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、内核注入)
|
||||||
172
android/README.md
Normal file
@@ -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 秒)以进一步降低耗电
|
||||||
69
android/app/build.gradle
Normal file
@@ -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'
|
||||||
|
}
|
||||||
2
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep all app classes — JNI method names must match exactly
|
||||||
|
-keep class com.yama.client.** { *; }
|
||||||
46
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="YAMA"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.YAMA">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".CaptureService"
|
||||||
|
android:foregroundServiceType="dataSync|mediaProjection"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ControlService"
|
||||||
|
android:description="@string/accessibility_service_description"
|
||||||
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accessibilityservice"
|
||||||
|
android:resource="@xml/accessibility_service_config" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
56
android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -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
|
||||||
|
)
|
||||||
200
android/app/src/main/cpp/ScreenHandler.h
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
#pragma once
|
||||||
|
// Android 屏幕处理器:管理截屏子连接,接收 Java 侧 MediaCodec 输出的 H.264 NALU,
|
||||||
|
// 按协议封装后通过子连接发送给服务端。
|
||||||
|
#include "common/commands.h"
|
||||||
|
#include "client/IOCPClient.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <queue>
|
||||||
|
#include <vector>
|
||||||
|
#include <thread>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#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<uint8_t> 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<std::mutex> lk(m_mutex);
|
||||||
|
while (m_queue.size() >= 6) m_queue.pop();
|
||||||
|
m_queue.push({std::vector<uint8_t>(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<uint8_t> data; bool isKeyframe; };
|
||||||
|
|
||||||
|
IOCPClient* m_client;
|
||||||
|
int m_width, m_height;
|
||||||
|
std::atomic<bool> m_started;
|
||||||
|
std::atomic<bool> m_running;
|
||||||
|
std::atomic<bool> m_firstSent;
|
||||||
|
std::atomic<bool> m_skipLogged{false};
|
||||||
|
|
||||||
|
BmpInfoHeader m_bmpHdr;
|
||||||
|
|
||||||
|
std::queue<Frame> m_queue;
|
||||||
|
std::mutex m_mutex;
|
||||||
|
std::condition_variable m_cond;
|
||||||
|
std::thread m_sendThread;
|
||||||
|
|
||||||
|
void SendLoop() {
|
||||||
|
while (m_running) {
|
||||||
|
std::unique_lock<std::mutex> 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<uint8_t> 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());
|
||||||
|
}
|
||||||
|
};
|
||||||
24
android/app/src/main/cpp/android_compat.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// android_compat.h - Android NDK 兼容垫片,通过 CMake force-include 注入每个源文件
|
||||||
|
// 不修改任何共用代码(client/、common/)
|
||||||
|
#pragma once
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
// strcpy_s(dest, src) — MSVC 2-arg 扩展,Android NDK 无此签名
|
||||||
|
template<size_t N>
|
||||||
|
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__
|
||||||
BIN
android/app/src/main/cpp/lib/arm64-v8a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/arm64-v8a/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a
Normal file
662
android/app/src/main/cpp/main.cpp
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
|
||||||
|
#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<bool> g_running{false};
|
||||||
|
static std::atomic<uint64_t> 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<uint64_t> 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<int> g_screenWidth{0};
|
||||||
|
static std::atomic<int> g_screenHeight{0};
|
||||||
|
|
||||||
|
// 活跃子连接的 handler 集合(受 g_shMutex 保护);支持多个浏览者/控制者同时连接
|
||||||
|
static std::set<AndroidScreenHandler*> 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<IOCPClient> client;
|
||||||
|
std::unique_ptr<AndroidScreenHandler> handler;
|
||||||
|
try {
|
||||||
|
client = std::make_unique<IOCPClient>(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<AndroidScreenHandler>(client.get(), w, h);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<IOCPClient> 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<std::mutex> 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"
|
||||||
360
android/app/src/main/java/com/yama/client/CaptureService.kt
Normal file
@@ -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<Int, Int> {
|
||||||
|
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<Int, Int> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
290
android/app/src/main/java/com/yama/client/ControlService.kt
Normal file
@@ -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<Pair<Long, PointF>>()
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// 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<Pair<Long, PointF>>) {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
65
android/app/src/main/java/com/yama/client/MainActivity.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
26
android/app/src/main/java/com/yama/client/YamaBridge.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#F2F2F2</color>
|
||||||
|
</resources>
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YAMA</string>
|
||||||
|
<string name="accessibility_service_description">YAMA remote control service</string>
|
||||||
|
</resources>
|
||||||
8
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.YAMA" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="colorPrimary">#1A73E8</item>
|
||||||
|
<item name="colorPrimaryDark">#1558B0</item>
|
||||||
|
<item name="colorAccent">#1A73E8</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||||
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
|
android:canPerformGestures="true"
|
||||||
|
android:notificationTimeout="0"
|
||||||
|
android:settingsActivity="com.yama.client.MainActivity" />
|
||||||
4
android/build.gradle
Normal file
@@ -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
|
||||||
|
}
|
||||||
307
android/build_apk.sh
Normal file
@@ -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/<you>/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/<ver>/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 '(?<=<url>)[^<]+' | 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" <<EOF
|
||||||
|
sdk.dir=$SDK_DIR
|
||||||
|
EOF
|
||||||
|
if $IS_WSL && [ -n "${NDK_DIR:-}" ]; then
|
||||||
|
echo "ndk.dir=$NDK_DIR" >> "$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
|
||||||
77
android/build_zstd_android.sh
Normal file
@@ -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}/"
|
||||||
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
248
android/gradlew
vendored
Normal file
@@ -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" "$@"
|
||||||
93
android/gradlew.bat
vendored
Normal file
@@ -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
|
||||||
16
android/settings.gradle
Normal file
@@ -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")
|
||||||
BIN
android/yama-release.jks
Normal file
@@ -30,11 +30,13 @@ inline int WSAGetLastError()
|
|||||||
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
||||||
#else
|
#else
|
||||||
#include "common/zstd_wrapper.h"
|
#include "common/zstd_wrapper.h"
|
||||||
|
#ifdef _WIN32
|
||||||
# ifdef _WIN64
|
# ifdef _WIN64
|
||||||
# pragma comment(lib, "zstd/zstd_x64.lib")
|
# pragma comment(lib, "zstd/zstd_x64.lib")
|
||||||
# else
|
# else
|
||||||
# pragma comment(lib, "zstd/zstd.lib")
|
# pragma comment(lib, "zstd/zstd.lib")
|
||||||
# endif
|
# endif
|
||||||
|
#endif
|
||||||
#define Z_FAILED(p) ZSTD_isError(p)
|
#define Z_FAILED(p) ZSTD_isError(p)
|
||||||
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
||||||
#define ZSTD_CLEVEL ZSTD_CLEVEL_DEFAULT
|
#define ZSTD_CLEVEL ZSTD_CLEVEL_DEFAULT
|
||||||
|
|||||||
@@ -34,7 +34,14 @@
|
|||||||
#define AUTO_TICK(p, q)
|
#define AUTO_TICK(p, q)
|
||||||
#define STOP_TICK
|
#define STOP_TICK
|
||||||
#define OutputDebugStringA(p) printf(p)
|
#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 <unistd.h>
|
#include <unistd.h>
|
||||||
#define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000))
|
#define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000))
|
||||||
|
|
||||||
@@ -653,6 +660,7 @@ enum {
|
|||||||
CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行
|
CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行
|
||||||
CLIENT_TYPE_LINUX = 6, // LINUX 客户端
|
CLIENT_TYPE_LINUX = 6, // LINUX 客户端
|
||||||
CLIENT_TYPE_MACOS = 7, // MACOS 客户端
|
CLIENT_TYPE_MACOS = 7, // MACOS 客户端
|
||||||
|
CLIENT_TYPE_ANDROID = 8, // ANDROID 客户端
|
||||||
};
|
};
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
@@ -680,6 +688,8 @@ inline const char* GetClientType(int typ)
|
|||||||
return "LNX";
|
return "LNX";
|
||||||
case CLIENT_TYPE_MACOS:
|
case CLIENT_TYPE_MACOS:
|
||||||
return "MAC";
|
return "MAC";
|
||||||
|
case CLIENT_TYPE_ANDROID:
|
||||||
|
return "APK";
|
||||||
default:
|
default:
|
||||||
return "DLL";
|
return "DLL";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1485,14 +1485,20 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
|
|||||||
if (TryMigrateClientMetadata(id, strPCName, path)) {
|
if (TryMigrateClientMetadata(id, strPCName, path)) {
|
||||||
modify = true;
|
modify = true;
|
||||||
}
|
}
|
||||||
CString loc = m_ClientMap->GetClientMapData(id, MAP_LOCATION);
|
// 优先级:① 客户端刚发来的有效地理位置(非空非"?")
|
||||||
if (loc.IsEmpty()) {
|
// ② 服务端缓存(排除历史遗留的"?"无效值)
|
||||||
loc = v[RES_CLIENT_LOC].c_str();
|
// ③ 服务端按连接 IP 自行查询
|
||||||
if (loc.IsEmpty()) {
|
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();
|
loc = m_IPConverter->GetGeoLocation(data[ONLINELIST_IP].GetString()).c_str();
|
||||||
needConvert = !m_HasLocDB;
|
needConvert = !m_HasLocDB;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// TODO: Remove SafeUtf8ToAnsi after migrating to UTF-8
|
// TODO: Remove SafeUtf8ToAnsi after migrating to UTF-8
|
||||||
if (needConvert)
|
if (needConvert)
|
||||||
loc = SafeUtf8ToAnsi(loc.GetString()).c_str();
|
loc = SafeUtf8ToAnsi(loc.GetString()).c_str();
|
||||||
@@ -3478,7 +3484,7 @@ void CMy2015RemoteDlg::CheckHeartbeat()
|
|||||||
{
|
{
|
||||||
CLock lock(m_cs);
|
CLock lock(m_cs);
|
||||||
auto now = time(0);
|
auto now = time(0);
|
||||||
int HEARTBEAT_TIMEOUT = max(60, m_settings.ReportInterval * 3);
|
int HEARTBEAT_TIMEOUT = max(120, m_settings.ReportInterval * 3);
|
||||||
|
|
||||||
// 收集需要删除的 context(避免遍历时修改 vector)
|
// 收集需要删除的 context(避免遍历时修改 vector)
|
||||||
std::vector<context*> toRemove;
|
std::vector<context*> toRemove;
|
||||||
@@ -10304,6 +10310,17 @@ context* CMy2015RemoteDlg::FindHostByIP(const std::string& ip)
|
|||||||
return NULL;
|
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)
|
uint64_t CMy2015RemoteDlg::FindClientIDByIP(const std::string& ip)
|
||||||
{
|
{
|
||||||
CString clientIP(ip.c_str());
|
CString clientIP(ip.c_str());
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ public:
|
|||||||
CGridDialog * m_gridDlg = NULL;
|
CGridDialog * m_gridDlg = NULL;
|
||||||
std::vector<DllInfo*> m_DllList;
|
std::vector<DllInfo*> m_DllList;
|
||||||
context* FindHostByIP(const std::string& ip);
|
context* FindHostByIP(const std::string& ip);
|
||||||
|
context* FindHostByClientID(uint64_t clientID); // 线程安全:通过 clientID 精确查主连接
|
||||||
uint64_t FindClientIDByIP(const std::string& ip); // 线程安全:在锁内获取ID
|
uint64_t FindClientIDByIP(const std::string& ip); // 线程安全:在锁内获取ID
|
||||||
void InjectTinyRunDll(const std::string& ip, int pid);
|
void InjectTinyRunDll(const std::string& ip, int pid);
|
||||||
NOTIFYICONDATA m_Nid;
|
NOTIFYICONDATA m_Nid;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
#include <md5.h>
|
#include <md5.h>
|
||||||
#include <cstdint> // for uint16_t
|
#include <cstdint> // for uint16_t
|
||||||
|
|
||||||
|
extern CMy2015RemoteDlg* g_2015RemoteDlg;
|
||||||
|
|
||||||
extern "C" uint32_t licenseGetBuildTag() { volatile uint32_t tag = 0xC0DE2026u; return tag; }
|
extern "C" uint32_t licenseGetBuildTag() { volatile uint32_t tag = 0xC0DE2026u; return tag; }
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <mutex> // for std::mutex, std::lock_guard
|
#include <mutex> // for std::mutex, std::lock_guard
|
||||||
@@ -225,7 +227,15 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE
|
|||||||
if (WebService().IsRunning() && !WebService().IsMfcTriggered(m_ClientID)) {
|
if (WebService().IsRunning() && !WebService().IsMfcTriggered(m_ClientID)) {
|
||||||
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
||||||
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
|
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,让前端按钮显示正确
|
// 透传客户端初始的音频开/关状态给 web,让前端按钮显示正确
|
||||||
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
|
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
|
||||||
}
|
}
|
||||||
@@ -953,7 +963,6 @@ VOID CScreenSpyDlg::OnClose()
|
|||||||
CWnd* parent = GetParent();
|
CWnd* parent = GetParent();
|
||||||
if (parent)
|
if (parent)
|
||||||
parent->SendMessage(WM_CHILD_CLOSED, (WPARAM)this, 0);
|
parent->SendMessage(WM_CHILD_CLOSED, (WPARAM)this, 0);
|
||||||
extern CMy2015RemoteDlg *g_2015RemoteDlg;
|
|
||||||
if(g_2015RemoteDlg)
|
if(g_2015RemoteDlg)
|
||||||
g_2015RemoteDlg->RemoveRemoteWindow(GetSafeHwnd());
|
g_2015RemoteDlg->RemoveRemoteWindow(GetSafeHwnd());
|
||||||
|
|
||||||
@@ -1126,7 +1135,13 @@ VOID CScreenSpyDlg::OnReceiveComplete()
|
|||||||
if (m_bIsWebSession && WebService().IsRunning()) {
|
if (m_bIsWebSession && WebService().IsRunning()) {
|
||||||
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
||||||
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -694,6 +694,8 @@ 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)
|
// Get screen dimensions + audio state from device info cache (may not be ready)
|
||||||
int width = 0, height = 0;
|
int width = 0, height = 0;
|
||||||
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
||||||
|
bool top_down = false;
|
||||||
|
std::string client_type;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||||||
auto it = m_DeviceCache.find(device_id);
|
auto it = m_DeviceCache.find(device_id);
|
||||||
@@ -701,6 +703,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
width = it->second->screen_width;
|
width = it->second->screen_width;
|
||||||
height = it->second->screen_height;
|
height = it->second->screen_height;
|
||||||
audio_enabled = it->second->audio_enabled;
|
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) {
|
if (width > 0 && height > 0) {
|
||||||
res["width"] = width;
|
res["width"] = width;
|
||||||
res["height"] = height;
|
res["height"] = height;
|
||||||
|
res["top_down"] = top_down;
|
||||||
|
if (!client_type.empty()) res["client_type"] = client_type;
|
||||||
}
|
}
|
||||||
if (audio_enabled >= 0) {
|
if (audio_enabled >= 0) {
|
||||||
res["audio_enabled"] = (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;
|
if (m_bStopping) return;
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
@@ -1725,6 +1731,8 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
|
|||||||
}
|
}
|
||||||
it->second->screen_width = width;
|
it->second->screen_width = width;
|
||||||
it->second->screen_height = height;
|
it->second->screen_height = height;
|
||||||
|
it->second->top_down = top_down;
|
||||||
|
if (!client_type.empty()) it->second->client_type = client_type;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify watching clients
|
// Notify watching clients
|
||||||
@@ -1733,6 +1741,8 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
|
|||||||
res["id"] = device_id;
|
res["id"] = device_id;
|
||||||
res["width"] = width;
|
res["width"] = width;
|
||||||
res["height"] = height;
|
res["height"] = height;
|
||||||
|
res["top_down"] = top_down;
|
||||||
|
if (!client_type.empty()) res["client_type"] = client_type;
|
||||||
|
|
||||||
Json::StreamWriterBuilder builder;
|
Json::StreamWriterBuilder builder;
|
||||||
builder["indentation"] = "";
|
builder["indentation"] = "";
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ struct WebDeviceInfo {
|
|||||||
int screen_width;
|
int screen_width;
|
||||||
int screen_height;
|
int screen_height;
|
||||||
bool online;
|
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=开
|
// 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来),0=关,1=开
|
||||||
int audio_enabled = -1;
|
int audio_enabled = -1;
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ public:
|
|||||||
void CacheKeyframe(uint64_t device_id, const uint8_t* data, size_t len);
|
void CacheKeyframe(uint64_t device_id, const uint8_t* data, size_t len);
|
||||||
|
|
||||||
// Resolution change notification
|
// 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
|
// Audio enable/disable notification — pushes current state to all web
|
||||||
// clients watching this device and caches it for newcomers.
|
// clients watching this device and caches it for newcomers.
|
||||||
|
|||||||
@@ -783,6 +783,21 @@
|
|||||||
#screen-page:-webkit-full-screen .toolbar-toggle {
|
#screen-page:-webkit-full-screen .toolbar-toggle {
|
||||||
display: flex;
|
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 {
|
.compat-warning {
|
||||||
background: linear-gradient(90deg, #ff9800, #f57c00);
|
background: linear-gradient(90deg, #ff9800, #f57c00);
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -1063,6 +1078,33 @@
|
|||||||
}
|
}
|
||||||
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
|
.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); }
|
.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 */
|
/* Mobile responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.page {
|
.page {
|
||||||
@@ -1195,6 +1237,18 @@
|
|||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
<canvas id="screen-canvas"></canvas>
|
<canvas id="screen-canvas"></canvas>
|
||||||
<div class="cursor-overlay" id="cursor-overlay"></div>
|
<div class="cursor-overlay" id="cursor-overlay"></div>
|
||||||
|
<!-- Android virtual navigation bar: Back / Home / Recents -->
|
||||||
|
<div class="android-navbar" id="android-navbar">
|
||||||
|
<button class="nav-btn" onclick="sendAndroidNav(8)" title="Back" tabindex="-1">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="white"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" onclick="sendAndroidNav(36)" title="Home" tabindex="-1">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" onclick="sendAndroidNav(93)" title="Recents" tabindex="-1">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="quick-controls" id="quick-controls">
|
<div class="quick-controls" id="quick-controls">
|
||||||
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">↻</button>
|
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">↻</button>
|
||||||
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">⌨</button>
|
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">⌨</button>
|
||||||
@@ -1310,6 +1364,8 @@
|
|||||||
let bwBytesAccum = 0; // current-second byte accumulator
|
let bwBytesAccum = 0; // current-second byte accumulator
|
||||||
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
|
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
|
||||||
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
|
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 canvas = document.getElementById('screen-canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
@@ -1564,6 +1620,10 @@
|
|||||||
// Resolution may not be available yet (first connection)
|
// Resolution may not be available yet (first connection)
|
||||||
if (msg.width && msg.height) {
|
if (msg.width && msg.height) {
|
||||||
updateScreenStatus('connected');
|
updateScreenStatus('connected');
|
||||||
|
isTopDown = msg.top_down === true;
|
||||||
|
isAndroidRemote = msg.client_type === 'APK';
|
||||||
|
updateAndroidNavbar();
|
||||||
|
if (isTouchDevice) updateUIForOrientation();
|
||||||
initDecoder(msg.width, msg.height);
|
initDecoder(msg.width, msg.height);
|
||||||
} else {
|
} else {
|
||||||
// Wait for resolution_changed message
|
// Wait for resolution_changed message
|
||||||
@@ -1609,6 +1669,15 @@
|
|||||||
break;
|
break;
|
||||||
case 'resolution_changed':
|
case 'resolution_changed':
|
||||||
updateScreenStatus('connected');
|
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);
|
initDecoder(msg.width, msg.height);
|
||||||
break;
|
break;
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
@@ -1694,8 +1763,13 @@
|
|||||||
// Update input shortcuts position after canvas resize
|
// Update input shortcuts position after canvas resize
|
||||||
requestAnimationFrame(updateInputShortcutsPosition);
|
requestAnimationFrame(updateInputShortcutsPosition);
|
||||||
|
|
||||||
// Set up vertical flip transform once (BMP is bottom-up)
|
// 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);
|
ctx.setTransform(1, 0, 0, -1, 0, height);
|
||||||
|
}
|
||||||
if (decoder) { try { decoder.close(); } catch(e) {} }
|
if (decoder) { try { decoder.close(); } catch(e) {} }
|
||||||
// Reset FPS sliding window on decoder (re)init so a resolution
|
// Reset FPS sliding window on decoder (re)init so a resolution
|
||||||
// change or codec switch doesn't carry over stale counts.
|
// change or codec switch doesn't carry over stale counts.
|
||||||
@@ -2084,7 +2158,7 @@
|
|||||||
}
|
}
|
||||||
decoder.decode(new EncodedVideoChunk({
|
decoder.decode(new EncodedVideoChunk({
|
||||||
type: isKeyframe ? 'key' : 'delta',
|
type: isKeyframe ? 'key' : 'delta',
|
||||||
timestamp: decodeTimestamp++,
|
timestamp: decodeTimestamp++ * 33333, // µs,按 30fps 帧间隔递增
|
||||||
data: videoData
|
data: videoData
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2923,7 +2997,8 @@
|
|||||||
btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
|
btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
|
||||||
|
|
||||||
if (isLandscape()) {
|
if (isLandscape()) {
|
||||||
// Landscape mode
|
// Landscape mode: always clear android-portrait immersive class
|
||||||
|
document.getElementById('screen-page').classList.remove('android-portrait');
|
||||||
quickControls.classList.remove('visible');
|
quickControls.classList.remove('visible');
|
||||||
inputShortcuts.classList.remove('visible');
|
inputShortcuts.classList.remove('visible');
|
||||||
|
|
||||||
@@ -2942,8 +3017,21 @@
|
|||||||
btnMouseBar.classList.remove('ui-hidden');
|
btnMouseBar.classList.remove('ui-hidden');
|
||||||
btnKeyboardBar.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 {
|
} 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');
|
quickControls.classList.add('visible');
|
||||||
// Input shortcuts only visible when keyboard is open
|
// Input shortcuts only visible when keyboard is open
|
||||||
const keyboardOpen = document.activeElement === mobileKeyboard;
|
const keyboardOpen = document.activeElement === mobileKeyboard;
|
||||||
@@ -3038,7 +3126,8 @@
|
|||||||
} else if (!controlEnabled) {
|
} else if (!controlEnabled) {
|
||||||
canvas.style.cursor = 'default';
|
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) {
|
if (controlEnabled) {
|
||||||
applyRemoteCursor(currentCursorIndex);
|
applyRemoteCursor(currentCursorIndex);
|
||||||
}
|
}
|
||||||
@@ -3278,6 +3367,83 @@
|
|||||||
const touchIndicator = document.getElementById('touch-indicator');
|
const touchIndicator = document.getElementById('touch-indicator');
|
||||||
const mobileKeyboard = document.getElementById('mobile-keyboard');
|
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
|
// Initialize cursor to center of screen
|
||||||
function initCursor() {
|
function initCursor() {
|
||||||
if (!cursorState.initialized && canvas.width > 0) {
|
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) {
|
function sendKey(keyCode, isDown, altKey) {
|
||||||
if (!controlEnabled) return; // Control mode required
|
if (!controlEnabled) return; // Control mode required
|
||||||
// Filter Windows keys (handled locally, not sent to remote)
|
// Filter Windows keys (handled locally, not sent to remote)
|
||||||
@@ -3391,7 +3568,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single finger touch
|
// Single finger touch — Android direct mode bypasses the touchpad state machine
|
||||||
|
if (isAndroidRemote && isTouchDevice) { adTouchStart(e); return; }
|
||||||
|
|
||||||
initCursor();
|
initCursor();
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
const oldStartX = touchState.startX;
|
const oldStartX = touchState.startX;
|
||||||
@@ -3560,6 +3739,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single finger move — Android direct mode
|
||||||
|
if (isAndroidRemote && isTouchDevice) { adTouchMove(e); return; }
|
||||||
|
|
||||||
// Single finger move - state machine based
|
// Single finger move - state machine based
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
const dx = touch.clientX - touchState.lastX;
|
const dx = touch.clientX - touchState.lastX;
|
||||||
@@ -3646,6 +3828,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android direct mode touchend
|
||||||
|
if (isAndroidRemote && isTouchDevice) { adTouchEnd(); return; }
|
||||||
|
|
||||||
// State machine based touchend
|
// State machine based touchend
|
||||||
const overlay = document.getElementById('cursor-overlay');
|
const overlay = document.getElementById('cursor-overlay');
|
||||||
const x = Math.round(cursorState.x);
|
const x = Math.round(cursorState.x);
|
||||||
@@ -3925,6 +4110,9 @@
|
|||||||
ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) }));
|
ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) }));
|
||||||
}
|
}
|
||||||
currentDevice = null; // Clear current device
|
currentDevice = null; // Clear current device
|
||||||
|
isAndroidRemote = false;
|
||||||
|
updateAndroidNavbar();
|
||||||
|
document.getElementById('screen-page').classList.remove('android-portrait');
|
||||||
showPage('devices-page');
|
showPage('devices-page');
|
||||||
getDevices();
|
getDevices();
|
||||||
}
|
}
|
||||||
|
|||||||