Изменил работу со скриншотами
This commit is contained in:
parent
7257a5207e
commit
da72c4eda6
@ -175,6 +175,7 @@ if (WIN32)
|
|||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
|
# adb копируется в post-build чтобы не падать при configure если adb.exe занят
|
||||||
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
|
file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*")
|
||||||
foreach(_adb_file ${_adb_files})
|
foreach(_adb_file ${_adb_files})
|
||||||
@ -230,6 +231,10 @@ if (WIN32)
|
|||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
|
"${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk"
|
||||||
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
|
"$<TARGET_FILE_DIR:ARC>/assets/ADBKeyboard.apk"
|
||||||
|
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk"
|
||||||
|
"$<TARGET_FILE_DIR:ARC>/assets/arc-shot.apk"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
|
# Copy OpenSSL DLLs required for HTTPS (Qt SSL backend)
|
||||||
@ -296,6 +301,7 @@ elseif (APPLE)
|
|||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/no_devices.png" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
|
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets")
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
target_include_directories(ARC PRIVATE
|
target_include_directories(ARC PRIVATE
|
||||||
|
|||||||
@ -6,9 +6,62 @@
|
|||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QTcpSocket>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHostAddress>
|
||||||
|
|
||||||
#include "dump/TaskDumpRecorder.h"
|
#include "dump/TaskDumpRecorder.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Конфиг скриншот-стратегии (config.ini [screenshot]). Кэшируется при первом чтении.
|
||||||
|
struct ScreenshotCfg {
|
||||||
|
QString transport; // "http" | "execout"
|
||||||
|
int httpPort = 8099;
|
||||||
|
int httpTimeoutMs = 4000;
|
||||||
|
QString remotePath = "/sdcard/arc_shot.png";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScreenshotCfg &screenshotCfg() {
|
||||||
|
static ScreenshotCfg cfg = [] {
|
||||||
|
const QSettings s("config.ini", QSettings::IniFormat);
|
||||||
|
ScreenshotCfg c;
|
||||||
|
c.transport = s.value("screenshot/transport", "http").toString().trimmed().toLower();
|
||||||
|
c.httpPort = s.value("screenshot/httpPort", 8099).toInt();
|
||||||
|
c.httpTimeoutMs = s.value("screenshot/httpTimeoutMs", 4000).toInt();
|
||||||
|
c.remotePath = s.value("screenshot/remotePath", "/sdcard/arc_shot.png").toString();
|
||||||
|
return c;
|
||||||
|
}();
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэш IP телефона по serial (резолв дорогой — adb shell). Потокобезопасно.
|
||||||
|
// С TTL: IP редко меняется, перечитываем не чаще kIpTtlSecs (на случай смены DHCP).
|
||||||
|
constexpr qint64 kIpTtlSecs = 300;
|
||||||
|
QMutex g_ipCacheMutex;
|
||||||
|
QHash<QString, QString> g_ipCache;
|
||||||
|
QHash<QString, qint64> g_ipCacheTs;
|
||||||
|
|
||||||
|
// Негативный кэш: serial → unix ts последней неудачи HTTP. Пока не истёк бэкофф,
|
||||||
|
// идём сразу в exec-out fallback, не дёргая screencap+IP+таймаут каждый цикл.
|
||||||
|
constexpr qint64 kHttpFailBackoffSecs = 60;
|
||||||
|
QMutex g_httpFailMutex;
|
||||||
|
QHash<QString, qint64> g_httpFailAt;
|
||||||
|
|
||||||
|
// Лог смены пути скриншота (http ↔ execout) для наблюдаемости — без спама каждые 4с.
|
||||||
|
QMutex g_modeMutex;
|
||||||
|
QHash<QString, QString> g_lastMode;
|
||||||
|
|
||||||
|
void logScreenshotMode(const QString &deviceId, const QString &mode) {
|
||||||
|
QMutexLocker lock(&g_modeMutex);
|
||||||
|
if (g_lastMode.value(deviceId) != mode) {
|
||||||
|
g_lastMode.insert(deviceId, mode);
|
||||||
|
qInfo().noquote() << "[AdbUtils] screenshot path for" << deviceId << "→" << mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,6 +502,36 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
||||||
|
// http-режим: пробуем разгрузку через APK, при неудаче — fallback на exec-out.
|
||||||
|
if (screenshotCfg().transport == "http") {
|
||||||
|
// Негативный бэкофф: если HTTP недавно не сработал — не дёргаем его каждый цикл.
|
||||||
|
bool skipHttp = false;
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
const qint64 failAt = g_httpFailAt.value(deviceId, 0);
|
||||||
|
skipHttp = failAt != 0
|
||||||
|
&& QDateTime::currentSecsSinceEpoch() - failAt < kHttpFailBackoffSecs;
|
||||||
|
}
|
||||||
|
if (!skipHttp) {
|
||||||
|
if (const QByteArray png = captureScreenshotHttp(deviceId); !png.isEmpty()) {
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt.remove(deviceId);
|
||||||
|
logScreenshotMode(deviceId, "http");
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt[deviceId] = QDateTime::currentSecsSinceEpoch();
|
||||||
|
}
|
||||||
|
// arc-shot мог умереть — пробуем поднять (раз в бэкофф-окно, не спамит).
|
||||||
|
kickArcShot(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logScreenshotMode(deviceId, "exec-out (fallback)");
|
||||||
|
return captureScreenshotExecOut(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.setWorkingDirectory(adbDir());
|
process.setWorkingDirectory(adbDir());
|
||||||
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"});
|
||||||
@ -464,6 +547,172 @@ QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) {
|
|||||||
return process.readAllStandardOutput();
|
return process.readAllStandardOutput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::captureScreenshotHttp(const QString &deviceId) {
|
||||||
|
const ScreenshotCfg &cfg = screenshotCfg();
|
||||||
|
|
||||||
|
// 1. Снимаем В ФАЙЛ на устройстве — байты НЕ идут через adb, команда быстрая.
|
||||||
|
QString stdErr;
|
||||||
|
if (!startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "screencap", "-p", cfg.remotePath},
|
||||||
|
nullptr, &stdErr)) {
|
||||||
|
qWarning() << "[AdbUtils] http screencap-to-file failed for" << deviceId << stdErr;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. IP телефона в Wi-Fi.
|
||||||
|
const QString ip = resolvePhoneIp(deviceId);
|
||||||
|
if (ip.isEmpty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Качаем файл по HTTP напрямую с телефона (мимо adb).
|
||||||
|
const QString fileName = cfg.remotePath.section('/', -1);
|
||||||
|
const QByteArray png = httpGet(ip, cfg.httpPort, "/" + fileName, cfg.httpTimeoutMs);
|
||||||
|
if (png.isEmpty()) {
|
||||||
|
// Не сбрасываем кэш IP — он переживёт неудачу http (TTL сам перечитает при
|
||||||
|
// реальной смене IP). Иначе IP не доезжал бы до БД на fallback-устройствах.
|
||||||
|
qWarning() << "[AdbUtils] http screenshot GET failed for" << deviceId << "ip:" << ip;
|
||||||
|
}
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::screenshotMode(const QString &deviceId) {
|
||||||
|
QMutexLocker lock(&g_modeMutex);
|
||||||
|
return g_lastMode.value(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString AdbUtils::resolvePhoneIp(const QString &deviceId) {
|
||||||
|
const qint64 now = QDateTime::currentSecsSinceEpoch();
|
||||||
|
{
|
||||||
|
QMutexLocker lock(&g_ipCacheMutex);
|
||||||
|
if (const QString cached = g_ipCache.value(deviceId);
|
||||||
|
!cached.isEmpty() && now - g_ipCacheTs.value(deviceId, 0) < kIpTtlSecs) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ip;
|
||||||
|
// Для TCP-серийника IP:port — IP прямо из серийника.
|
||||||
|
static const QRegularExpression reIpPort(R"(^(\d+\.\d+\.\d+\.\d+):\d+$)");
|
||||||
|
if (const auto m = reIpPort.match(deviceId); m.hasMatch()) {
|
||||||
|
ip = m.captured(1);
|
||||||
|
} else {
|
||||||
|
// Иначе спрашиваем устройство: ip route get 1.1.1.1 → "... src <ipv4> ..."
|
||||||
|
QString out;
|
||||||
|
if (startProcess(adbPath(),
|
||||||
|
{"-s", deviceId, "shell", "ip", "route", "get", "1.1.1.1"},
|
||||||
|
&out)) {
|
||||||
|
static const QRegularExpression reSrc(R"(src\s+(\d+\.\d+\.\d+\.\d+))");
|
||||||
|
if (const auto m = reSrc.match(out); m.hasMatch()) {
|
||||||
|
ip = m.captured(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ip.isEmpty()) {
|
||||||
|
QMutexLocker lock(&g_ipCacheMutex);
|
||||||
|
g_ipCache.insert(deviceId, ip);
|
||||||
|
g_ipCacheTs.insert(deviceId, now);
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray AdbUtils::httpGet(const QString &ip, int port, const QString &path, int timeoutMs) {
|
||||||
|
QTcpSocket sock;
|
||||||
|
sock.connectToHost(ip, static_cast<quint16>(port));
|
||||||
|
if (!sock.waitForConnected(timeoutMs)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray req = "GET " + path.toUtf8() + " HTTP/1.0\r\n"
|
||||||
|
"Host: " + ip.toUtf8() + "\r\n"
|
||||||
|
"Connection: close\r\n\r\n";
|
||||||
|
sock.write(req);
|
||||||
|
if (!sock.waitForBytesWritten(timeoutMs)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем до закрытия соединения (HTTP/1.0 + Connection: close).
|
||||||
|
QByteArray resp;
|
||||||
|
while (sock.state() == QAbstractSocket::ConnectedState || sock.bytesAvailable() > 0) {
|
||||||
|
if (!sock.waitForReadyRead(timeoutMs)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
resp += sock.readAll();
|
||||||
|
}
|
||||||
|
resp += sock.readAll();
|
||||||
|
|
||||||
|
// Разбор: статус-строка + тело после "\r\n\r\n".
|
||||||
|
const int headerEnd = resp.indexOf("\r\n\r\n");
|
||||||
|
if (headerEnd < 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QByteArray header = resp.left(headerEnd);
|
||||||
|
if (!header.startsWith("HTTP/1.") || !header.contains(" 200")) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return resp.mid(headerEnd + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AdbUtils::kickArcShot(const QString &deviceId) {
|
||||||
|
const QString adb = adbPath();
|
||||||
|
|
||||||
|
// 1. Установлен ли arc-shot? (pm path вернёт "package:..." если да)
|
||||||
|
QString pmOut;
|
||||||
|
const bool installed = startProcess(adb,
|
||||||
|
{"-s", deviceId, "shell", "pm", "path", "com.arc.shot"}, &pmOut)
|
||||||
|
&& pmOut.contains("package:");
|
||||||
|
|
||||||
|
// 2. Не установлен (удалили / новое устройство) — ставим bundled APK рядом с десктопом.
|
||||||
|
if (!installed) {
|
||||||
|
const QString apk = QCoreApplication::applicationDirPath() + "/assets/arc-shot.apk";
|
||||||
|
if (!QFileInfo::exists(apk)) {
|
||||||
|
qWarning() << "[AdbUtils] arc-shot apk not found at" << apk << "— skip auto-install";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QString err;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "install", "-r", apk}, nullptr, &err)) {
|
||||||
|
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Доступ к /sdcard без диалога на устройстве.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
|
||||||
|
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
|
||||||
|
qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
|
||||||
|
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
|
||||||
|
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
|
||||||
|
"+com.arc.shot"});
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
|
||||||
|
"RUN_ANY_IN_BACKGROUND", "allow"});
|
||||||
|
|
||||||
|
// 4. Поднять сервис. Свежая установка — через Activity (am start -n .../MainActivity):
|
||||||
|
// надёжно снимает stopped-state, в котором у части вендоров FGS-старт молча
|
||||||
|
// игнорируется. Activity без UI и сразу finish(), сервис стартует из onCreate.
|
||||||
|
// Уже установленный (реанимация) — start-foreground-service, без Activity поверх
|
||||||
|
// работающего банк-скрипта.
|
||||||
|
QString err;
|
||||||
|
const bool ok = !installed
|
||||||
|
? startProcess(adb, {"-s", deviceId, "shell", "am", "start",
|
||||||
|
"-n", "com.arc.shot/.MainActivity"}, nullptr, &err)
|
||||||
|
: startProcess(adb, {"-s", deviceId, "shell", "am", "start-foreground-service",
|
||||||
|
"-n", "com.arc.shot/.ArcShotService"}, nullptr, &err);
|
||||||
|
if (ok) {
|
||||||
|
qInfo().noquote() << "[AdbUtils] arc-shot" << (installed ? "revive attempt" : "started")
|
||||||
|
<< "for" << deviceId;
|
||||||
|
if (!installed) {
|
||||||
|
// Свежая установка: сбрасываем http-бэкофф, чтобы http включился на следующем
|
||||||
|
// кадре (а не ждал 60с) — устройство быстрее уходит с exec-out.
|
||||||
|
QMutexLocker lock(&g_httpFailMutex);
|
||||||
|
g_httpFailAt.remove(deviceId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qDebug().noquote() << "[AdbUtils] arc-shot kick failed for" << deviceId << ":" << err.trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
|
bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) {
|
||||||
const QString adb = adbPath();
|
const QString adb = adbPath();
|
||||||
struct Step { QStringList args; };
|
struct Step { QStringList args; };
|
||||||
|
|||||||
@ -27,9 +27,26 @@ public:
|
|||||||
|
|
||||||
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
static bool takeScreenshot(const QString &deviceId, const QString &name);
|
||||||
|
|
||||||
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла
|
// IP телефона в Wi-Fi (кэш по serial с TTL). Для IP:port-серийника берётся из
|
||||||
|
// серийника, иначе `adb shell ip route get 1.1.1.1` → src. Пусто, если не в Wi-Fi.
|
||||||
|
// Используется и для http-скриншота, и для сохранения IP в БД (wifi_connections).
|
||||||
|
static QString resolvePhoneIp(const QString &deviceId);
|
||||||
|
|
||||||
|
// Текущий режим скриншотов устройства: "http" | "exec-out (fallback)" | "" (ещё не
|
||||||
|
// определялся). Для индикации в UI. Ключ — adb-серийник.
|
||||||
|
static QString screenshotMode(const QString &deviceId);
|
||||||
|
|
||||||
|
// Захватывает скриншот напрямую в память (PNG-байты), без временного файла.
|
||||||
|
// Выбирает стратегию по config.ini [screenshot] transport: http (через APK
|
||||||
|
// arc-shot, мимо adb) с fallback на exec-out, либо сразу exec-out.
|
||||||
static QByteArray captureScreenshotBytes(const QString &deviceId);
|
static QByteArray captureScreenshotBytes(const QString &deviceId);
|
||||||
|
|
||||||
|
// Скриншот через APK arc-shot: adb screencap в файл на /sdcard (дёшево, без
|
||||||
|
// передачи байтов через adb) + HTTP-GET файла напрямую с телефона по его
|
||||||
|
// Wi-Fi IP. Разгружает adb. Пусто при недоступности (нет APK/сети) — вызывающий
|
||||||
|
// откатывается на exec-out. Возвращает PNG-байты.
|
||||||
|
static QByteArray captureScreenshotHttp(const QString &deviceId);
|
||||||
|
|
||||||
static bool takeXmlDump(const QString &deviceId, const QString &name);
|
static bool takeXmlDump(const QString &deviceId, const QString &name);
|
||||||
|
|
||||||
static bool makeTap(const QString &deviceId, int x, int y);
|
static bool makeTap(const QString &deviceId, int x, int y);
|
||||||
@ -73,4 +90,15 @@ private:
|
|||||||
static QString adbDir();
|
static QString adbDir();
|
||||||
static bool startProcess(const QString &program, const QStringList &args,
|
static bool startProcess(const QString &program, const QStringList &args,
|
||||||
QString *stdOut = nullptr, QString *stdErr = nullptr);
|
QString *stdOut = nullptr, QString *stdErr = nullptr);
|
||||||
|
|
||||||
|
// Старый способ: exec-out screencap -p (стримит PNG через adb). Fallback для http.
|
||||||
|
static QByteArray captureScreenshotExecOut(const QString &deviceId);
|
||||||
|
|
||||||
|
// Синхронный HTTP/1.0 GET по QTcpSocket (без event-loop, безопасно из worker-потоков).
|
||||||
|
// Возвращает тело ответа при 200, иначе пусто.
|
||||||
|
static QByteArray httpGet(const QString &ip, int port, const QString &path, int timeoutMs);
|
||||||
|
|
||||||
|
// Поднимает foreground-сервис arc-shot (ставит APK, если удалён). Дёргается из
|
||||||
|
// http-fail ветки, т.е. не чаще раза в бэкофф-окно.
|
||||||
|
static void kickArcShot(const QString &deviceId);
|
||||||
};
|
};
|
||||||
|
|||||||
4
apk/arc-shot/.gitignore
vendored
Normal file
4
apk/arc-shot/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
*.keystore
|
||||||
|
local.properties
|
||||||
54
apk/arc-shot/README.md
Normal file
54
apk/arc-shot/README.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# arc-shot
|
||||||
|
|
||||||
|
Крошечное Android-приложение для фермы: foreground-сервис + HTTP-файл-сервер на
|
||||||
|
порту **8099**, раздающий скриншоты с `/sdcard`. Нужно, чтобы десктоп качал кадры
|
||||||
|
по HTTP **напрямую с телефона, мимо adb** — иначе `adb exec-out screencap` по Wi-Fi
|
||||||
|
виснет и душит adb-сервер (каскад `adb devices timeout` → mass-offline).
|
||||||
|
|
||||||
|
Экран приложение **не снимает** (это требовало бы MediaProjection с диалогом
|
||||||
|
согласия) — кадр делает десктоп через `adb shell screencap -p /sdcard/arc_shot.png`,
|
||||||
|
а arc-shot только отдаёт готовый файл по HTTP. Без внешних зависимостей (raw
|
||||||
|
`ServerSocket`).
|
||||||
|
|
||||||
|
## Эндпоинты
|
||||||
|
|
||||||
|
- `GET /health` → `200 ok`
|
||||||
|
- `GET /<file>.png` → файл из `/sdcard` (например `/arc_shot.png`)
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
**Офлайн, без Gradle/AGP** (только Android SDK build-tools):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build_manual.sh
|
||||||
|
# -> build/arc-shot.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Переменные: `BT_VER` (build-tools, дефолт 35.0.1), `PLATFORM` (дефолт android-34),
|
||||||
|
`MIN_SDK`/`TARGET_SDK`, `ANDROID_SDK_ROOT`.
|
||||||
|
|
||||||
|
**Через Android Studio / Gradle** — см. `build.gradle`. Важно: для AGP-сборки убрать
|
||||||
|
атрибут `package="com.arc.shot"` из `AndroidManifest.xml` (AGP берёт пакет из
|
||||||
|
`namespace`). Ручной `build_manual.sh`, наоборот, требует `package=` в манифесте.
|
||||||
|
|
||||||
|
## Установка и выдача прав (с десктопа, через adb)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <serial> install -r build/arc-shot.apk
|
||||||
|
# доступ к /sdcard без диалога на устройстве:
|
||||||
|
adb -s <serial> shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
|
||||||
|
# запустить сервис:
|
||||||
|
adb -s <serial> shell am start -n com.arc.shot/.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <serial> shell screencap -p /sdcard/arc_shot.png
|
||||||
|
IP=$(adb -s <serial> shell ip route get 1.1.1.1 | sed -n 's/.*src \([0-9.]*\).*/\1/p')
|
||||||
|
curl -s "http://$IP:8099/arc_shot.png" -o shot.png && file shot.png
|
||||||
|
curl -s "http://$IP:8099/health" # -> ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Десктоп использует этот путь автоматически при `config.ini [screenshot] transport=http`
|
||||||
|
(с fallback на `exec-out`, если arc-shot недоступен).
|
||||||
33
apk/arc-shot/build.gradle
Normal file
33
apk/arc-shot/build.gradle
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Стандартная сборка через Android Studio / Gradle (онлайн, тянет AGP с Google Maven).
|
||||||
|
// Офлайн-сборка без AGP — см. build_manual.sh.
|
||||||
|
//
|
||||||
|
// ВНИМАНИЕ: AGP 8 берёт package из namespace и НЕ допускает атрибут package= в
|
||||||
|
// AndroidManifest.xml. Для gradle-сборки убери `package="com.arc.shot"` из манифеста
|
||||||
|
// (он нужен только ручному build_manual.sh).
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.5.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.arc.shot'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.arc.shot'
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apk/arc-shot/build_manual.sh
Executable file
61
apk/arc-shot/build_manual.sh
Executable file
@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Офлайн-сборка APK arc-shot напрямую через Android build-tools (без Gradle/AGP).
|
||||||
|
# Использует только локальный SDK + android.jar. Требует: javac (JDK 17+), Android SDK.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}"
|
||||||
|
BT_VER="${BT_VER:-35.0.1}"
|
||||||
|
PLATFORM="${PLATFORM:-android-34}"
|
||||||
|
MIN_SDK="${MIN_SDK:-24}"
|
||||||
|
TARGET_SDK="${TARGET_SDK:-34}"
|
||||||
|
|
||||||
|
BT="$SDK/build-tools/$BT_VER"
|
||||||
|
ANDROID_JAR="$SDK/platforms/$PLATFORM/android.jar"
|
||||||
|
|
||||||
|
[ -d "$BT" ] || { echo "build-tools $BT_VER not found at $BT"; exit 1; }
|
||||||
|
[ -f "$ANDROID_JAR" ] || { echo "android.jar not found at $ANDROID_JAR"; exit 1; }
|
||||||
|
|
||||||
|
OUT="build"
|
||||||
|
rm -rf "$OUT"
|
||||||
|
mkdir -p "$OUT/classes" "$OUT/dex"
|
||||||
|
|
||||||
|
echo "[1/6] javac"
|
||||||
|
find src/main/java -name '*.java' > "$OUT/sources.txt"
|
||||||
|
javac --release 17 -classpath "$ANDROID_JAR" -d "$OUT/classes" @"$OUT/sources.txt"
|
||||||
|
|
||||||
|
echo "[2/6] d8 -> classes.dex"
|
||||||
|
CLASSES=$(find "$OUT/classes" -name '*.class')
|
||||||
|
"$BT/d8" --min-api "$MIN_SDK" --lib "$ANDROID_JAR" --output "$OUT/dex" $CLASSES
|
||||||
|
|
||||||
|
echo "[3/6] aapt2 link"
|
||||||
|
"$BT/aapt2" link \
|
||||||
|
-o "$OUT/app-unsigned.apk" \
|
||||||
|
-I "$ANDROID_JAR" \
|
||||||
|
--manifest src/main/AndroidManifest.xml \
|
||||||
|
--min-sdk-version "$MIN_SDK" \
|
||||||
|
--target-sdk-version "$TARGET_SDK"
|
||||||
|
|
||||||
|
echo "[4/6] add classes.dex"
|
||||||
|
cp "$OUT/dex/classes.dex" "$OUT/classes.dex"
|
||||||
|
( cd "$OUT" && zip -j -q app-unsigned.apk classes.dex )
|
||||||
|
|
||||||
|
echo "[5/6] zipalign"
|
||||||
|
"$BT/zipalign" -f -p 4 "$OUT/app-unsigned.apk" "$OUT/app-aligned.apk"
|
||||||
|
|
||||||
|
echo "[6/6] sign"
|
||||||
|
# Keystore держим ВНЕ build/ — иначе rm -rf build стирает ключ и подпись меняется
|
||||||
|
# каждую пересборку (INSTALL_FAILED_UPDATE_INCOMPATIBLE при обновлении на устройстве).
|
||||||
|
KS="debug.keystore"
|
||||||
|
if [ ! -f "$KS" ]; then
|
||||||
|
keytool -genkeypair -keystore "$KS" -storepass android -keypass android \
|
||||||
|
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
"$BT/apksigner" sign \
|
||||||
|
--ks "$KS" --ks-pass pass:android --ks-key-alias androiddebugkey --key-pass pass:android \
|
||||||
|
--out "$OUT/arc-shot.apk" "$OUT/app-aligned.apk"
|
||||||
|
|
||||||
|
echo "OK -> $OUT/arc-shot.apk"
|
||||||
|
"$BT/aapt2" dump badging "$OUT/arc-shot.apk" 2>/dev/null | grep -E "package:|launchable-activity:|uses-permission: name='android.permission.(MANAGE_EXTERNAL_STORAGE|FOREGROUND_SERVICE)'" || true
|
||||||
14
apk/arc-shot/settings.gradle
Normal file
14
apk/arc-shot/settings.gradle
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = 'arc-shot'
|
||||||
39
apk/arc-shot/src/main/AndroidManifest.xml
Normal file
39
apk/arc-shot/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.arc.shot">
|
||||||
|
|
||||||
|
<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.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="arc-shot"
|
||||||
|
android:allowBackup="false">
|
||||||
|
|
||||||
|
<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=".ArcShotService"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="dataSync"/>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".BootReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
207
apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java
Normal file
207
apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
package com.arc.shot;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground-сервис arc-shot: крошечный HTTP-файл-сервер, раздающий скриншоты
|
||||||
|
* с /sdcard. Десктоп снимает кадр через `adb shell screencap -p /sdcard/arc_shot.png`
|
||||||
|
* (дёшево, без передачи байтов через adb) и качает файл по HTTP напрямую с телефона —
|
||||||
|
* так мегабайты PNG идут по Wi-Fi мимо adb и не душат adb-сервер.
|
||||||
|
*
|
||||||
|
* Экран сам НЕ снимает (это требовало бы MediaProjection с диалогом согласия) —
|
||||||
|
* только отдаёт уже снятый adb'ом файл. Доступ к /sdcard выдаётся через adb:
|
||||||
|
* adb shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
|
||||||
|
*/
|
||||||
|
public class ArcShotService extends Service {
|
||||||
|
private static final String TAG = "ArcShot";
|
||||||
|
static final int PORT = 8099;
|
||||||
|
private static final String BASE_DIR = "/sdcard";
|
||||||
|
private static final String CHANNEL_ID = "arc_shot";
|
||||||
|
|
||||||
|
private volatile boolean running = false;
|
||||||
|
private ServerSocket serverSocket;
|
||||||
|
private Thread serverThread;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
startForegroundCompat();
|
||||||
|
startServer();
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
running = false;
|
||||||
|
closeQuietly(serverSocket);
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startForegroundCompat() {
|
||||||
|
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
|
||||||
|
NotificationChannel ch = new NotificationChannel(
|
||||||
|
CHANNEL_ID, "arc-shot", NotificationManager.IMPORTANCE_MIN);
|
||||||
|
nm.createNotificationChannel(ch);
|
||||||
|
}
|
||||||
|
Notification.Builder b = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
? new Notification.Builder(this, CHANNEL_ID)
|
||||||
|
: new Notification.Builder(this);
|
||||||
|
Notification n = b.setContentTitle("arc-shot")
|
||||||
|
.setContentText("screenshot server :" + PORT)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build();
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||||
|
} else {
|
||||||
|
startForeground(1, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void startServer() {
|
||||||
|
// Перезапускаем, если поток ещё не создан ИЛИ умер (раньше тут было
|
||||||
|
// `if (running) return`, из-за чего мёртвый accept-поток уже не поднимался,
|
||||||
|
// а порт оставался LISTEN без обработчика — соединения висли без ответа).
|
||||||
|
if (running && serverThread != null && serverThread.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
serverThread = new Thread(this::serverLoop, "arc-shot-http");
|
||||||
|
serverThread.setDaemon(true);
|
||||||
|
serverThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void serverLoop() {
|
||||||
|
ServerSocket server = null;
|
||||||
|
try {
|
||||||
|
server = new ServerSocket();
|
||||||
|
server.setReuseAddress(true);
|
||||||
|
server.bind(new InetSocketAddress(PORT));
|
||||||
|
serverSocket = server;
|
||||||
|
Log.i(TAG, "listening on :" + PORT);
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
final Socket sock = server.accept();
|
||||||
|
// Каждое соединение — в своём потоке: медленный/битый клиент не
|
||||||
|
// блокирует accept (раньше один зависший handle вешал весь сервер).
|
||||||
|
Thread t = new Thread(() -> handle(sock), "arc-shot-conn");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.start();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
if (!running) break;
|
||||||
|
// Одна сбойная итерация не должна ронять сервер.
|
||||||
|
Log.w(TAG, "accept error: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "server fatal: " + t.getMessage());
|
||||||
|
} finally {
|
||||||
|
// Освобождаем сокет, чтобы следующий startServer() (onStartCommand от
|
||||||
|
// desktop-kick) пересоздал слушатель начисто.
|
||||||
|
running = false;
|
||||||
|
closeQuietly(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(Socket sock) {
|
||||||
|
try (Socket s = sock) {
|
||||||
|
s.setSoTimeout(5000);
|
||||||
|
BufferedReader in = new BufferedReader(
|
||||||
|
new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
|
||||||
|
OutputStream out = s.getOutputStream();
|
||||||
|
|
||||||
|
String line = in.readLine();
|
||||||
|
if (line == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String[] parts = line.split(" ");
|
||||||
|
if (parts.length < 2 || !"GET".equals(parts[0])) {
|
||||||
|
writeResponse(out, "400 Bad Request", "text/plain", new byte[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String path = parts[1];
|
||||||
|
int q = path.indexOf('?');
|
||||||
|
if (q >= 0) {
|
||||||
|
path = path.substring(0, q);
|
||||||
|
}
|
||||||
|
if ("/health".equals(path)) {
|
||||||
|
writeResponse(out, "200 OK", "text/plain", "ok".getBytes(StandardCharsets.UTF_8));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String name = path.startsWith("/") ? path.substring(1) : path;
|
||||||
|
File file = new File(BASE_DIR, name);
|
||||||
|
// Защита от path traversal — файл должен лежать внутри BASE_DIR.
|
||||||
|
// /sdcard — симлинк на /storage/emulated/0, поэтому сравниваем canonical
|
||||||
|
// обеих сторон, а не с литералом "/sdcard".
|
||||||
|
String baseCanon = new File(BASE_DIR).getCanonicalPath();
|
||||||
|
String canon = file.getCanonicalPath();
|
||||||
|
if (!canon.startsWith(baseCanon + "/") || !file.isFile()) {
|
||||||
|
writeResponse(out, "404 Not Found", "text/plain", new byte[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeResponse(out, "200 OK", "image/png", readFile(file));
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "handle error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readFile(File f) throws IOException {
|
||||||
|
try (FileInputStream fis = new FileInputStream(f);
|
||||||
|
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int n;
|
||||||
|
while ((n = fis.read(buf)) > 0) {
|
||||||
|
bos.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeResponse(OutputStream out, String status, String ctype, byte[] body)
|
||||||
|
throws IOException {
|
||||||
|
String header = "HTTP/1.0 " + status + "\r\n"
|
||||||
|
+ "Content-Type: " + ctype + "\r\n"
|
||||||
|
+ "Content-Length: " + body.length + "\r\n"
|
||||||
|
+ "Connection: close\r\n\r\n";
|
||||||
|
out.write(header.getBytes(StandardCharsets.UTF_8));
|
||||||
|
if (body.length > 0) {
|
||||||
|
out.write(body);
|
||||||
|
}
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void closeQuietly(ServerSocket s) {
|
||||||
|
if (s != null) {
|
||||||
|
try {
|
||||||
|
s.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java
Normal file
19
apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package com.arc.shot;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
/** Поднимает arc-shot после перезагрузки телефона. */
|
||||||
|
public class BootReceiver extends BroadcastReceiver {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context ctx, Intent intent) {
|
||||||
|
Intent i = new Intent(ctx, ArcShotService.class);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
ctx.startForegroundService(i);
|
||||||
|
} else {
|
||||||
|
ctx.startService(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java
Normal file
25
apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package com.arc.shot;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Служебное Activity без UI: при запуске поднимает foreground-сервис и закрывается.
|
||||||
|
* Нужно, чтобы у приложения был launcher-значок и чтобы сервис можно было поднять
|
||||||
|
* через `adb shell am start -n com.arc.shot/.MainActivity` или тапом.
|
||||||
|
*/
|
||||||
|
public class MainActivity extends Activity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
Intent i = new Intent(this, ArcShotService.class);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(i);
|
||||||
|
} else {
|
||||||
|
startService(i);
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,80 +0,0 @@
|
|||||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
|
||||||
<hierarchy rotation="0">
|
|
||||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
|
||||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
|
||||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
|
||||||
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
|
||||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
|
||||||
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
|
|
||||||
checked="false" clickable="false" enabled="true" focusable="true" focused="false"
|
|
||||||
scrollable="false" long-clickable="false" password="false" selected="false"
|
|
||||||
bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_singleton"
|
|
||||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
|
||||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
|
||||||
selected="false" bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_dialog"
|
|
||||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
|
||||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[18,532][702,1060]">
|
|
||||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/content_container"
|
|
||||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
|
||||||
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[48,562][672,816]">
|
|
||||||
<node index="0" text="" resource-id="" class="android.widget.LinearLayout"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc=""
|
|
||||||
checkable="false" checked="false" clickable="false" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[93,607][627,771]">
|
|
||||||
<node index="0" text=""
|
|
||||||
resource-id="com.android.permissioncontroller:id/permission_icon"
|
|
||||||
class="android.widget.ImageView"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc=""
|
|
||||||
checkable="false" checked="false" clickable="false" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[337,607][382,652]"/>
|
|
||||||
<node index="1" text="Allow Альфа-Банк to access your contacts?"
|
|
||||||
resource-id="com.android.permissioncontroller:id/permission_message"
|
|
||||||
class="android.widget.TextView"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc=""
|
|
||||||
checkable="false" checked="false" clickable="false" enabled="true"
|
|
||||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[93,675][627,771]"/>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
<node index="1" text="" resource-id="" class="android.widget.LinearLayout"
|
|
||||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
|
|
||||||
checked="false" clickable="false" enabled="true" focusable="false" focused="false"
|
|
||||||
scrollable="false" long-clickable="false" password="false" selected="false"
|
|
||||||
bounds="[48,818][672,1030]">
|
|
||||||
<node index="0" text="ALLOW"
|
|
||||||
resource-id="com.android.permissioncontroller:id/permission_allow_button"
|
|
||||||
class="android.widget.Button" package="com.google.android.permissioncontroller"
|
|
||||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
|
||||||
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[48,818][672,923]"/>
|
|
||||||
<node index="1" text="DON’T ALLOW"
|
|
||||||
resource-id="com.android.permissioncontroller:id/permission_deny_button"
|
|
||||||
class="android.widget.Button" package="com.google.android.permissioncontroller"
|
|
||||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
|
||||||
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
|
||||||
password="false" selected="false" bounds="[48,925][672,1030]"/>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</node>
|
|
||||||
</hierarchy>
|
|
||||||
BIN
assets/arc-shot.apk
Normal file
BIN
assets/arc-shot.apk
Normal file
Binary file not shown.
10
config.ini
10
config.ini
@ -34,6 +34,16 @@ manifestUrl=http://142.93.102.40/updates/test/version.json
|
|||||||
manifestUrlTest=http://142.93.102.40/updates/test/version.json
|
manifestUrlTest=http://142.93.102.40/updates/test/version.json
|
||||||
manifestUrlProd=http://142.93.102.40/updates/prod/version.json
|
manifestUrlProd=http://142.93.102.40/updates/prod/version.json
|
||||||
|
|
||||||
|
[screenshot]
|
||||||
|
; transport: http (через APK arc-shot, мимо adb) | execout (старый exec-out screencap)
|
||||||
|
transport=http
|
||||||
|
httpPort=8099
|
||||||
|
httpTimeoutMs=4000
|
||||||
|
remotePath=/sdcard/arc_shot.png
|
||||||
|
; интервал превью-кадра, сек: http можно чаще (мимо adb), exec-out реже (через adb)
|
||||||
|
previewIntervalSecs=4
|
||||||
|
previewIntervalHttpSecs=2
|
||||||
|
|
||||||
[black]
|
[black]
|
||||||
android_package_name=app.blackwallet.ar
|
android_package_name=app.blackwallet.ar
|
||||||
currency=ARS
|
currency=ARS
|
||||||
|
|||||||
@ -259,6 +259,18 @@ void DatabaseManager::initSchema() {
|
|||||||
value TEXT
|
value TEXT
|
||||||
))");
|
))");
|
||||||
|
|
||||||
|
// Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id).
|
||||||
|
// Задел под авто-реконнект по Wi-Fi: ip+port для adb connect, code — одноразовый
|
||||||
|
// pairing-код (нужен только для первого adb pair; реконнект идёт по ip:port).
|
||||||
|
q.exec(R"(CREATE TABLE IF NOT EXISTS wifi_connections (
|
||||||
|
android_id TEXT PRIMARY KEY,
|
||||||
|
ip TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
code TEXT,
|
||||||
|
updated_at DATETIME,
|
||||||
|
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||||
|
))");
|
||||||
|
|
||||||
db.commit();
|
db.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
database/dao/WifiConnectionDAO.cpp
Normal file
82
database/dao/WifiConnectionDAO.cpp
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
#include "WifiConnectionDAO.h"
|
||||||
|
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
#include "DatabaseManager.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
WifiConnection parseRow(const QSqlQuery &query) {
|
||||||
|
WifiConnection wc;
|
||||||
|
wc.androidId = query.value("android_id").toString();
|
||||||
|
wc.ip = query.value("ip").toString();
|
||||||
|
wc.port = query.value("port").toInt();
|
||||||
|
wc.code = query.value("code").toString();
|
||||||
|
wc.updatedAt = query.value("updated_at").toString();
|
||||||
|
return wc;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port,
|
||||||
|
const QString &code) {
|
||||||
|
QSqlQuery query(DatabaseManager::instance().database());
|
||||||
|
query.prepare(R"(
|
||||||
|
INSERT INTO wifi_connections (android_id, ip, port, code, updated_at)
|
||||||
|
VALUES (:android_id, :ip, :port, :code, :updated_at)
|
||||||
|
ON CONFLICT(android_id) DO UPDATE SET
|
||||||
|
ip = excluded.ip,
|
||||||
|
port = excluded.port,
|
||||||
|
code = excluded.code,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
)");
|
||||||
|
query.bindValue(":android_id", androidId);
|
||||||
|
query.bindValue(":ip", ip);
|
||||||
|
query.bindValue(":port", port);
|
||||||
|
query.bindValue(":code", code);
|
||||||
|
query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
WifiConnection WifiConnectionDAO::getByAndroidId(const QString &androidId) {
|
||||||
|
QSqlQuery query(DatabaseManager::instance().database());
|
||||||
|
query.prepare("SELECT * FROM wifi_connections WHERE android_id = :android_id LIMIT 1");
|
||||||
|
query.bindValue(":android_id", androidId);
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "WifiConnectionDAO::getByAndroidId error:" << query.lastError().text();
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (query.next()) {
|
||||||
|
return parseRow(query);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<WifiConnection> WifiConnectionDAO::getAll() {
|
||||||
|
QList<WifiConnection> result;
|
||||||
|
QSqlQuery query(DatabaseManager::instance().database());
|
||||||
|
if (!query.exec("SELECT * FROM wifi_connections")) {
|
||||||
|
qWarning() << "WifiConnectionDAO::getAll error:" << query.lastError().text();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
while (query.next()) {
|
||||||
|
result.append(parseRow(query));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WifiConnectionDAO::remove(const QString &androidId) {
|
||||||
|
QSqlQuery query(DatabaseManager::instance().database());
|
||||||
|
query.prepare("DELETE FROM wifi_connections WHERE android_id = :android_id");
|
||||||
|
query.bindValue(":android_id", androidId);
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "WifiConnectionDAO::remove error:" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
32
database/dao/WifiConnectionDAO.h
Normal file
32
database/dao/WifiConnectionDAO.h
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QList>
|
||||||
|
|
||||||
|
// Реквизиты подключения устройства по Wi-Fi. Связь с устройством — по android_id
|
||||||
|
// (= devices.id), т.е. одна запись на физический аппарат, независимо от транспорта.
|
||||||
|
struct WifiConnection {
|
||||||
|
QString androidId; // = devices.id
|
||||||
|
QString ip; // последний известный IP телефона в Wi-Fi
|
||||||
|
int port = 0; // порт для adb connect (5555 для legacy tcpip)
|
||||||
|
QString code; // одноразовый pairing-код (пусто для legacy tcpip)
|
||||||
|
QString updatedAt; // ISO-время последнего обновления
|
||||||
|
};
|
||||||
|
|
||||||
|
// DAO таблицы wifi_connections. Задел под будущий авто-реконнект по Wi-Fi:
|
||||||
|
// хранит ip/port/code per-device, чтобы переподключать устройство без повторного
|
||||||
|
// поиска. В текущей итерации — только хранилище (воркер реконнекта отложен).
|
||||||
|
class WifiConnectionDAO {
|
||||||
|
public:
|
||||||
|
// Вставка/обновление по android_id (PRIMARY KEY).
|
||||||
|
static bool upsert(const QString &androidId, const QString &ip, int port,
|
||||||
|
const QString &code = {});
|
||||||
|
|
||||||
|
// Запись по android_id (пустой androidId в результате → не найдено).
|
||||||
|
[[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId);
|
||||||
|
|
||||||
|
// Все сохранённые реквизиты.
|
||||||
|
[[nodiscard]] static QList<WifiConnection> getAll();
|
||||||
|
|
||||||
|
static bool remove(const QString &androidId);
|
||||||
|
};
|
||||||
@ -145,3 +145,15 @@ CREATE TABLE IF NOT EXISTS settings
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT
|
value TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id).
|
||||||
|
-- ip+port — для adb connect; code — одноразовый pairing-код (только для первого adb pair).
|
||||||
|
CREATE TABLE IF NOT EXISTS wifi_connections
|
||||||
|
(
|
||||||
|
android_id TEXT PRIMARY KEY,
|
||||||
|
ip TEXT,
|
||||||
|
port INTEGER,
|
||||||
|
code TEXT,
|
||||||
|
updated_at DATETIME,
|
||||||
|
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
#include <dao/DeviceDAO.h>
|
#include <dao/DeviceDAO.h>
|
||||||
|
#include <dao/WifiConnectionDAO.h>
|
||||||
#include <db/DeviceInfo.h>
|
#include <db/DeviceInfo.h>
|
||||||
|
|
||||||
#include "adb/AdbUtils.h"
|
#include "adb/AdbUtils.h"
|
||||||
@ -334,8 +335,25 @@ void DeviceScreener::start() {
|
|||||||
QThreadPool scanPool;
|
QThreadPool scanPool;
|
||||||
scanPool.setMaxThreadCount(kMaxParallelScans);
|
scanPool.setMaxThreadCount(kMaxParallelScans);
|
||||||
|
|
||||||
|
// Интервал превью-кадра зависит от режима скриншота: для http (разгрузка через
|
||||||
|
// arc-shot, байты мимо adb) можно чаще, чем для exec-out (стрим через adb).
|
||||||
|
const QSettings shotCfg("config.ini", QSettings::IniFormat);
|
||||||
|
const qint64 shotIntervalSecs =
|
||||||
|
shotCfg.value("screenshot/previewIntervalSecs", kScreencapIntervalSecs).toInt();
|
||||||
|
const qint64 shotIntervalHttpSecs =
|
||||||
|
shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt();
|
||||||
|
|
||||||
while (m_running) {
|
while (m_running) {
|
||||||
QList<QPair<QString, QString> > devices = readAdbDevices();
|
bool adbOk = true;
|
||||||
|
QList<QPair<QString, QString> > devices = readAdbDevices(&adbOk);
|
||||||
|
// Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит,
|
||||||
|
// что устройства пропали. Иначе один зависший вызов (напр. из-за медленных
|
||||||
|
// screencap'ов по Wi-Fi) гасит всю ферму разом (mass-offline каскад).
|
||||||
|
// Пропускаем цикл, сохраняя прошлое online/offline-состояние.
|
||||||
|
if (!adbOk) {
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
||||||
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
||||||
|
|
||||||
@ -356,11 +374,15 @@ void DeviceScreener::start() {
|
|||||||
appsDue.insert(serial);
|
appsDue.insert(serial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// С каких устройств пора снять превью-кадр (троттл screencap, см.
|
// С каких устройств пора снять превью-кадр. Интервал зависит от режима:
|
||||||
// kScreencapIntervalSecs) — чтобы не грузить adb каждый цикл.
|
// http (мимо adb) — чаще (shotIntervalHttpSecs), exec-out — реже
|
||||||
|
// (shotIntervalSecs), чтобы не грузить adb. Режим известен только после
|
||||||
|
// первого кадра — до того идём по обычному интервалу.
|
||||||
QSet<QString> shotDue;
|
QSet<QString> shotDue;
|
||||||
for (const QString &serial : deviceSerials) {
|
for (const QString &serial : deviceSerials) {
|
||||||
if (nowSec - m_lastScreencap.value(serial, 0) >= kScreencapIntervalSecs) {
|
const qint64 interval = (AdbUtils::screenshotMode(serial) == "http")
|
||||||
|
? shotIntervalHttpSecs : shotIntervalSecs;
|
||||||
|
if (nowSec - m_lastScreencap.value(serial, 0) >= interval) {
|
||||||
shotDue.insert(serial);
|
shotDue.insert(serial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -425,6 +447,18 @@ void DeviceScreener::start() {
|
|||||||
qDebug() << "Cant update device";
|
qDebug() << "Cant update device";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем Wi-Fi IP телефона в wifi_connections (по android_id) — задел под
|
||||||
|
// авто-реконнект. Резолвим напрямую (независимо от http-скриншота, чтобы IP
|
||||||
|
// был и для exec-out-устройств); resolvePhoneIp кэширует с TTL ~5 мин, так что
|
||||||
|
// adb дёргается редко. Пишем только при изменении. upsert после upsertDevice —
|
||||||
|
// FK wifi_connections → devices(id).
|
||||||
|
if (const QString ip = AdbUtils::resolvePhoneIp(scan.adbSerial);
|
||||||
|
!ip.isEmpty() && m_lastPersistedIp.value(device.id) != ip) {
|
||||||
|
if (WifiConnectionDAO::upsert(device.id, ip, 5555)) {
|
||||||
|
m_lastPersistedIp[device.id] = ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем установленные приложения по троттлу (список прочитан в фазе A).
|
// Проверяем установленные приложения по троттлу (список прочитан в фазе A).
|
||||||
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
|
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
|
||||||
// цикле и не пометить все приложения как отсутствующие.
|
// цикле и не пометить все приложения как отсутствующие.
|
||||||
@ -572,21 +606,10 @@ QByteArray convertPngToJpeg(const QByteArray &pngData, const int quality = 90) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) {
|
QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) {
|
||||||
QProcess process;
|
// Единая стратегия: http через APK arc-shot (мимо adb) с fallback на exec-out.
|
||||||
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
// Раньше здесь был прямой exec-out screencap — по Wi-Fi он виснет и душит adb
|
||||||
QStringList arguments;
|
// (каскад adb devices timeout → mass-offline).
|
||||||
|
const QByteArray imageData = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
arguments << "-s" << deviceId << "exec-out" << "screencap" << "-p";
|
|
||||||
process.start(AdbUtils::adbPath(), arguments);
|
|
||||||
|
|
||||||
if (!process.waitForFinished(5000)) {
|
|
||||||
qWarning() << "ADB скриншот не завершился для" << deviceId;
|
|
||||||
process.kill();
|
|
||||||
process.waitForFinished(1000);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const QByteArray imageData = process.readAllStandardOutput();
|
|
||||||
|
|
||||||
if (imageData.isEmpty()) {
|
if (imageData.isEmpty()) {
|
||||||
qWarning() << "Скриншот пустой для " << deviceId;
|
qWarning() << "Скриншот пустой для " << deviceId;
|
||||||
@ -596,7 +619,8 @@ QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) {
|
|||||||
return convertPngToJpeg(imageData, 90);
|
return convertPngToJpeg(imageData, 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
|
QList<QPair<QString, QString> > DeviceScreener::readAdbDevices(bool *adbOk) {
|
||||||
|
if (adbOk) *adbOk = false;
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
||||||
process.start(AdbUtils::adbPath(), QStringList() << "devices");
|
process.start(AdbUtils::adbPath(), QStringList() << "devices");
|
||||||
@ -612,6 +636,7 @@ QList<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (adbOk) *adbOk = true;
|
||||||
const QString output = process.readAllStandardOutput();
|
const QString output = process.readAllStandardOutput();
|
||||||
QList<QPair<QString, QString> > devices;
|
QList<QPair<QString, QString> > devices;
|
||||||
const QStringList lines = output.split('\n');
|
const QStringList lines = output.split('\n');
|
||||||
|
|||||||
@ -65,6 +65,7 @@ private:
|
|||||||
QMap<QString, DeviceInfo> m_staticInfoCache;
|
QMap<QString, DeviceInfo> m_staticInfoCache;
|
||||||
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
|
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
|
||||||
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
|
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
|
||||||
|
QMap<QString, QString> m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль)
|
||||||
|
|
||||||
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
|
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
|
||||||
// потоков). Не содержит ничего, что требует БД/членов класса.
|
// потоков). Не содержит ничего, что требует БД/членов класса.
|
||||||
@ -77,7 +78,10 @@ private:
|
|||||||
bool appsRead = false; // true → getListApps вызывался в этом цикле
|
bool appsRead = false; // true → getListApps вызывался в этом цикле
|
||||||
};
|
};
|
||||||
|
|
||||||
static QList<QPair<QString, QString> > readAdbDevices();
|
// adbOk (если передан) выставляется в false, когда сам `adb devices` не
|
||||||
|
// отработал (не стартовал / таймаут) — в этом случае возвращается пустой
|
||||||
|
// список, но цикл НЕ должен трактовать это как «все устройства пропали».
|
||||||
|
static QList<QPair<QString, QString> > readAdbDevices(bool *adbOk = nullptr);
|
||||||
|
|
||||||
static QByteArray takeScreenshot(const QString &deviceId);
|
static QByteArray takeScreenshot(const QString &deviceId);
|
||||||
|
|
||||||
|
|||||||
@ -338,7 +338,20 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
|
|||||||
imageLabel->setAlignment(Qt::AlignCenter);
|
imageLabel->setAlignment(Qt::AlignCenter);
|
||||||
layout->addWidget(imageLabel, 0, 0);
|
layout->addWidget(imageLabel, 0, 0);
|
||||||
|
|
||||||
auto *statusLabel = new QLabel(QString("%1 %2%").arg(device.name).arg(device.battery), this);
|
// Режим скриншотов устройства (для онлайн-устройств): http — разгрузка через
|
||||||
|
// arc-shot мимо adb; exec-out — fallback через adb (arc-shot недоступен).
|
||||||
|
QString statusText = QString("%1 %2%").arg(device.name).arg(device.battery);
|
||||||
|
if (device.status == "device") {
|
||||||
|
const QString shotMode = AdbUtils::screenshotMode(
|
||||||
|
device.adbSerial.isEmpty() ? device.id : device.adbSerial);
|
||||||
|
if (shotMode == "http") {
|
||||||
|
statusText += " <span style='color:#7CFC7C;'>скрин: http</span>";
|
||||||
|
} else if (shotMode.startsWith("exec-out")) {
|
||||||
|
statusText += " <span style='color:#FFB347;'>скрин: adb</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto *statusLabel = new QLabel(statusText, this);
|
||||||
|
statusLabel->setTextFormat(Qt::RichText);
|
||||||
statusLabel->setWordWrap(true);
|
statusLabel->setWordWrap(true);
|
||||||
statusLabel->setMaximumWidth(300);
|
statusLabel->setMaximumWidth(300);
|
||||||
statusLabel->setStyleSheet(
|
statusLabel->setStyleSheet(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user