Feat: TV remote control via D-pad focus navigation using AccessibilityService
This commit is contained in:
@@ -3,6 +3,9 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
// 模拟器调试时传 -PincludeEmulatorAbis=true;build_apk.sh 发布时强制传 false
|
||||
def INCLUDE_EMULATOR_ABIS = project.findProperty('includeEmulatorAbis')?.toBoolean() ?: false
|
||||
|
||||
android {
|
||||
namespace 'com.yama.client'
|
||||
compileSdk 35
|
||||
@@ -16,7 +19,11 @@ android {
|
||||
versionName "1.0"
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
if (INCLUDE_EMULATOR_ABIS) {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
|
||||
} else {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
|
||||
@@ -24,6 +24,7 @@ set(CORE_SOURCES
|
||||
${YAMA_ROOT}/client/Buffer.cpp
|
||||
${YAMA_ROOT}/client/IOCPClient.cpp
|
||||
${YAMA_ROOT}/client/sign_shim_unix.cpp
|
||||
${YAMA_ROOT}/common/logger.cpp
|
||||
${YAMA_ROOT}/common/ikcp.c
|
||||
)
|
||||
|
||||
@@ -34,12 +35,11 @@ set(ANDROID_SOURCES
|
||||
|
||||
add_library(yama SHARED ${CORE_SOURCES} ${ANDROID_SOURCES})
|
||||
|
||||
# ---- 预编译静态库(由 SimplePlugins/sign_lib/build_android.sh 产出)----
|
||||
# ---- 预编译静态库(由 android/build_android_libs.ps1 产出)----
|
||||
# 支持 arm64-v8a / armeabi-v7a(真机)和 x86 / x86_64(Android 模拟器)
|
||||
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"
|
||||
)
|
||||
|
||||
BIN
android/app/src/main/cpp/lib/x86/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/x86/libsign.a
Normal file
Binary file not shown.
BIN
android/app/src/main/cpp/lib/x86/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/x86/libzstd.a
Normal file
Binary file not shown.
BIN
android/app/src/main/cpp/lib/x86_64/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/x86_64/libsign.a
Normal file
Binary file not shown.
BIN
android/app/src/main/cpp/lib/x86_64/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/x86_64/libzstd.a
Normal file
Binary file not shown.
@@ -1,6 +1,7 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
@@ -9,7 +10,9 @@
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netdb.h>
|
||||
@@ -22,13 +25,18 @@
|
||||
#include "ScreenHandler.h"
|
||||
#define XXH_INLINE_ALL
|
||||
#include "common/xxhash.h"
|
||||
#include "common/logger.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__)
|
||||
|
||||
// 1 = 状态提示显示到电视屏幕;0 = 只写 logcat,不显示到屏幕
|
||||
#define SCREEN_STATUS_ENABLED 0
|
||||
|
||||
extern "C" int signMessage_c(const char* pk, int pkLen, const unsigned char* msg,
|
||||
int msgLen, char* buf, int bufSize);
|
||||
|
||||
// ---- 设备信息 helper(登录时填充,仅调用一次)----
|
||||
|
||||
static int GetCpuCores() {
|
||||
@@ -109,23 +117,31 @@ static std::string JsonGetStr(const std::string& json, const char* key) {
|
||||
|
||||
// 查询 ip-api.com,一次请求同时获取公网 IP 和地理位置(与 Windows 客户端同源)
|
||||
// 在连接线程中同步调用,超时 3 秒
|
||||
static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
|
||||
pubIp.clear(); location.clear();
|
||||
struct GeoResult {
|
||||
std::string pubIp, location;
|
||||
std::atomic<bool> ready{false};
|
||||
};
|
||||
|
||||
static void FetchGeoInfoImpl(std::shared_ptr<GeoResult> out) {
|
||||
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;
|
||||
|
||||
if (getaddrinfo("ip-api.com", "80", &hints, &res) != 0 || !res) {
|
||||
out->ready.store(true, std::memory_order_release); return;
|
||||
}
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) { freeaddrinfo(res); return false; }
|
||||
if (fd < 0) { freeaddrinfo(res); out->ready.store(true, std::memory_order_release); return; }
|
||||
|
||||
struct timeval tv = {3, 0};
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||
connect(fd, res->ai_addr, res->ai_addrlen);
|
||||
fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds);
|
||||
bool connected = (select(fd + 1, nullptr, &wfds, nullptr, &tv) == 1);
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) & ~O_NONBLOCK);
|
||||
|
||||
bool ok = false;
|
||||
if (connect(fd, res->ai_addr, res->ai_addrlen) == 0) {
|
||||
if (connected) {
|
||||
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";
|
||||
@@ -140,19 +156,31 @@ static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
|
||||
if (sep != std::string::npos) {
|
||||
const std::string body = resp.substr(sep + 4);
|
||||
if (JsonGetStr(body, "status") == "success") {
|
||||
pubIp = JsonGetStr(body, "query");
|
||||
out->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;
|
||||
if (!city.empty() && !country.empty()) out->location = city + ", " + country;
|
||||
else if (!country.empty()) out->location = country;
|
||||
else if (!city.empty()) out->location = city;
|
||||
}
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
freeaddrinfo(res);
|
||||
return ok;
|
||||
out->ready.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
// getaddrinfo 在某些设备上会永久阻塞,用独立线程 + 5 秒轮询超时保护 ConnectionThread
|
||||
static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
|
||||
pubIp.clear(); location.clear();
|
||||
auto result = std::make_shared<GeoResult>();
|
||||
std::thread(FetchGeoInfoImpl, result).detach();
|
||||
for (int i = 0; i < 50 && !result->ready.load(std::memory_order_acquire); i++)
|
||||
usleep(100000); // 100ms × 50 = 5 秒
|
||||
if (!result->ready.load(std::memory_order_acquire)) return false;
|
||||
pubIp = result->pubIp;
|
||||
location = result->location;
|
||||
return !pubIp.empty();
|
||||
}
|
||||
|
||||
// ---- 全局状态 ----
|
||||
@@ -173,6 +201,7 @@ static jmethodID g_getActiveWindowMid = nullptr;
|
||||
static jclass g_captureClass = nullptr;
|
||||
static jmethodID g_requestIdrMid = nullptr;
|
||||
static jmethodID g_forceFirstFrameMid = nullptr;
|
||||
static jmethodID g_statusMid = nullptr; // CaptureService.onNativeStatus(String)
|
||||
|
||||
// 心跳日志限流:每 60 秒最多打一次
|
||||
static std::atomic<uint64_t> g_lastHbLogMs{0};
|
||||
@@ -277,6 +306,29 @@ static void RequestIdrFromJava() { CallCaptureStaticVoid(g_requestIdrMid);
|
||||
// 强制 VirtualDisplay 推一帧(静止屏幕绕过 SurfaceFlinger 空闲优化)
|
||||
static void ForceFirstFrameFromJava() { CallCaptureStaticVoid(g_forceFirstFrameMid); }
|
||||
|
||||
// 向 CaptureService.onNativeStatus() 发送状态 Toast(连接线程诊断用)
|
||||
static void PostStatus(const char* msg) {
|
||||
LOGI("STATUS: %s", msg);
|
||||
#if SCREEN_STATUS_ENABLED
|
||||
if (!g_jvm || !g_captureClass || !g_statusMid) return;
|
||||
JNIEnv* jenv = nullptr;
|
||||
bool attached = false;
|
||||
if (g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6) == JNI_EDETACHED) {
|
||||
g_jvm->AttachCurrentThread(&jenv, nullptr);
|
||||
attached = true;
|
||||
}
|
||||
if (jenv) {
|
||||
jstring js = jenv->NewStringUTF(msg);
|
||||
if (js) {
|
||||
jenv->CallStaticVoidMethod(g_captureClass, g_statusMid, js);
|
||||
jenv->DeleteLocalRef(js);
|
||||
}
|
||||
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||
}
|
||||
if (attached) g_jvm->DetachCurrentThread();
|
||||
#endif
|
||||
}
|
||||
|
||||
static void ScreenSpyThread()
|
||||
{
|
||||
// 等待 Java 侧 startCapture() 调用 nativeSetScreenSize 设置真实分辨率。
|
||||
@@ -299,32 +351,36 @@ static void ScreenSpyThread()
|
||||
std::unique_ptr<IOCPClient> client;
|
||||
std::unique_ptr<AndroidScreenHandler> handler;
|
||||
try {
|
||||
LOGI("SST[%d]: creating IOCPClient", attempt);
|
||||
client = std::make_unique<IOCPClient>(g_bExit, true);
|
||||
client->EnableSubConnAuth(true, g_myClientID);
|
||||
|
||||
LOGI("SST[%d]: connecting %s:%d", attempt, g_serverIp.c_str(), g_serverPort);
|
||||
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||
LOGI("ScreenSpyThread: connect failed (attempt %d/20), retry 2s", attempt);
|
||||
Sleep(2000);
|
||||
continue;
|
||||
}
|
||||
LOGI("SST[%d]: connected, creating handler w=%d h=%d", attempt, w, h);
|
||||
|
||||
handler = std::make_unique<AndroidScreenHandler>(client.get(), w, h);
|
||||
LOGI("SST[%d]: handler created, inserting to set", attempt);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||
g_screenHandlers.insert(handler.get());
|
||||
}
|
||||
|
||||
LOGI("SST[%d]: setManagerCallBack", attempt);
|
||||
client->setManagerCallBack(handler.get(),
|
||||
IOCPManager::DataProcess,
|
||||
IOCPManager::ReconnectProcess);
|
||||
LOGI("SST[%d]: SendBitmapInfo", attempt);
|
||||
handler->SendBitmapInfo();
|
||||
// 子连接刚建立,浏览器 initDecoder 完成后需要关键帧才能开始显示。
|
||||
// ForceFirstFrameFromJava:短暂 resize VirtualDisplay 强制 SurfaceFlinger
|
||||
// 推一帧给编码器——静止屏幕时 SurfaceFlinger 有空闲优化不主动推帧,
|
||||
// 导致 REQUEST_SYNC_FRAME 排队等待,造成 10-60 秒黑屏。
|
||||
// RequestIdrFromJava 确保此帧被编码为 IDR。
|
||||
LOGI("SST[%d]: ForceFirstFrame", attempt);
|
||||
ForceFirstFrameFromJava();
|
||||
LOGI("SST[%d]: RequestIdr", attempt);
|
||||
RequestIdrFromJava();
|
||||
LOGI("SST[%d]: entering wait loop", attempt);
|
||||
|
||||
while (client->IsRunning() && client->IsConnected() && S_CLIENT_NORMAL == g_bExit)
|
||||
Sleep(200);
|
||||
@@ -354,24 +410,28 @@ static void ScreenSpyThread()
|
||||
int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||
{
|
||||
if (!szBuffer || !ulLength) return TRUE;
|
||||
|
||||
if (!ClientAuth::IsCommandAllowed(szBuffer[0]))
|
||||
int allowed = (int)ClientAuth::IsCommandAllowed(szBuffer[0]);
|
||||
if (!allowed) {
|
||||
LOGI("DataProcess cmd=%d len=%lu allowed=%d",
|
||||
(int)(unsigned char)szBuffer[0], (unsigned long)ulLength, allowed);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
switch (szBuffer[0]) {
|
||||
case COMMAND_BYE:
|
||||
LOGI("COMMAND_BYE");
|
||||
PostStatus("BYE from server");
|
||||
g_bExit = S_CLIENT_EXIT;
|
||||
break;
|
||||
|
||||
case CMD_HEARTBEAT_ACK:
|
||||
if (ulLength >= 1 + (ULONG)sizeof(HeartbeatACK)) {
|
||||
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
|
||||
HeartbeatACK ack;
|
||||
memcpy(&ack, szBuffer + 1, sizeof(HeartbeatACK));
|
||||
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;
|
||||
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);
|
||||
@@ -386,6 +446,9 @@ int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||
if (settings.ReportInterval > 0)
|
||||
g_heartbeatInterval = std::max(settings.ReportInterval, 30);
|
||||
LOGI("MasterSettings OK interval=%ds (server=%d)", g_heartbeatInterval, settings.ReportInterval);
|
||||
PostStatus("masterSettings: OK");
|
||||
} else {
|
||||
PostStatus("masterSettings: FAIL");
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -394,7 +457,8 @@ int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||
// 每个 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);
|
||||
LOGI("cmd: COMMAND_SCREEN_SPY len=%lu active=%zu w=%d h=%d",
|
||||
(unsigned long)ulLength, active, g_screenWidth.load(), g_screenHeight.load());
|
||||
std::thread(ScreenSpyThread).detach();
|
||||
break;
|
||||
}
|
||||
@@ -421,7 +485,7 @@ int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||
break;
|
||||
|
||||
default:
|
||||
LOGI("Unhandled cmd=%d", (int)szBuffer[0]);
|
||||
LOGI("cmd: unhandled cmd=%d len=%lu", (int)szBuffer[0], (unsigned long)ulLength);
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
@@ -432,11 +496,10 @@ static void ConnectionThread()
|
||||
{
|
||||
LOGI("ConnectionThread → %s:%d", g_serverIp.c_str(), g_serverPort);
|
||||
|
||||
PostStatus("geo: fetching...");
|
||||
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());
|
||||
PostStatus(("geo: " + (g_pubIp.empty() ? "failed" : g_pubIp)).c_str());
|
||||
|
||||
LOGIN_INFOR logInfo;
|
||||
strncpy(logInfo.szPCName, g_deviceModel.c_str(), sizeof(logInfo.szPCName) - 1);
|
||||
@@ -474,14 +537,21 @@ static void ConnectionThread()
|
||||
std::unique_ptr<IOCPClient> client(new IOCPClient(g_bExit, false));
|
||||
client->setManagerCallBack(nullptr, DataProcess, nullptr);
|
||||
|
||||
int g_connAttempt = 0;
|
||||
while (S_CLIENT_NORMAL == g_bExit && g_running.load()) {
|
||||
char connMsg[64];
|
||||
snprintf(connMsg, sizeof(connMsg), "connect #%d → %s:%d",
|
||||
++g_connAttempt, g_serverIp.c_str(), g_serverPort);
|
||||
PostStatus(connMsg);
|
||||
clock_t c = clock();
|
||||
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||
LOGI("Connect failed, retry 5s");
|
||||
snprintf(connMsg, sizeof(connMsg), "connect #%d failed errno=%d", g_connAttempt, errno);
|
||||
PostStatus(connMsg);
|
||||
Sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
PostStatus("connected! sending login...");
|
||||
ClientAuth::OnNewConnection();
|
||||
client->SendLoginInfo(logInfo.Speed(clock() - c));
|
||||
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||
@@ -493,16 +563,22 @@ static void ConnectionThread()
|
||||
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;
|
||||
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) {
|
||||
char dbg[96];
|
||||
snprintf(dbg, sizeof(dbg), "drop@%ds run=%d conn=%d exit=%d run2=%d",
|
||||
i, (int)client->IsRunning(), (int)client->IsConnected(),
|
||||
(int)(g_bExit == S_CLIENT_NORMAL), (int)g_running.load());
|
||||
PostStatus(dbg);
|
||||
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;
|
||||
PostStatus("timeout: masterSettings");
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
@@ -510,9 +586,8 @@ static void ConnectionThread()
|
||||
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;
|
||||
PostStatus("timeout: ACK");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +609,10 @@ static void ConnectionThread()
|
||||
}
|
||||
}
|
||||
}
|
||||
LOGI("Disconnected");
|
||||
PostStatus("disconnected, retry...");
|
||||
// Give server 2 s to remove old context from HostList; without this
|
||||
// the immediate reconnect hits "already exists" and server skips SendMasterSettings.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
}
|
||||
|
||||
g_running.store(false);
|
||||
@@ -603,6 +681,10 @@ Java_com_yama_client_YamaBridge_nativeInit(
|
||||
g_forceFirstFrameMid = env->GetStaticMethodID(g_captureClass, "forceFirstFrame", "()V");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); g_forceFirstFrameMid = nullptr; }
|
||||
if (!g_forceFirstFrameMid) LOGE("CaptureService.forceFirstFrame not found");
|
||||
g_statusMid = env->GetStaticMethodID(g_captureClass, "onNativeStatus", "(Ljava/lang/String;)V");
|
||||
if (env->ExceptionCheck()) { env->ExceptionClear(); g_statusMid = nullptr; }
|
||||
if (!g_statusMid) LOGE("CaptureService.onNativeStatus not found");
|
||||
UseAndroidLog(PostStatus);
|
||||
} else {
|
||||
LOGE("CaptureService class not found");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
|
||||
class CaptureService : Service() {
|
||||
|
||||
@@ -39,6 +40,14 @@ class CaptureService : Service() {
|
||||
// CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争
|
||||
@Volatile var instance: CaptureService? = null
|
||||
|
||||
@JvmStatic
|
||||
fun onNativeStatus(msg: String) {
|
||||
val svc = instance ?: return
|
||||
svc.idrHandler.post {
|
||||
Toast.makeText(svc, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun requestIdr() {
|
||||
val svc = instance ?: return
|
||||
@@ -100,9 +109,16 @@ class CaptureService : Service() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
createNotificationChannel()
|
||||
// CS-06: start with DATA_SYNC type only; upgrade to MEDIA_PROJECTION in
|
||||
// onStartCommand when the token is actually available. On Android 14+
|
||||
// (targetSdk 34+) calling startForeground(MEDIA_PROJECTION) without
|
||||
// immediately pairing it with getMediaProjection() causes the system to
|
||||
// kill the service after a ~5-second grace period — the root cause of the
|
||||
// 3-9 s disconnect seen on Google TV when no screen-capture permission
|
||||
// was granted.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, buildNotification(),
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
}
|
||||
@@ -124,12 +140,23 @@ class CaptureService : Service() {
|
||||
Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res")
|
||||
|
||||
// YB-01 fix: so 库加载失败是 Error,不被 catch(Exception) 捕获,需单独处理
|
||||
// ExceptionInInitializerError 包裹 UnsatisfiedLinkError(首次访问 object 时触发)
|
||||
val initRet: Int
|
||||
try {
|
||||
YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "")
|
||||
initRet = 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
|
||||
} catch (e: ExceptionInInitializerError) {
|
||||
Log.e(TAG, "Native library init failed: ${e.cause}")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (initRet != 0) {
|
||||
Log.e(TAG, "nativeInit failed: ret=$initRet")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1)
|
||||
@@ -139,12 +166,22 @@ class CaptureService : Service() {
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA)
|
||||
|
||||
if (projResult == android.app.Activity.RESULT_OK && projData != null) {
|
||||
// Android 14+: upgrade foreground type to MEDIA_PROJECTION in the same
|
||||
// start command that calls getMediaProjection(), as required by API 34+.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, buildNotification(),
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
}
|
||||
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)
|
||||
try {
|
||||
startCapture(mp, sw, sh)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Screen capture failed: $e — running without capture")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture")
|
||||
}
|
||||
@@ -223,9 +260,14 @@ class CaptureService : Service() {
|
||||
// Android 14+ requires callback registered before createVirtualDisplay()
|
||||
mp.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped")
|
||||
Log.i(TAG, "MediaProjection stopped — switching to dataSync foreground type")
|
||||
stopCapture()
|
||||
stopSelf()
|
||||
// Android 14+: system auto-kills a foreground service whose mediaProjection
|
||||
// is revoked. Re-declare as dataSync-only to survive without screen capture.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIF_ID, buildNotification(),
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
}
|
||||
}
|
||||
}, null)
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
/**
|
||||
* AccessibilityService that injects touch gestures and global key actions
|
||||
@@ -88,6 +90,22 @@ class ControlService : AccessibilityService() {
|
||||
// ── Main-thread handler ───────────────────────────────────────────────
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ── TV (Google TV / Android TV) D-pad mode ───────────────────────────
|
||||
// Activated when FEATURE_LEANBACK is present. Mouse events are translated
|
||||
// to accessibility focus navigation — no touchscreen or root required.
|
||||
private var isTV = false
|
||||
private var tvLastEncX = -1f
|
||||
private var tvLastEncY = -1f
|
||||
private var tvAccumDX = 0f
|
||||
private var tvAccumDY = 0f
|
||||
private val TV_STEP = 40f // encoding-space pixels per D-pad step
|
||||
// Touch-drag disambiguation: fire click on LBUTTONUP only if finger barely moved
|
||||
private var tvTouchDown = false
|
||||
private var tvTouchDownX = 0f
|
||||
private var tvTouchDownY = 0f
|
||||
private var tvTouchMoved = false
|
||||
private val TV_TAP_THRESHOLD = 15f // encoding pixels — below this = tap, above = drag
|
||||
|
||||
// ── Left-button drag accumulator ─────────────────────────────────────
|
||||
private var lbuttonDown = false
|
||||
private var gestureStart = 0L
|
||||
@@ -100,6 +118,8 @@ class ControlService : AccessibilityService() {
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
isTV = packageManager.hasSystemFeature("android.software.leanback")
|
||||
if (isTV) Log.i(TAG, "TV mode: D-pad navigation enabled")
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
@@ -127,6 +147,7 @@ class ControlService : AccessibilityService() {
|
||||
// Event routing (runs on main thread)
|
||||
|
||||
private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||
if (isTV) { handleEventTV(message, wParam, ptX, ptY); return }
|
||||
// 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()
|
||||
@@ -265,6 +286,119 @@ class ControlService : AccessibilityService() {
|
||||
if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// TV D-pad mode — translates mouse events to accessibility focus actions
|
||||
|
||||
private fun handleEventTV(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||
when (message) {
|
||||
WM_MOUSEMOVE -> {
|
||||
if (tvLastEncX >= 0) {
|
||||
tvAccumDX += ptX - tvLastEncX
|
||||
tvAccumDY += ptY - tvLastEncY
|
||||
while (tvAccumDX > TV_STEP) { tvMoveFocus(View.FOCUS_RIGHT); tvAccumDX -= TV_STEP }
|
||||
while (tvAccumDX < -TV_STEP) { tvMoveFocus(View.FOCUS_LEFT); tvAccumDX += TV_STEP }
|
||||
while (tvAccumDY > TV_STEP) { tvMoveFocus(View.FOCUS_DOWN); tvAccumDY -= TV_STEP }
|
||||
while (tvAccumDY < -TV_STEP) { tvMoveFocus(View.FOCUS_UP); tvAccumDY += TV_STEP }
|
||||
}
|
||||
if (tvTouchDown) {
|
||||
val dx = ptX - tvTouchDownX
|
||||
val dy = ptY - tvTouchDownY
|
||||
if (dx * dx + dy * dy > TV_TAP_THRESHOLD * TV_TAP_THRESHOLD) tvTouchMoved = true
|
||||
}
|
||||
tvLastEncX = ptX.toFloat()
|
||||
tvLastEncY = ptY.toFloat()
|
||||
}
|
||||
WM_LBUTTONDOWN -> {
|
||||
tvTouchDown = true
|
||||
tvTouchDownX = ptX.toFloat()
|
||||
tvTouchDownY = ptY.toFloat()
|
||||
tvTouchMoved = false
|
||||
tvAccumDX = 0f
|
||||
tvAccumDY = 0f
|
||||
tvLastEncX = ptX.toFloat()
|
||||
tvLastEncY = ptY.toFloat()
|
||||
}
|
||||
WM_LBUTTONUP -> {
|
||||
if (tvTouchDown && !tvTouchMoved) tvClick()
|
||||
tvTouchDown = false
|
||||
}
|
||||
WM_LBUTTONDBLCLK -> tvClick()
|
||||
WM_RBUTTONDOWN -> performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
WM_MOUSEWHEEL -> {
|
||||
val delta = (wParam.toInt() ushr 16).toShort().toInt()
|
||||
tvScroll(delta < 0)
|
||||
}
|
||||
WM_KEYDOWN, WM_SYSKEYDOWN -> {
|
||||
val vk = wParam.toInt() and 0xFFFF
|
||||
when (vk) {
|
||||
0x25 -> tvMoveFocus(View.FOCUS_LEFT) // VK_LEFT
|
||||
0x26 -> tvMoveFocus(View.FOCUS_UP) // VK_UP
|
||||
0x27 -> tvMoveFocus(View.FOCUS_RIGHT) // VK_RIGHT
|
||||
0x28 -> tvMoveFocus(View.FOCUS_DOWN) // VK_DOWN
|
||||
0x0D -> tvClick() // VK_RETURN
|
||||
else -> handleKeyDown(vk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun tvMoveFocus(direction: Int) {
|
||||
val root = rootInActiveWindow ?: return
|
||||
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||
if (focused == null) {
|
||||
val first = findFirstFocusable(root)
|
||||
first?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
first?.recycle()
|
||||
} else {
|
||||
val next = focused.focusSearch(direction)
|
||||
next?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||
focused.recycle()
|
||||
next?.recycle()
|
||||
}
|
||||
root.recycle()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun tvClick() {
|
||||
val root = rootInActiveWindow ?: return
|
||||
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||
if (focused != null) {
|
||||
focused.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
focused.recycle()
|
||||
} else {
|
||||
Log.w(TAG, "tvClick: no focused node")
|
||||
}
|
||||
root.recycle()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun tvScroll(forward: Boolean) {
|
||||
val root = rootInActiveWindow ?: return
|
||||
val action = if (forward) AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
|
||||
else AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
|
||||
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||
if (focused == null || !focused.performAction(action)) root.performAction(action)
|
||||
focused?.recycle()
|
||||
root.recycle()
|
||||
}
|
||||
|
||||
// Walk accessibility tree depth-first, return first visible focusable node (caller must recycle)
|
||||
@Suppress("DEPRECATION")
|
||||
private fun findFirstFocusable(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
|
||||
if (node.isFocusable && node.isVisibleToUser) return AccessibilityNodeInfo.obtain(node)
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
val hit = findFirstFocusable(child)
|
||||
child.recycle()
|
||||
if (hit != null) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun handleKeyDown(vk: Int) {
|
||||
when (vk) {
|
||||
0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canPerformGestures="true"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:notificationTimeout="0"
|
||||
android:settingsActivity="com.yama.client.MainActivity" />
|
||||
|
||||
Reference in New Issue
Block a user