Feat: Android client Phase 0-3 full implementation

This commit is contained in:
yuanyuanxiang
2026-06-17 23:19:12 +02:00
parent 837d89c8b5
commit 5485e8f9e3
53 changed files with 3327 additions and 30 deletions

21
android/.gitignore vendored Normal file
View 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
View 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 辅助函数 | POSIXAndroid 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` APIAndroid 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滚轮上→ 手指下划 +400pxdelta < 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 字节),**坐标从 lParamoffset 24读取**,与 Windows/Linux/macOS 客户端完全一致;不读 pt.x/pt.y64-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` 纯 CNDK 直接编译,当前未启用)
- 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 # ForegroundServiceMediaProjection + 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_IDXXH64 作为 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 36MSG64: 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
View 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 // 改为你的服务端端口
```
改完再执行编译,否则客户端连不上服务端。
---
## 编译环境
**WSLUbuntu或原生 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.xLinux 版) | 见下方说明 |
### 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 NDKLinux 版)
> **重要**:必须使用 **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
View 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
View File

@@ -0,0 +1,2 @@
# Keep all app classes JNI method names must match exactly
-keep class com.yama.client.** { *; }

View 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>

View 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
)

View 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 H264Android 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 字节)
// 坐标从 lParamoffset 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());
}
};

View 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__

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
// 直接用 StateARM 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 设置)
// 初始化为 0ScreenSpyThread 等到非零后再读,避免在 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 设置真实分辨率。
// 若在此之前读到默认值 0SendBitmapInfo 会发错误尺寸给服务端,
// 导致浏览器 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"

View 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()
}
}

View 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) }
}

View 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()
}
}

View 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)
}

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F2F2F2</color>
</resources>

View 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>

View 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>

View File

@@ -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
View 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
View 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

View 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}/"

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m

Binary file not shown.

View 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
View 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
View 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
View 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

Binary file not shown.