1
0
forked from BRT/arc

Изменил работу со скриншотами

This commit is contained in:
slava 2026-06-17 18:30:08 +07:00
parent 7257a5207e
commit da72c4eda6
22 changed files with 952 additions and 103 deletions

View File

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

View File

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

View File

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

@ -0,0 +1,4 @@
build/
.gradle/
*.keystore
local.properties

54
apk/arc-shot/README.md Normal file
View 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
View 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
View 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

View File

@ -0,0 +1,14 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'arc-shot'

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

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

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

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

View File

@ -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="DONT 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

Binary file not shown.

View File

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

View File

@ -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();
} }

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

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

View File

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

View File

@ -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');

View File

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

View File

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