Feat: Android client Phase 0-3 full implementation
69
android/app/build.gradle
Normal file
@@ -0,0 +1,69 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.yama.client'
|
||||
compileSdk 35
|
||||
ndkVersion "30.0.14904198"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.yama.client"
|
||||
minSdk 21
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags '-std=c++17'
|
||||
arguments '-DANDROID_STL=c++_static'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path "src/main/cpp/CMakeLists.txt"
|
||||
version "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
storeFile file("../yama-release.jks")
|
||||
storePassword System.getenv("YAMA_PWD") ?: ""
|
||||
keyAlias 'yama'
|
||||
keyPassword System.getenv("YAMA_PWD") ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||
}
|
||||
2
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Keep all app classes — JNI method names must match exactly
|
||||
-keep class com.yama.client.** { *; }
|
||||
46
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="YAMA"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.YAMA">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".CaptureService"
|
||||
android:foregroundServiceType="dataSync|mediaProjection"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".ControlService"
|
||||
android:description="@string/accessibility_service_description"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
56
android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
project(yama_client LANGUAGES CXX C)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# YAMA 根目录(相对于本文件:cpp/ → main/ → src/ → app/ → android/ → YAMA/)
|
||||
get_filename_component(YAMA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../" ABSOLUTE)
|
||||
|
||||
# ---- 头文件搜索路径(与 linux/CMakeLists.txt 同构)----
|
||||
include_directories(
|
||||
${YAMA_ROOT}
|
||||
${YAMA_ROOT}/client
|
||||
${YAMA_ROOT}/compress
|
||||
${CMAKE_CURRENT_SOURCE_DIR} # android_compat.h
|
||||
)
|
||||
|
||||
# ---- 强制注入兼容头(替代修改共用源文件)----
|
||||
add_compile_options(-include "${CMAKE_CURRENT_SOURCE_DIR}/android_compat.h")
|
||||
|
||||
# ---- 核心源文件(与 linux/CMakeLists.txt 相同)----
|
||||
set(CORE_SOURCES
|
||||
${YAMA_ROOT}/client/Buffer.cpp
|
||||
${YAMA_ROOT}/client/IOCPClient.cpp
|
||||
${YAMA_ROOT}/client/sign_shim_unix.cpp
|
||||
${YAMA_ROOT}/common/ikcp.c
|
||||
)
|
||||
|
||||
# ---- Android NDK 入口 ----
|
||||
set(ANDROID_SOURCES
|
||||
main.cpp
|
||||
)
|
||||
|
||||
add_library(yama SHARED ${CORE_SOURCES} ${ANDROID_SOURCES})
|
||||
|
||||
# ---- 预编译静态库(由 SimplePlugins/sign_lib/build_android.sh 产出)----
|
||||
target_link_libraries(yama PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libsign.a"
|
||||
)
|
||||
|
||||
# ---- zstd(由 android/build_zstd_android.sh 产出)----
|
||||
target_link_libraries(yama PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libzstd.a"
|
||||
)
|
||||
|
||||
# ---- Android 系统库 ----
|
||||
target_link_libraries(yama PRIVATE android log)
|
||||
|
||||
# ---- 编译选项 ----
|
||||
target_compile_options(yama PRIVATE
|
||||
-O2
|
||||
-fvisibility=hidden
|
||||
-Wno-unused-parameter
|
||||
-Wno-missing-field-initializers
|
||||
)
|
||||
200
android/app/src/main/cpp/ScreenHandler.h
Normal file
@@ -0,0 +1,200 @@
|
||||
#pragma once
|
||||
// Android 屏幕处理器:管理截屏子连接,接收 Java 侧 MediaCodec 输出的 H.264 NALU,
|
||||
// 按协议封装后通过子连接发送给服务端。
|
||||
#include "common/commands.h"
|
||||
#include "client/IOCPClient.h"
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <queue>
|
||||
#include <vector>
|
||||
#include <thread>
|
||||
#include <cstring>
|
||||
#include <cinttypes>
|
||||
#include <android/log.h>
|
||||
|
||||
#define LOGI_SH(...) __android_log_print(ANDROID_LOG_INFO, "YAMA_SCR", __VA_ARGS__)
|
||||
#define LOGE_SH(...) __android_log_print(ANDROID_LOG_ERROR, "YAMA_SCR", __VA_ARGS__)
|
||||
|
||||
extern uint64_t g_myClientID;
|
||||
// 定义在 main.cpp,供子连接的 OnReceive 调用
|
||||
extern void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY);
|
||||
|
||||
// Linux/macOS 共用的 BITMAPINFOHEADER 布局(与 Windows 完全一致)
|
||||
#pragma pack(push, 1)
|
||||
struct BmpInfoHeader {
|
||||
uint32_t biSize;
|
||||
int32_t biWidth;
|
||||
int32_t biHeight;
|
||||
uint16_t biPlanes;
|
||||
uint16_t biBitCount;
|
||||
uint32_t biCompression;
|
||||
uint32_t biSizeImage;
|
||||
int32_t biXPelsPerMeter;
|
||||
int32_t biYPelsPerMeter;
|
||||
uint32_t biClrUsed;
|
||||
uint32_t biClrImportant;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
class AndroidScreenHandler : public IOCPManager {
|
||||
public:
|
||||
AndroidScreenHandler(IOCPClient* client, int width, int height)
|
||||
: m_client(client), m_width(width), m_height(height),
|
||||
m_started(false), m_running(true), m_firstSent(false)
|
||||
{
|
||||
memset(&m_bmpHdr, 0, sizeof(m_bmpHdr));
|
||||
m_bmpHdr.biSize = sizeof(BmpInfoHeader);
|
||||
m_bmpHdr.biWidth = width;
|
||||
m_bmpHdr.biHeight = height;
|
||||
m_bmpHdr.biPlanes = 1;
|
||||
m_bmpHdr.biBitCount = 32;
|
||||
m_bmpHdr.biCompression = 0;
|
||||
m_bmpHdr.biSizeImage = (uint32_t)(width * height * 4);
|
||||
// 1 = top-down H264(Android MediaCodec 标准编码顺序);
|
||||
// 服务端据此用负步长写 DIB,抵消 GDI bottom-up 翻转
|
||||
m_bmpHdr.biClrImportant = 1;
|
||||
|
||||
m_sendThread = std::thread(&AndroidScreenHandler::SendLoop, this);
|
||||
}
|
||||
|
||||
~AndroidScreenHandler() {
|
||||
m_running = false;
|
||||
m_cond.notify_all();
|
||||
if (m_sendThread.joinable())
|
||||
m_sendThread.join();
|
||||
}
|
||||
|
||||
// 子连接建立后立即调用,告知服务端屏幕尺寸和编码格式
|
||||
void SendBitmapInfo() {
|
||||
const uint32_t total = 1 + sizeof(BmpInfoHeader) + 2 * sizeof(uint64_t) + sizeof(ScreenSettings);
|
||||
std::vector<uint8_t> buf(total, 0);
|
||||
|
||||
buf[0] = TOKEN_BITMAPINFO;
|
||||
memcpy(&buf[1], &m_bmpHdr, sizeof(BmpInfoHeader));
|
||||
|
||||
uint64_t clientID = g_myClientID;
|
||||
uint64_t zero = 0;
|
||||
size_t off = 1 + sizeof(BmpInfoHeader);
|
||||
memcpy(&buf[off], &clientID, 8);
|
||||
memcpy(&buf[off + 8], &zero, 8);
|
||||
|
||||
ScreenSettings ss = {};
|
||||
ss.MaxFPS = 10;
|
||||
ss.ScreenWidth = m_width;
|
||||
ss.ScreenHeight = m_height;
|
||||
ss.QualityLevel = QUALITY_GOOD; // 告知服务端使用 H264 解码器
|
||||
ss.ScreenType = USING_VIRTUAL; // 虚拟显示
|
||||
memcpy(&buf[off + 16], &ss, sizeof(ss));
|
||||
|
||||
m_client->Send2Server((char*)buf.data(), total);
|
||||
LOGI_SH("SendBitmapInfo %dx%d clientID=%" PRIu64, m_width, m_height, clientID);
|
||||
|
||||
// H.264 是推送模式,不需要等 COMMAND_NEXT 才开始发帧。
|
||||
// 服务端在 auth 通过瞬间发 COMMAND_NEXT,此时 setManagerCallBack 尚未注册,
|
||||
// 消息被 WorkThread 丢弃,m_started 永远 false 导致帧全部卡在队列里。
|
||||
m_started = true;
|
||||
m_cond.notify_all();
|
||||
}
|
||||
|
||||
// 由 JNI 线程调用,投递 MediaCodec 输出的 NALU
|
||||
void OnFrameData(const uint8_t* data, uint32_t size, bool isKeyframe) {
|
||||
if (!m_running || size == 0) return;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_mutex);
|
||||
while (m_queue.size() >= 6) m_queue.pop();
|
||||
m_queue.push({std::vector<uint8_t>(data, data + size), isKeyframe});
|
||||
}
|
||||
m_cond.notify_one();
|
||||
}
|
||||
|
||||
virtual VOID OnReceive(PBYTE data, ULONG size) override {
|
||||
if (!size) return;
|
||||
switch (data[0]) {
|
||||
case COMMAND_NEXT:
|
||||
// 推送模式下已在 SendBitmapInfo 里开始推流;此处保留兼容,无副作用。
|
||||
LOGI_SH("COMMAND_NEXT received");
|
||||
m_started = true;
|
||||
m_cond.notify_all();
|
||||
break;
|
||||
case CMD_QUALITY_LEVEL:
|
||||
if (size >= 2) LOGI_SH("QualityLevel=%d", (int)(int8_t)data[1]);
|
||||
break;
|
||||
case COMMAND_SCREEN_CONTROL: {
|
||||
// 服务端通过子连接下发鼠标/键盘控制包(MSG64 固定 48 字节)
|
||||
// 坐标从 lParam(offset 24)读取,与 Windows/Linux/macOS 客户端一致:
|
||||
// lParam = MAKELPARAM(enc_x, enc_y),低16位=x,高16位=y。
|
||||
// 不读 pt.x/pt.y (offset 40/44):64-bit MSG 的 pt 在 offset 36,
|
||||
// 与 MSG64 offset 40 不同,直接读会得到错误坐标。
|
||||
if ((ULONG)size < 1 + 48u) break;
|
||||
const uint8_t* p = data + 1;
|
||||
uint64_t msgVal = 0, wParam = 0, lParam = 0;
|
||||
memcpy(&msgVal, p + 8, 8);
|
||||
memcpy(&wParam, p + 16, 8);
|
||||
memcpy(&lParam, p + 24, 8);
|
||||
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
|
||||
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
|
||||
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
struct Frame { std::vector<uint8_t> data; bool isKeyframe; };
|
||||
|
||||
IOCPClient* m_client;
|
||||
int m_width, m_height;
|
||||
std::atomic<bool> m_started;
|
||||
std::atomic<bool> m_running;
|
||||
std::atomic<bool> m_firstSent;
|
||||
std::atomic<bool> m_skipLogged{false};
|
||||
|
||||
BmpInfoHeader m_bmpHdr;
|
||||
|
||||
std::queue<Frame> m_queue;
|
||||
std::mutex m_mutex;
|
||||
std::condition_variable m_cond;
|
||||
std::thread m_sendThread;
|
||||
|
||||
void SendLoop() {
|
||||
while (m_running) {
|
||||
std::unique_lock<std::mutex> lk(m_mutex);
|
||||
m_cond.wait(lk, [&]{ return (!m_queue.empty() && m_started) || !m_running; });
|
||||
if (!m_running) break;
|
||||
|
||||
Frame f = std::move(m_queue.front());
|
||||
m_queue.pop();
|
||||
lk.unlock();
|
||||
|
||||
// 第一帧必须是关键帧
|
||||
if (!m_firstSent && !f.isKeyframe) {
|
||||
if (!m_skipLogged.exchange(true))
|
||||
LOGI_SH("SendLoop: waiting for first IDR, skipping P-frames");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!m_firstSent)
|
||||
LOGI_SH("SendH264 first IDR size=%u", (uint32_t)f.data.size());
|
||||
SendH264(f.data.data(), (uint32_t)f.data.size());
|
||||
m_firstSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式: [TOKEN_NEXTSCREEN:1][ALGORITHM_H264:1][cursorX:4][cursorY:4][cursorType:1][NALU:N]
|
||||
void SendH264(const uint8_t* nalu, uint32_t naluSize) {
|
||||
const uint32_t hdrSize = 1 + 1 + 4 + 4 + 1;
|
||||
std::vector<uint8_t> pkt(hdrSize + naluSize);
|
||||
|
||||
pkt[0] = TOKEN_NEXTSCREEN;
|
||||
pkt[1] = ALGORITHM_H264;
|
||||
// cursor: (0,0), type: IDC_ARROW=1
|
||||
memset(&pkt[2], 0, 8);
|
||||
pkt[10] = 1;
|
||||
memcpy(&pkt[hdrSize], nalu, naluSize);
|
||||
|
||||
m_client->Send2Server((char*)pkt.data(), pkt.size());
|
||||
}
|
||||
};
|
||||
24
android/app/src/main/cpp/android_compat.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// android_compat.h - Android NDK 兼容垫片,通过 CMake force-include 注入每个源文件
|
||||
// 不修改任何共用代码(client/、common/)
|
||||
#pragma once
|
||||
#ifdef __ANDROID__
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
|
||||
// strcpy_s(dest, src) — MSVC 2-arg 扩展,Android NDK 无此签名
|
||||
template<size_t N>
|
||||
inline int strcpy_s(char (&dest)[N], const char* src) {
|
||||
strncpy(dest, src ? src : "", N - 1);
|
||||
dest[N - 1] = '\0';
|
||||
return 0;
|
||||
}
|
||||
#endif // __cplusplus
|
||||
|
||||
// rand_s — Windows 安全随机数,Android 用 arc4random 替代
|
||||
#define rand_s(p) (*(p) = (unsigned int)arc4random(), 0)
|
||||
|
||||
#endif // __ANDROID__
|
||||
BIN
android/app/src/main/cpp/lib/arm64-v8a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/arm64-v8a/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a
Normal file
662
android/app/src/main/cpp/main.cpp
Normal file
@@ -0,0 +1,662 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netdb.h>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
#include "common/commands.h"
|
||||
#include "common/client_auth_state.h"
|
||||
#include "common/rtt_estimator.h"
|
||||
#include "client/IOCPClient.h"
|
||||
#include "ScreenHandler.h"
|
||||
#define XXH_INLINE_ALL
|
||||
#include "common/xxhash.h"
|
||||
|
||||
#define LOG_TAG "YAMA"
|
||||
#undef Mprintf
|
||||
#define Mprintf(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
// ---- 设备信息 helper(登录时填充,仅调用一次)----
|
||||
|
||||
static int GetCpuCores() {
|
||||
long n = sysconf(_SC_NPROCESSORS_ONLN);
|
||||
return (n > 0) ? (int)n : 1;
|
||||
}
|
||||
|
||||
static double GetMemoryGB() {
|
||||
FILE* f = fopen("/proc/meminfo", "r");
|
||||
if (!f) return 0.0;
|
||||
char line[128];
|
||||
unsigned long kb = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (sscanf(line, "MemTotal: %lu kB", &kb) == 1) break;
|
||||
}
|
||||
fclose(f);
|
||||
return kb / (1024.0 * 1024.0);
|
||||
}
|
||||
|
||||
// 遍历 cpu0-cpu7,取 cpuinfo_max_freq 最大值(大核频率)
|
||||
static int GetCpuMHz() {
|
||||
// 优先从 sysfs 读取(真机有效)
|
||||
unsigned long maxKhz = 0;
|
||||
char path[80];
|
||||
for (int i = 0; i < 8; i++) {
|
||||
snprintf(path, sizeof(path),
|
||||
"/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i);
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) continue;
|
||||
unsigned long khz = 0;
|
||||
if (fscanf(f, "%lu", &khz) == 1 && khz > maxKhz) maxKhz = khz;
|
||||
fclose(f);
|
||||
}
|
||||
if (maxKhz > 0) return (int)(maxKhz / 1000);
|
||||
|
||||
// 回退:解析 /proc/cpuinfo 中的 "BogoMIPS"(模拟器可用)
|
||||
FILE* f = fopen("/proc/cpuinfo", "r");
|
||||
if (!f) return 0;
|
||||
char line[128];
|
||||
double bogomips = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (sscanf(line, "BogoMIPS : %lf", &bogomips) == 1 ||
|
||||
sscanf(line, "bogomips : %lf", &bogomips) == 1) break;
|
||||
}
|
||||
fclose(f);
|
||||
return bogomips > 0 ? (int)bogomips : 0;
|
||||
}
|
||||
|
||||
static long GetFileSize(const std::string& path) {
|
||||
if (path.empty()) return 0;
|
||||
struct stat st;
|
||||
return (stat(path.c_str(), &st) == 0) ? (long)st.st_size : 0;
|
||||
}
|
||||
|
||||
static std::string FormatFileSize(long bytes) {
|
||||
char buf[32];
|
||||
if (bytes >= 1024 * 1024)
|
||||
snprintf(buf, sizeof(buf), "%.1fM", bytes / (1024.0 * 1024.0));
|
||||
else if (bytes >= 1024)
|
||||
snprintf(buf, sizeof(buf), "%.1fK", bytes / 1024.0);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%ldB", bytes);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// 从 JSON 字符串中提取字符串字段值
|
||||
static std::string JsonGetStr(const std::string& json, const char* key) {
|
||||
std::string needle = std::string("\"") + key + "\":";
|
||||
auto pos = json.find(needle);
|
||||
if (pos == std::string::npos) return "";
|
||||
pos += needle.size();
|
||||
while (pos < json.size() && json[pos] == ' ') pos++;
|
||||
if (pos >= json.size() || json[pos] != '"') return "";
|
||||
pos++;
|
||||
auto end = json.find('"', pos);
|
||||
return (end == std::string::npos) ? "" : json.substr(pos, end - pos);
|
||||
}
|
||||
|
||||
// 查询 ip-api.com,一次请求同时获取公网 IP 和地理位置(与 Windows 客户端同源)
|
||||
// 在连接线程中同步调用,超时 3 秒
|
||||
static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
|
||||
pubIp.clear(); location.clear();
|
||||
|
||||
struct addrinfo hints = {}, *res = nullptr;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
if (getaddrinfo("ip-api.com", "80", &hints, &res) != 0 || !res) return false;
|
||||
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) { freeaddrinfo(res); return false; }
|
||||
|
||||
struct timeval tv = {3, 0};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
|
||||
bool ok = false;
|
||||
if (connect(fd, res->ai_addr, res->ai_addrlen) == 0) {
|
||||
const char* req = "GET /json/?fields=status,query,country,city HTTP/1.0\r\n"
|
||||
"Host: ip-api.com\r\n"
|
||||
"Connection: close\r\n\r\n";
|
||||
send(fd, req, strlen(req), 0);
|
||||
|
||||
std::string resp;
|
||||
char buf[512];
|
||||
ssize_t n;
|
||||
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) resp.append(buf, (size_t)n);
|
||||
|
||||
auto sep = resp.find("\r\n\r\n");
|
||||
if (sep != std::string::npos) {
|
||||
const std::string body = resp.substr(sep + 4);
|
||||
if (JsonGetStr(body, "status") == "success") {
|
||||
pubIp = JsonGetStr(body, "query");
|
||||
std::string city = JsonGetStr(body, "city");
|
||||
std::string country = JsonGetStr(body, "country");
|
||||
if (!city.empty() && !country.empty()) location = city + ", " + country;
|
||||
else if (!country.empty()) location = country;
|
||||
else if (!city.empty()) location = city;
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
freeaddrinfo(res);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---- 全局状态 ----
|
||||
// CPP-06: g_bExit 是全局变量,跨调用边界不会被编译器缓存进寄存器;
|
||||
// IOCPClient 构造接受 const State& 持有引用,不能改为 atomic/volatile,
|
||||
// 直接用 State;ARM 4 字节对齐写入硬件层面原子,实际安全。
|
||||
State g_bExit{S_CLIENT_NORMAL};
|
||||
uint64_t g_myClientID = 0; // sub_conn_thread.h 需要的外部符号
|
||||
static std::atomic<bool> g_running{false};
|
||||
static std::atomic<uint64_t> g_lastHeartbeatAckMs{0};
|
||||
|
||||
// ---- JNI 反向调用(DataProcess → ControlService / CaptureService)----
|
||||
static JavaVM* g_jvm = nullptr;
|
||||
static jclass g_ctrlClass = nullptr;
|
||||
static jmethodID g_ctrlMethod = nullptr;
|
||||
static jmethodID g_getActiveWindowMid = nullptr;
|
||||
// CaptureService.requestIdr() / forceFirstFrame() — ScreenSpyThread 子连接建立后触发
|
||||
static jclass g_captureClass = nullptr;
|
||||
static jmethodID g_requestIdrMid = nullptr;
|
||||
static jmethodID g_forceFirstFrameMid = nullptr;
|
||||
|
||||
// 心跳日志限流:每 60 秒最多打一次
|
||||
static std::atomic<uint64_t> g_lastHbLogMs{0};
|
||||
|
||||
static constexpr uint64_t HB_LOG_INTERVAL_MS = 60000;
|
||||
|
||||
// 供主连接和子连接共同调用:把 COMMAND_SCREEN_CONTROL 路由到 ControlService.onControlEvent()
|
||||
// 定义在全局函数区;ScreenHandler.h 通过 extern 声明使用。
|
||||
void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY)
|
||||
{
|
||||
if (!g_jvm || !g_ctrlClass || !g_ctrlMethod) {
|
||||
LOGE("SCREEN_CONTROL: JNI not ready (ctrl=%p method=%p)", g_ctrlClass, g_ctrlMethod);
|
||||
return;
|
||||
}
|
||||
if (msgVal != 0x200u)
|
||||
LOGI("SCREEN_CONTROL: msg=0x%X pt=(%d,%d)", msgVal, ptX, ptY);
|
||||
|
||||
JNIEnv* jenv = nullptr;
|
||||
bool attached = false;
|
||||
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||
if (st == JNI_EDETACHED) {
|
||||
// CPP-03 fix: 检查 AttachCurrentThread 返回值,失败时 jenv 仍为 null
|
||||
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
|
||||
attached = true;
|
||||
} else if (st != JNI_OK || !jenv) {
|
||||
return;
|
||||
}
|
||||
jenv->CallStaticVoidMethod(g_ctrlClass, g_ctrlMethod,
|
||||
(jint)msgVal, (jlong)wParam, (jint)ptX, (jint)ptY);
|
||||
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||
if (attached) g_jvm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
static std::string GetActiveWindowFromJava()
|
||||
{
|
||||
if (!g_jvm || !g_ctrlClass || !g_getActiveWindowMid) return "Android";
|
||||
JNIEnv* jenv = nullptr;
|
||||
bool attached = false;
|
||||
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||
if (st == JNI_EDETACHED) {
|
||||
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return "Android";
|
||||
attached = true;
|
||||
} else if (st != JNI_OK || !jenv) {
|
||||
return "Android";
|
||||
}
|
||||
jstring js = (jstring)jenv->CallStaticObjectMethod(g_ctrlClass, g_getActiveWindowMid);
|
||||
std::string result = "Android";
|
||||
if (js && !jenv->ExceptionOccurred()) {
|
||||
const char* c = jenv->GetStringUTFChars(js, nullptr);
|
||||
if (c) { result = c; jenv->ReleaseStringUTFChars(js, c); }
|
||||
jenv->DeleteLocalRef(js);
|
||||
}
|
||||
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||
if (attached) g_jvm->DetachCurrentThread();
|
||||
return result;
|
||||
}
|
||||
|
||||
// 服务器连接参数
|
||||
static std::string g_serverIp;
|
||||
static int g_serverPort = 443;
|
||||
|
||||
// 设备信息(nativeInit 传入)
|
||||
static std::string g_androidId;
|
||||
static std::string g_deviceModel;
|
||||
static std::string g_androidVersion;
|
||||
static std::string g_screenRes;
|
||||
static std::string g_username;
|
||||
static std::string g_apkPath;
|
||||
|
||||
// 屏幕尺寸(Java 侧 MediaCodec 配置后通过 nativeSetScreenSize 设置)
|
||||
// 初始化为 0;ScreenSpyThread 等到非零后再读,避免在 nativeSetScreenSize 之前
|
||||
// 拿到默认值 1280×720 发给服务端导致 decoder 尺寸错误黑屏。
|
||||
static std::atomic<int> g_screenWidth{0};
|
||||
static std::atomic<int> g_screenHeight{0};
|
||||
|
||||
// 活跃子连接的 handler 集合(受 g_shMutex 保护);支持多个浏览者/控制者同时连接
|
||||
static std::set<AndroidScreenHandler*> g_screenHandlers;
|
||||
static std::mutex g_shMutex;
|
||||
|
||||
// ------------------------------------------------------------------ 屏幕子连接线程
|
||||
// 通过 JNI 调用 CaptureService 的静态方法,复用同一套 attach/detach 模板。
|
||||
static void CallCaptureStaticVoid(jmethodID mid)
|
||||
{
|
||||
if (!g_jvm || !g_captureClass || !mid) return;
|
||||
JNIEnv* jenv = nullptr;
|
||||
bool attached = false;
|
||||
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||
if (st == JNI_EDETACHED) {
|
||||
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
|
||||
attached = true;
|
||||
} else if (st != JNI_OK || !jenv) {
|
||||
return;
|
||||
}
|
||||
jenv->CallStaticVoidMethod(g_captureClass, mid);
|
||||
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||
if (attached) g_jvm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
// 触发编码器输出关键帧(setParameters REQUEST_SYNC_FRAME)
|
||||
static void RequestIdrFromJava() { CallCaptureStaticVoid(g_requestIdrMid); }
|
||||
|
||||
// 强制 VirtualDisplay 推一帧(静止屏幕绕过 SurfaceFlinger 空闲优化)
|
||||
static void ForceFirstFrameFromJava() { CallCaptureStaticVoid(g_forceFirstFrameMid); }
|
||||
|
||||
static void ScreenSpyThread()
|
||||
{
|
||||
// 等待 Java 侧 startCapture() 调用 nativeSetScreenSize 设置真实分辨率。
|
||||
// 若在此之前读到默认值 0,SendBitmapInfo 会发错误尺寸给服务端,
|
||||
// 导致浏览器 initDecoder 尺寸与 H.264 SPS 不一致 → decode error → 黑屏。
|
||||
for (int i = 0; i < 100 && g_screenWidth.load() == 0 && g_running.load(); ++i)
|
||||
Sleep(50); // 最多等 5 秒
|
||||
|
||||
int w = g_screenWidth.load();
|
||||
int h = g_screenHeight.load();
|
||||
if (w == 0 || h == 0) {
|
||||
LOGE("ScreenSpyThread: screen size not set after 5s, abort");
|
||||
return;
|
||||
}
|
||||
LOGI("ScreenSpyThread start → %s:%d size=%dx%d", g_serverIp.c_str(), g_serverPort, w, h);
|
||||
|
||||
// 服务端只发一次 COMMAND_SCREEN_SPY 就等子连接,子连接失败必须自己重试
|
||||
for (int attempt = 1; attempt <= 20 && S_CLIENT_NORMAL == g_bExit && g_running.load(); ++attempt) {
|
||||
// 声明在 try 外部,确保 catch 中 handler 仍有效,可安全从 g_screenHandlers 移除
|
||||
std::unique_ptr<IOCPClient> client;
|
||||
std::unique_ptr<AndroidScreenHandler> handler;
|
||||
try {
|
||||
client = std::make_unique<IOCPClient>(g_bExit, true);
|
||||
client->EnableSubConnAuth(true, g_myClientID);
|
||||
|
||||
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||
LOGI("ScreenSpyThread: connect failed (attempt %d/20), retry 2s", attempt);
|
||||
Sleep(2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
handler = std::make_unique<AndroidScreenHandler>(client.get(), w, h);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||
g_screenHandlers.insert(handler.get());
|
||||
}
|
||||
|
||||
client->setManagerCallBack(handler.get(),
|
||||
IOCPManager::DataProcess,
|
||||
IOCPManager::ReconnectProcess);
|
||||
handler->SendBitmapInfo();
|
||||
// 子连接刚建立,浏览器 initDecoder 完成后需要关键帧才能开始显示。
|
||||
// ForceFirstFrameFromJava:短暂 resize VirtualDisplay 强制 SurfaceFlinger
|
||||
// 推一帧给编码器——静止屏幕时 SurfaceFlinger 有空闲优化不主动推帧,
|
||||
// 导致 REQUEST_SYNC_FRAME 排队等待,造成 10-60 秒黑屏。
|
||||
// RequestIdrFromJava 确保此帧被编码为 IDR。
|
||||
ForceFirstFrameFromJava();
|
||||
RequestIdrFromJava();
|
||||
|
||||
while (client->IsRunning() && client->IsConnected() && S_CLIENT_NORMAL == g_bExit)
|
||||
Sleep(200);
|
||||
|
||||
client->setManagerCallBack(nullptr, nullptr, nullptr);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||
g_screenHandlers.erase(handler.get());
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOGE("ScreenSpyThread exception (attempt %d): %s", attempt, e.what());
|
||||
// 先清除回调,再析构 handler:防止 client 在 handler 析构后仍持有其指针
|
||||
if (client) client->setManagerCallBack(nullptr, nullptr, nullptr);
|
||||
if (handler) {
|
||||
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||
g_screenHandlers.erase(handler.get());
|
||||
}
|
||||
Sleep(2000);
|
||||
continue;
|
||||
}
|
||||
break; // 正常结束,不再重试
|
||||
}
|
||||
LOGI("ScreenSpyThread exit");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ DataProcess
|
||||
int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||
{
|
||||
if (!szBuffer || !ulLength) return TRUE;
|
||||
|
||||
if (!ClientAuth::IsCommandAllowed(szBuffer[0]))
|
||||
return TRUE;
|
||||
|
||||
switch (szBuffer[0]) {
|
||||
case COMMAND_BYE:
|
||||
LOGI("COMMAND_BYE");
|
||||
g_bExit = S_CLIENT_EXIT;
|
||||
break;
|
||||
|
||||
case CMD_HEARTBEAT_ACK:
|
||||
if (ulLength >= 1 + (ULONG)sizeof(HeartbeatACK)) {
|
||||
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
||||
uint64_t now = GetUnixMs();
|
||||
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed);
|
||||
int64_t rtt = (int64_t)now - (int64_t)ack->Time;
|
||||
if (ack->ProcessingMs > 0 && (int64_t)ack->ProcessingMs < rtt)
|
||||
rtt -= ack->ProcessingMs;
|
||||
g_rttEstimator.update_from_sample((double)rtt);
|
||||
if (now - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
|
||||
g_lastHbLogMs.store(now, std::memory_order_relaxed);
|
||||
LOGI("HeartbeatACK RTT=%" PRId64 "ms SRTT=%.1fms", rtt, g_rttEstimator.srtt * 1000.0);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case CMD_MASTERSETTING: {
|
||||
MasterSettings settings;
|
||||
if (ClientAuth::HandleMasterSettings(szBuffer + 1, (int)ulLength - 1, &settings)) {
|
||||
if (settings.ReportInterval > 0)
|
||||
g_heartbeatInterval = std::max(settings.ReportInterval, 30);
|
||||
LOGI("MasterSettings OK interval=%ds (server=%d)", g_heartbeatInterval, settings.ReportInterval);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case COMMAND_SCREEN_SPY: {
|
||||
// 每个 COMMAND_SCREEN_SPY 对应一个独立子连接,支持多人同时观看/控制
|
||||
size_t active;
|
||||
{ std::lock_guard<std::mutex> lk(g_shMutex); active = g_screenHandlers.size(); }
|
||||
LOGI("COMMAND_SCREEN_SPY → ScreenSpyThread (active=%zu)", active);
|
||||
std::thread(ScreenSpyThread).detach();
|
||||
break;
|
||||
}
|
||||
|
||||
case COMMAND_SCREEN_CONTROL: {
|
||||
if (ulLength < 1 + 48u) { LOGI("SCREEN_CONTROL(main): too short %u", ulLength); break; }
|
||||
const uint8_t* p = szBuffer + 1;
|
||||
uint64_t msgVal = 0, wParam = 0, lParam = 0;
|
||||
memcpy(&msgVal, p + 8, 8);
|
||||
memcpy(&wParam, p + 16, 8);
|
||||
memcpy(&lParam, p + 24, 8);
|
||||
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
|
||||
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
|
||||
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
|
||||
break;
|
||||
}
|
||||
|
||||
case COMMAND_SHELL:
|
||||
LOGI("COMMAND_SHELL (not implemented)");
|
||||
break;
|
||||
|
||||
case COMMAND_SYSTEM:
|
||||
LOGI("COMMAND_SYSTEM (not implemented)");
|
||||
break;
|
||||
|
||||
default:
|
||||
LOGI("Unhandled cmd=%d", (int)szBuffer[0]);
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 网络主线程
|
||||
static void ConnectionThread()
|
||||
{
|
||||
LOGI("ConnectionThread → %s:%d", g_serverIp.c_str(), g_serverPort);
|
||||
|
||||
std::string g_pubIp, g_location;
|
||||
FetchGeoInfo(g_pubIp, g_location);
|
||||
// 失败时留空串——服务端检测到空串会回落到自己的 IP 地理查询;
|
||||
// 发 "?" 会被服务端当作有效值缓存,导致永远显示问号
|
||||
LOGI("GeoInfo: ip=%s loc=%s", g_pubIp.c_str(), g_location.c_str());
|
||||
|
||||
LOGIN_INFOR logInfo;
|
||||
strncpy(logInfo.szPCName, g_deviceModel.c_str(), sizeof(logInfo.szPCName) - 1);
|
||||
strncpy(logInfo.OsVerInfoEx, g_androidVersion.c_str(), sizeof(logInfo.OsVerInfoEx) - 1);
|
||||
strncpy(logInfo.szStartTime, ToPekingTimeAsString(nullptr).c_str(), sizeof(logInfo.szStartTime) - 1);
|
||||
logInfo.dwCPUMHz = GetCpuMHz();
|
||||
logInfo.bWebCamIsExist = 0;
|
||||
|
||||
g_myClientID = XXH64(g_androidId.c_str(), g_androidId.size(), 0);
|
||||
|
||||
logInfo.AddReserved("APK");
|
||||
logInfo.AddReserved(64); // OS bits
|
||||
logInfo.AddReserved(GetCpuCores()); // CPU 核数
|
||||
logInfo.AddReserved(GetMemoryGB()); // 内存 GB
|
||||
logInfo.AddReserved(g_apkPath.empty() ? "/data/app/com.yama.client"
|
||||
: g_apkPath.c_str()); // 文件路径
|
||||
logInfo.AddReserved("?");
|
||||
logInfo.AddReserved(logInfo.szStartTime);
|
||||
logInfo.AddReserved("?");
|
||||
logInfo.AddReserved(64); // 程序位数
|
||||
logInfo.AddReserved("");
|
||||
logInfo.AddReserved(g_location.c_str()); // [10] 地理位置
|
||||
logInfo.AddReserved(g_pubIp.c_str()); // [11] 公网 IP
|
||||
logInfo.AddReserved("v1.0.0");
|
||||
logInfo.AddReserved(g_username.c_str());
|
||||
logInfo.AddReserved(0); // IsRunningAsAdmin
|
||||
logInfo.AddReserved(g_screenRes.c_str());
|
||||
logInfo.AddReserved(std::to_string(g_myClientID).c_str());
|
||||
logInfo.AddReserved((int)getpid()); // PID
|
||||
logInfo.AddReserved(FormatFileSize(GetFileSize(g_apkPath)).c_str()); // 文件大小
|
||||
|
||||
ClientAuth::g_loginMsg = std::string(logInfo.szStartTime) + "|" + std::to_string(g_myClientID);
|
||||
LOGI("ClientID=%" PRIu64, g_myClientID);
|
||||
|
||||
std::unique_ptr<IOCPClient> client(new IOCPClient(g_bExit, false));
|
||||
client->setManagerCallBack(nullptr, DataProcess, nullptr);
|
||||
|
||||
while (S_CLIENT_NORMAL == g_bExit && g_running.load()) {
|
||||
clock_t c = clock();
|
||||
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||
LOGI("Connect failed, retry 5s");
|
||||
Sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
ClientAuth::OnNewConnection();
|
||||
client->SendLoginInfo(logInfo.Speed(clock() - c));
|
||||
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||
LOGI("Connected & login sent");
|
||||
|
||||
while (client->IsRunning() && client->IsConnected()
|
||||
&& S_CLIENT_NORMAL == g_bExit && g_running.load())
|
||||
{
|
||||
int interval = g_heartbeatInterval > 0 ? g_heartbeatInterval : 30;
|
||||
for (int i = 0; i < interval; ++i) {
|
||||
if (!client->IsRunning() || !client->IsConnected()
|
||||
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) break;
|
||||
Sleep(1000);
|
||||
}
|
||||
if (!client->IsRunning() || !client->IsConnected()
|
||||
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) break;
|
||||
|
||||
if (ClientAuth::IsTimedOut()) {
|
||||
LOGI("MasterSettings timeout → reconnect");
|
||||
client->Disconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
{
|
||||
int ackTO = (interval * 3 > 60) ? interval * 3 : 60;
|
||||
uint64_t last = g_lastHeartbeatAckMs.load(std::memory_order_relaxed);
|
||||
uint64_t now = GetUnixMs();
|
||||
if (last > 0 && now > last && now - last > (uint64_t)ackTO * 1000ULL) {
|
||||
LOGI("ACK timeout → reconnect");
|
||||
client->Disconnect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Heartbeat hb;
|
||||
hb.Time = GetUnixMs();
|
||||
hb.Ping = (int)(g_rttEstimator.srtt * 1000.0);
|
||||
std::string aw = GetActiveWindowFromJava();
|
||||
strncpy(hb.ActiveWnd, aw.c_str(), sizeof(hb.ActiveWnd) - 1);
|
||||
|
||||
BYTE buf[sizeof(Heartbeat) + 1];
|
||||
buf[0] = TOKEN_HEARTBEAT;
|
||||
memcpy(buf + 1, &hb, sizeof(Heartbeat));
|
||||
client->Send2Server((char*)buf, sizeof(buf));
|
||||
{
|
||||
uint64_t now2 = GetUnixMs();
|
||||
if (now2 - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
|
||||
// ACK 分支会更新 g_lastHbLogMs,这里仅在没有 ACK 时兜底打印
|
||||
LOGI("Heartbeat Ping=%dms", hb.Ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
LOGI("Disconnected");
|
||||
}
|
||||
|
||||
g_running.store(false);
|
||||
LOGI("ConnectionThread exit");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ JNI
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_yama_client_YamaBridge_nativeInit(
|
||||
JNIEnv* env, jobject,
|
||||
jstring serverIp, jint serverPort,
|
||||
jstring androidId, jstring deviceModel,
|
||||
jstring androidVersion, jstring screenRes,
|
||||
jstring username, jstring apkPath)
|
||||
{
|
||||
// CPP-01 fix: CAS 原子地完成"检查+设置",消除 check-then-act 竞争
|
||||
bool expected = false;
|
||||
if (!g_running.compare_exchange_strong(expected, true)) {
|
||||
LOGI("already running"); return -1;
|
||||
}
|
||||
|
||||
auto toStr = [&](jstring js) -> std::string {
|
||||
if (!js) return "";
|
||||
const char* c = env->GetStringUTFChars(js, nullptr);
|
||||
std::string s = c ? c : "";
|
||||
env->ReleaseStringUTFChars(js, c);
|
||||
return s;
|
||||
};
|
||||
|
||||
g_serverIp = toStr(serverIp);
|
||||
g_serverPort = (int)serverPort;
|
||||
g_androidId = toStr(androidId);
|
||||
g_deviceModel = toStr(deviceModel);
|
||||
g_androidVersion = toStr(androidVersion);
|
||||
g_screenRes = toStr(screenRes);
|
||||
g_username = toStr(username);
|
||||
g_apkPath = toStr(apkPath);
|
||||
|
||||
// 缓存 JVM 和 Class/Method 引用。必须在 Java 线程(nativeInit 调用栈)
|
||||
// 里做 FindClass,否则 AttachCurrentThread 的后台线程只有系统 ClassLoader,
|
||||
// 找不到 App 类,GetStaticMethodID 拿到 null 导致后续调用崩溃。
|
||||
if (g_jvm == nullptr) {
|
||||
if (env->GetJavaVM(&g_jvm) == JNI_OK) {
|
||||
jclass cls = env->FindClass("com/yama/client/ControlService");
|
||||
if (cls) {
|
||||
g_ctrlClass = (jclass)env->NewGlobalRef(cls);
|
||||
g_ctrlMethod = env->GetStaticMethodID(g_ctrlClass, "onControlEvent", "(IJII)V");
|
||||
if (!g_ctrlMethod) LOGE("ControlService.onControlEvent not found");
|
||||
g_getActiveWindowMid = env->GetStaticMethodID(g_ctrlClass, "getActiveWindow", "()Ljava/lang/String;");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); g_getActiveWindowMid = nullptr; }
|
||||
if (!g_getActiveWindowMid) LOGE("ControlService.getActiveWindow not found");
|
||||
} else {
|
||||
LOGE("ControlService class not found");
|
||||
}
|
||||
jclass capCls = env->FindClass("com/yama/client/CaptureService");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); capCls = nullptr; }
|
||||
if (capCls) {
|
||||
g_captureClass = (jclass)env->NewGlobalRef(capCls);
|
||||
g_requestIdrMid = env->GetStaticMethodID(g_captureClass, "requestIdr", "()V");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); g_requestIdrMid = nullptr; }
|
||||
if (!g_requestIdrMid) LOGE("CaptureService.requestIdr not found");
|
||||
// GetStaticMethodID 找不到方法时会挂起 JNI 异常;必须清除,
|
||||
// 否则 nativeInit 返回时 Java 层会抛出 NoSuchMethodError 崩溃。
|
||||
g_forceFirstFrameMid = env->GetStaticMethodID(g_captureClass, "forceFirstFrame", "()V");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); g_forceFirstFrameMid = nullptr; }
|
||||
if (!g_forceFirstFrameMid) LOGE("CaptureService.forceFirstFrame not found");
|
||||
} else {
|
||||
LOGE("CaptureService class not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g_bExit = S_CLIENT_NORMAL;
|
||||
// g_running 已在 CAS 中设为 true,不重复 store
|
||||
std::thread(ConnectionThread).detach();
|
||||
LOGI("nativeInit OK server=%s:%d", g_serverIp.c_str(), g_serverPort);
|
||||
return 0;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_yama_client_YamaBridge_nativeStop(JNIEnv*, jobject)
|
||||
{
|
||||
LOGI("nativeStop");
|
||||
g_bExit = S_CLIENT_EXIT;
|
||||
g_running.store(false);
|
||||
}
|
||||
|
||||
// 由 CaptureService 在 MediaCodec 配置完成后调用,告知 C++ 侧实际捕获尺寸
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_yama_client_YamaBridge_nativeSetScreenSize(JNIEnv*, jobject, jint width, jint height)
|
||||
{
|
||||
g_screenWidth.store(width);
|
||||
g_screenHeight.store(height);
|
||||
LOGI("ScreenSize=%dx%d", width, height);
|
||||
}
|
||||
|
||||
// 由 CaptureService 的 MediaCodec.Callback 调用,投递 H.264 NALU
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_yama_client_YamaBridge_nativeOnH264Frame(
|
||||
JNIEnv* env, jobject,
|
||||
jbyteArray data, jint offset, jint size, jboolean isKeyframe)
|
||||
{
|
||||
// CPP-04 fix: 验证 offset/size 边界,防止越界读写导致崩溃
|
||||
if (!data || offset < 0 || size <= 0) return;
|
||||
jsize arrLen = env->GetArrayLength(data);
|
||||
if ((jlong)offset + size > arrLen) {
|
||||
LOGE("nativeOnH264Frame: bounds violation offset=%d size=%d arrLen=%d", offset, size, arrLen);
|
||||
return;
|
||||
}
|
||||
|
||||
jbyte* buf = env->GetByteArrayElements(data, nullptr);
|
||||
if (!buf) return;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||
for (auto* h : g_screenHandlers)
|
||||
h->OnFrameData((const uint8_t*)buf + offset, (uint32_t)size, (bool)isKeyframe);
|
||||
}
|
||||
|
||||
env->ReleaseByteArrayElements(data, buf, JNI_ABORT);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
360
android/app/src/main/java/com/yama/client/CaptureService.kt
Normal file
@@ -0,0 +1,360 @@
|
||||
package com.yama.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
|
||||
class CaptureService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "YAMA"
|
||||
const val CHANNEL_ID = "yama_service"
|
||||
const val NOTIF_ID = 1
|
||||
|
||||
const val EXTRA_SERVER_IP = "server_ip"
|
||||
const val EXTRA_SERVER_PORT = "server_port"
|
||||
const val EXTRA_PROJECTION_RESULT = "projection_result"
|
||||
const val EXTRA_PROJECTION_DATA = "projection_data"
|
||||
|
||||
private const val MAX_LONG_SIDE = 1080
|
||||
|
||||
// CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争
|
||||
@Volatile var instance: CaptureService? = null
|
||||
|
||||
@JvmStatic
|
||||
fun requestIdr() {
|
||||
val svc = instance ?: return
|
||||
svc.idrHandler.post {
|
||||
runCatching {
|
||||
val p = Bundle()
|
||||
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||
svc.mediaCodec?.setParameters(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 静止屏幕黑屏修复:SurfaceFlinger 在无脏区时不向 VirtualDisplay 推帧,
|
||||
* 导致编码器无输入,REQUEST_SYNC_FRAME 悬空排队,浏览器等 10-60 秒才见画面。
|
||||
* 解决方法:将 VirtualDisplay 宽度临时改变 2px,触发一次强制合成,
|
||||
* 编码器随即得到输入帧并消费已排队的 IDR 请求。150ms 后恢复原始尺寸。
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forceFirstFrame() {
|
||||
val svc = instance ?: return
|
||||
svc.idrHandler.post {
|
||||
runCatching {
|
||||
val vd = svc.virtualDisplay ?: return@runCatching
|
||||
val w = ControlService.encW
|
||||
val h = ControlService.encH
|
||||
val dpi = svc.resources.displayMetrics.densityDpi
|
||||
Log.d(TAG, "forceFirstFrame: resize ${w}x${h} → ${w+2}x${h} to trigger composition")
|
||||
vd.resize(w + 2, h, dpi)
|
||||
svc.idrHandler.postDelayed({
|
||||
runCatching { vd.resize(w, h, dpi) }
|
||||
Log.d(TAG, "forceFirstFrame: restored ${w}x${h}")
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var mediaCodec: MediaCodec? = null
|
||||
|
||||
private val idrHandler = Handler(Looper.getMainLooper())
|
||||
// CS-04 fix: runCatching 包住 setParameters,防止 codec 已停止时抛异常
|
||||
private val idrRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
runCatching {
|
||||
val p = Bundle()
|
||||
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||
mediaCodec?.setParameters(p)
|
||||
}
|
||||
idrHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, buildNotification(),
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// CS-03 fix: START_STICKY 重启时 intent 为 null,必须在最前面检查
|
||||
if (intent == null) return START_NOT_STICKY
|
||||
val ip = intent.getStringExtra(EXTRA_SERVER_IP) ?: return START_NOT_STICKY
|
||||
val port = intent.getIntExtra(EXTRA_SERVER_PORT, 443)
|
||||
|
||||
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: ""
|
||||
val model = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
val osVer = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
|
||||
val (sw, sh) = getPhysicalResolution()
|
||||
val res = "1:${sw}*${sh}"
|
||||
val user = Build.USER
|
||||
|
||||
Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res")
|
||||
|
||||
// YB-01 fix: so 库加载失败是 Error,不被 catch(Exception) 捕获,需单独处理
|
||||
try {
|
||||
YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "")
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.e(TAG, "Native library load failed: $e")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1)
|
||||
val projData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
intent.getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java)
|
||||
else
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA)
|
||||
|
||||
if (projResult == android.app.Activity.RESULT_OK && projData != null) {
|
||||
val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
val mp = pm.getMediaProjection(projResult, projData)
|
||||
mediaProjection = mp
|
||||
// CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃
|
||||
if (sw > 0 && sh > 0) {
|
||||
startCapture(mp, sw, sh)
|
||||
} else {
|
||||
Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "No MediaProjection token, skipping screen capture")
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
instance = null
|
||||
stopCapture()
|
||||
YamaBridge.nativeStop()
|
||||
Log.i(TAG, "CaptureService destroyed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MediaProjection + MediaCodec
|
||||
|
||||
private fun startCapture(mp: MediaProjection, physW: Int, physH: Int) {
|
||||
val (encW, encH) = scaleDown(physW, physH, MAX_LONG_SIDE)
|
||||
val dpi = resources.displayMetrics.densityDpi
|
||||
|
||||
Log.i(TAG, "Capture: phys=${physW}x${physH} enc=${encW}x${encH} dpi=$dpi")
|
||||
|
||||
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||
val surface = configureEncoder(codec, encW, encH)
|
||||
|
||||
var configData: ByteArray? = null
|
||||
|
||||
codec.setCallback(object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { /* Surface 模式不用 */ }
|
||||
|
||||
override fun onOutputBufferAvailable(mc: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
||||
val buf = mc.getOutputBuffer(index)
|
||||
if (buf == null || info.size == 0) {
|
||||
mc.releaseOutputBuffer(index, false)
|
||||
return
|
||||
}
|
||||
buf.position(info.offset)
|
||||
buf.limit(info.offset + info.size)
|
||||
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||||
configData = ByteArray(info.size).also { buf.get(it) }
|
||||
mc.releaseOutputBuffer(index, false)
|
||||
return
|
||||
}
|
||||
|
||||
val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
|
||||
val frame = ByteArray(info.size).also { buf.get(it) }
|
||||
mc.releaseOutputBuffer(index, false)
|
||||
|
||||
// 部分安卓硬件编码器(如某些高通实现)对 REQUEST_SYNC_FRAME 强制产生的
|
||||
// IDR 帧不设置 BUFFER_FLAG_KEY_FRAME,导致 SendLoop 将所有帧视为 P 帧丢弃。
|
||||
// 补充扫描 NALU 字节来识别真正的关键帧,确保首帧能顺利发出。
|
||||
val actualIsKey = isKey || isNaluKeyframe(frame)
|
||||
val payload = if (actualIsKey && configData != null) configData!! + frame else frame
|
||||
YamaBridge.nativeOnH264Frame(payload, 0, payload.size, actualIsKey)
|
||||
}
|
||||
|
||||
override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) {
|
||||
Log.e(TAG, "MediaCodec error: $e")
|
||||
}
|
||||
|
||||
override fun onOutputFormatChanged(mc: MediaCodec, format: MediaFormat) {
|
||||
Log.i(TAG, "Output format: $format")
|
||||
}
|
||||
})
|
||||
|
||||
codec.start()
|
||||
mediaCodec = codec
|
||||
|
||||
idrHandler.postDelayed(idrRunnable, 500)
|
||||
|
||||
// Android 14+ requires callback registered before createVirtualDisplay()
|
||||
mp.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped")
|
||||
stopCapture()
|
||||
stopSelf()
|
||||
}
|
||||
}, null)
|
||||
|
||||
virtualDisplay = mp.createVirtualDisplay(
|
||||
"YAMA",
|
||||
encW, encH, dpi,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
surface, null, null
|
||||
)
|
||||
|
||||
YamaBridge.nativeSetScreenSize(encW, encH)
|
||||
|
||||
ControlService.physW = physW
|
||||
ControlService.physH = physH
|
||||
ControlService.encW = encW
|
||||
ControlService.encH = encH
|
||||
|
||||
Log.i(TAG, "MediaCodec + VirtualDisplay started ${encW}x${encH}")
|
||||
}
|
||||
|
||||
private fun buildFmt(w: Int, h: Int, lowLatency: Boolean, baseline: Boolean) =
|
||||
MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h).apply {
|
||||
setInteger(MediaFormat.KEY_COLOR_FORMAT,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
|
||||
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||
setInteger(MediaFormat.KEY_OPERATING_RATE, 30)
|
||||
if (lowLatency) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
setInteger(MediaFormat.KEY_LATENCY, 0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
setInteger(MediaFormat.KEY_LOW_LATENCY, 1)
|
||||
}
|
||||
if (baseline)
|
||||
setInteger(MediaFormat.KEY_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||
}
|
||||
|
||||
// 逐级降级:低延迟+Baseline → 低延迟 → 默认,确保真机兼容
|
||||
private fun configureEncoder(codec: MediaCodec, w: Int, h: Int): android.view.Surface {
|
||||
val configs = listOf(
|
||||
true to true,
|
||||
true to false,
|
||||
false to false,
|
||||
)
|
||||
for ((ll, bl) in configs) {
|
||||
try {
|
||||
val fmt = buildFmt(w, h, ll, bl)
|
||||
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
Log.i(TAG, "Encoder configured: lowLatency=$ll baseline=$bl")
|
||||
return codec.createInputSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Encoder config failed (lowLatency=$ll baseline=$bl): $e, retrying…")
|
||||
try { codec.reset() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
throw RuntimeException("MediaCodec: all encoder configs failed")
|
||||
}
|
||||
|
||||
private fun stopCapture() {
|
||||
idrHandler.removeCallbacks(idrRunnable)
|
||||
runCatching { virtualDisplay?.release() }
|
||||
// CS-07 fix: stop 和 release 分开,stop 失败不会跳过 release 导致资源泄漏
|
||||
runCatching { mediaCodec?.stop() }
|
||||
runCatching { mediaCodec?.release() }
|
||||
runCatching { mediaProjection?.stop() }
|
||||
virtualDisplay = null
|
||||
mediaCodec = null
|
||||
mediaProjection = null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助函数
|
||||
|
||||
private fun getPhysicalResolution(): Pair<Int, Int> {
|
||||
val wm = getSystemService(WINDOW_SERVICE) as WindowManager
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val b = wm.currentWindowMetrics.bounds
|
||||
Pair(b.width(), b.height())
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val dm = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealMetrics(dm)
|
||||
Pair(dm.widthPixels, dm.heightPixels)
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描 Annex-B NALU 流,遇到 NAL type 5(IDR)、7(SPS)、8(PPS) 即判定为关键帧 */
|
||||
private fun isNaluKeyframe(data: ByteArray): Boolean {
|
||||
var i = 0
|
||||
while (i + 4 < data.size) {
|
||||
if (data[i] == 0.toByte() && data[i+1] == 0.toByte() &&
|
||||
data[i+2] == 0.toByte() && data[i+3] == 1.toByte()) {
|
||||
val nalType = data[i + 4].toInt() and 0x1F
|
||||
if (nalType == 5 || nalType == 7 || nalType == 8) return true
|
||||
i += 5
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 按长边上限等比缩小,宽高各自 2 对齐(H.264 要求) */
|
||||
private fun scaleDown(w: Int, h: Int, maxLong: Int): Pair<Int, Int> {
|
||||
val longSide = maxOf(w, h)
|
||||
if (longSide <= maxLong) return Pair(w and -2, h and -2)
|
||||
val scale = maxLong.toDouble() / longSide
|
||||
return Pair((w * scale).toInt() and -2, (h * scale).toInt() and -2)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL_ID, "YAMA Service",
|
||||
NotificationManager.IMPORTANCE_LOW)
|
||||
getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
else
|
||||
@Suppress("DEPRECATION") Notification.Builder(this)
|
||||
return b.setContentTitle("YAMA")
|
||||
.setContentText("Screen monitoring active")
|
||||
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
290
android/app/src/main/java/com/yama/client/ControlService.kt
Normal file
@@ -0,0 +1,290 @@
|
||||
package com.yama.client
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
|
||||
/**
|
||||
* AccessibilityService that injects touch gestures and global key actions
|
||||
* dispatched by the server (COMMAND_SCREEN_CONTROL / MSG64).
|
||||
*
|
||||
* Requires API 24+ for dispatchGesture(); guarded at runtime so the APK
|
||||
* installs on API 21+ but gesture injection only runs on 24+.
|
||||
*
|
||||
* Activation: Settings → Accessibility → YAMA → enable.
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
class ControlService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "YAMA_CTRL"
|
||||
|
||||
// ── Service instance ─────────────────────────────────────────────
|
||||
@Volatile var instance: ControlService? = null
|
||||
|
||||
// ── Active foreground window ─────────────────────────────────────
|
||||
@JvmStatic
|
||||
@Volatile var activeWindow: String = "Android"
|
||||
|
||||
// ── Screen geometry (set by CaptureService) ──────────────────────
|
||||
// Server sends coords in encoding space [0, encW) × [0, encH).
|
||||
// We map them to physical screen space before dispatching.
|
||||
@Volatile var physW = 1080
|
||||
@Volatile var physH = 1920
|
||||
@Volatile var encW = 1080
|
||||
@Volatile var encH = 1920
|
||||
|
||||
// ── Windows message constants ────────────────────────────────────
|
||||
const val WM_MOUSEMOVE = 0x0200
|
||||
const val WM_LBUTTONDOWN = 0x0201
|
||||
const val WM_LBUTTONUP = 0x0202
|
||||
const val WM_LBUTTONDBLCLK = 0x0203
|
||||
const val WM_RBUTTONDOWN = 0x0204
|
||||
const val WM_MBUTTONDOWN = 0x0207
|
||||
const val WM_MOUSEWHEEL = 0x020A
|
||||
const val WM_KEYDOWN = 0x0100
|
||||
const val WM_SYSKEYDOWN = 0x0104
|
||||
|
||||
/**
|
||||
* Called from C++ DataProcess() via JNI on the IO thread.
|
||||
* Parameters:
|
||||
* message – Windows WM_* constant (low 16 bits of MSG64.message)
|
||||
* wParam – MSG64.wParam (key/button flags; high word of MOUSEWHEEL = delta)
|
||||
* ptX/ptY – MSG64.pt in encoding-space pixels
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onControlEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||
val svc = instance ?: run {
|
||||
Log.w(TAG, "ControlService inactive — event 0x${message.toString(16)} dropped")
|
||||
return
|
||||
}
|
||||
// All gesture state lives on the main thread; post there.
|
||||
svc.handler.post { svc.handleEvent(message, wParam, ptX, ptY) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screen state receiver ─────────────────────────────────────────────
|
||||
private val screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> activeWindow = "Locked"
|
||||
Intent.ACTION_USER_PRESENT -> activeWindow = "Android"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main-thread handler ───────────────────────────────────────────────
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ── Left-button drag accumulator ─────────────────────────────────────
|
||||
private var lbuttonDown = false
|
||||
private var gestureStart = 0L
|
||||
// Pairs of (millisecond offset from gesture start, screen point)
|
||||
private val strokePoints = mutableListOf<Pair<Long, PointF>>()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// AccessibilityService lifecycle
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}
|
||||
registerReceiver(screenReceiver, filter)
|
||||
Log.i(TAG, "ControlService connected phys=${physW}x${physH} enc=${encW}x${encH}")
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
|
||||
val pkg = event.packageName?.toString()
|
||||
if (!pkg.isNullOrEmpty()) activeWindow = pkg
|
||||
}
|
||||
}
|
||||
override fun onInterrupt() {}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
runCatching { unregisterReceiver(screenReceiver) }
|
||||
instance = null
|
||||
Log.i(TAG, "ControlService destroyed")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Event routing (runs on main thread)
|
||||
|
||||
private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||
// Scale encoding-space coords to physical screen
|
||||
val x = if (encW > 0) ptX.toFloat() * physW / encW else ptX.toFloat()
|
||||
val y = if (encH > 0) ptY.toFloat() * physH / encH else ptY.toFloat()
|
||||
if (message != WM_MOUSEMOVE)
|
||||
Log.d(TAG, "event 0x${message.toString(16)} enc=($ptX,$ptY) phys=(${x.toInt()},${y.toInt()})")
|
||||
|
||||
when (message) {
|
||||
|
||||
// ── Left button: accumulate drag path, dispatch on up ─────────
|
||||
WM_LBUTTONDOWN -> {
|
||||
lbuttonDown = true
|
||||
gestureStart = SystemClock.uptimeMillis()
|
||||
strokePoints.clear()
|
||||
strokePoints.add(0L to PointF(x, y))
|
||||
}
|
||||
WM_MOUSEMOVE -> {
|
||||
if (lbuttonDown) {
|
||||
strokePoints.add((SystemClock.uptimeMillis() - gestureStart) to PointF(x, y))
|
||||
}
|
||||
}
|
||||
WM_LBUTTONUP -> {
|
||||
if (!lbuttonDown) return
|
||||
lbuttonDown = false
|
||||
val elapsed = SystemClock.uptimeMillis() - gestureStart
|
||||
strokePoints.add(elapsed to PointF(x, y))
|
||||
val down = strokePoints[0].second
|
||||
val dx = x - down.x
|
||||
val dy = y - down.y
|
||||
|
||||
// ── System edge-gesture detection ────────────────────────────
|
||||
// dispatchGesture() is unreliable for system gestures; use performGlobalAction.
|
||||
val hEdge = physH * 0.08f // 8% of screen height = top/bottom trigger zone
|
||||
val wEdge = physW * 0.08f // 8% of screen width = left/right trigger zone
|
||||
val minH = physH * 0.15f // minimum vertical travel
|
||||
val minW = physW * 0.20f // minimum horizontal travel
|
||||
|
||||
val fromLeft = down.x < wEdge && dx > minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
|
||||
val fromRight = down.x > physW - wEdge && dx < -minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
|
||||
val fromTop = down.y < hEdge && dy > minH && Math.abs(dy) > Math.abs(dx)
|
||||
val fromBottom = down.y > physH - hEdge && dy < -minH && Math.abs(dy) > Math.abs(dx)
|
||||
|
||||
when {
|
||||
fromLeft || fromRight -> {
|
||||
Log.d(TAG, "Edge-swipe back (fromLeft=$fromLeft)")
|
||||
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
}
|
||||
fromTop -> {
|
||||
// Right half of screen → quick settings; left half → notifications
|
||||
if (down.x > physW * 0.5f) {
|
||||
Log.d(TAG, "Top-swipe quick settings")
|
||||
performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS)
|
||||
} else {
|
||||
Log.d(TAG, "Top-swipe notifications")
|
||||
performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
fromBottom -> {
|
||||
// Slow swipe (≥400 ms) or very long travel → Recents; quick swipe → Home
|
||||
if (elapsed >= 400L || Math.abs(dy) > physH * 0.40f) {
|
||||
Log.d(TAG, "Bottom-swipe recents (elapsed=${elapsed}ms)")
|
||||
performGlobalAction(GLOBAL_ACTION_RECENTS)
|
||||
} else {
|
||||
Log.d(TAG, "Bottom-swipe home (elapsed=${elapsed}ms)")
|
||||
performGlobalAction(GLOBAL_ACTION_HOME)
|
||||
}
|
||||
}
|
||||
dx * dx + dy * dy < 100f -> // 移动 < 10px → 单击
|
||||
dispatchTap(down.x, down.y)
|
||||
else ->
|
||||
dispatchTouchGesture(strokePoints)
|
||||
}
|
||||
// 手势执行后立即请求关键帧,让 decoder 尽快看到屏幕变化
|
||||
CaptureService.requestIdr()
|
||||
strokePoints.clear()
|
||||
}
|
||||
|
||||
// ── Double-click → two rapid taps ────────────────────────────
|
||||
WM_LBUTTONDBLCLK -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
val p = singlePointPath(x, y)
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(p, 0L, 80L))
|
||||
.addStroke(GestureDescription.StrokeDescription(p, 200L, 80L))
|
||||
.build()
|
||||
dispatchGesture(gesture, null, null)
|
||||
}
|
||||
|
||||
// ── Right-click → long press (600 ms) ────────────────────────
|
||||
WM_RBUTTONDOWN -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
val stroke = GestureDescription.StrokeDescription(singlePointPath(x, y), 0L, 600L)
|
||||
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
}
|
||||
|
||||
// ── Scroll wheel → vertical swipe ─────────────────────────────
|
||||
WM_MOUSEWHEEL -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
// High 16 bits of wParam hold the signed delta (+120 = up, -120 = down)
|
||||
val delta = (wParam.toInt() ushr 16).toShort().toInt()
|
||||
// Wheel-up (+delta) = user wants content above = finger swipes DOWN (+y)
|
||||
val dy = if (delta > 0) 400f else -400f
|
||||
val path = Path().apply { moveTo(x, y); lineTo(x, y + dy) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, 300L)
|
||||
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||
CaptureService.requestIdr()
|
||||
}
|
||||
|
||||
// ── Keyboard → global actions only ───────────────────────────
|
||||
WM_KEYDOWN, WM_SYSKEYDOWN -> handleKeyDown(wParam.toInt() and 0xFFFF)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
|
||||
private fun dispatchTouchGesture(points: List<Pair<Long, PointF>>) {
|
||||
if (points.isEmpty()) return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
|
||||
val path = Path()
|
||||
path.moveTo(points[0].second.x, points[0].second.y)
|
||||
for (i in 1 until points.size) {
|
||||
path.lineTo(points[i].second.x, points[i].second.y)
|
||||
}
|
||||
// Minimum 50 ms so Android registers the gesture; use actual elapsed for drags.
|
||||
val duration = maxOf(points.last().first, 50L)
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, duration)
|
||||
val ok = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(stroke).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Gesture ok") }
|
||||
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Gesture cancelled") }
|
||||
},
|
||||
null
|
||||
)
|
||||
if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)")
|
||||
}
|
||||
|
||||
private fun handleKeyDown(vk: Int) {
|
||||
when (vk) {
|
||||
0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc
|
||||
0x24 -> performGlobalAction(GLOBAL_ACTION_HOME) // VK_HOME
|
||||
0x5D -> performGlobalAction(GLOBAL_ACTION_RECENTS) // VK_APPS
|
||||
0x2C -> performGlobalAction(GLOBAL_ACTION_TAKE_SCREENSHOT) // PrtSc
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchTap(x: Float, y: Float) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
|
||||
val stroke = GestureDescription.StrokeDescription(path, 0L, 100L)
|
||||
val ok = dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Tap ok") }
|
||||
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Tap cancelled") }
|
||||
}, null)
|
||||
if (!ok) Log.e(TAG, "dispatchTap returned false")
|
||||
}
|
||||
|
||||
private fun singlePointPath(x: Float, y: Float) = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
|
||||
}
|
||||
65
android/app/src/main/java/com/yama/client/MainActivity.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.yama.client
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private val serverIp = "91.99.165.207"
|
||||
private val serverPort = 443
|
||||
|
||||
private lateinit var projectionManager: MediaProjectionManager
|
||||
|
||||
companion object {
|
||||
const val REQUEST_MEDIA_PROJECTION = 1001
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// MA-01 fix: 系统重建 Activity 时 savedInstanceState != null,
|
||||
// 此时 CaptureService 可能已在运行,不重复弹权限对话框
|
||||
if (savedInstanceState != null) { finish(); return }
|
||||
projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||
if (resultCode == Activity.RESULT_OK && data != null) {
|
||||
Log.i(CaptureService.TAG, "MediaProjection permission granted")
|
||||
val intent = Intent(this, CaptureService::class.java).apply {
|
||||
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
|
||||
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
|
||||
putExtra(CaptureService.EXTRA_PROJECTION_RESULT, resultCode)
|
||||
putExtra(CaptureService.EXTRA_PROJECTION_DATA, data)
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
} else {
|
||||
Log.w(CaptureService.TAG, "MediaProjection permission denied, starting without capture")
|
||||
val intent = Intent(this, CaptureService::class.java).apply {
|
||||
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
|
||||
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
|
||||
}
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
26
android/app/src/main/java/com/yama/client/YamaBridge.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.yama.client
|
||||
|
||||
object YamaBridge {
|
||||
init {
|
||||
System.loadLibrary("yama")
|
||||
}
|
||||
|
||||
external fun nativeInit(
|
||||
serverIp: String,
|
||||
serverPort: Int,
|
||||
androidId: String,
|
||||
deviceModel: String,
|
||||
androidVersion: String,
|
||||
screenRes: String,
|
||||
username: String,
|
||||
apkPath: String
|
||||
): Int
|
||||
|
||||
external fun nativeStop()
|
||||
|
||||
/** MediaCodec 配置完成后调用,告知 C++ 实际捕获分辨率 */
|
||||
external fun nativeSetScreenSize(width: Int, height: Int)
|
||||
|
||||
/** MediaCodec 每输出一帧 H.264 NALU 时调用 */
|
||||
external fun nativeOnH264Frame(data: ByteArray, offset: Int, size: Int, isKeyframe: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#F2F2F2</color>
|
||||
</resources>
|
||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">YAMA</string>
|
||||
<string name="accessibility_service_description">YAMA remote control service</string>
|
||||
</resources>
|
||||
8
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.YAMA" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="colorPrimary">#1A73E8</item>
|
||||
<item name="colorPrimaryDark">#1558B0</item>
|
||||
<item name="colorAccent">#1A73E8</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canPerformGestures="true"
|
||||
android:notificationTimeout="0"
|
||||
android:settingsActivity="com.yama.client.MainActivity" />
|
||||