From da72c4eda6abe6c5dcd519b85200d49735e61ea0 Mon Sep 17 00:00:00 2001 From: slava Date: Wed, 17 Jun 2026 18:30:08 +0700 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=83=20=D1=81=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BD=D1=88=D0=BE=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 6 + android/adb/AdbUtils.cpp | 249 ++++++++++++++++++ android/adb/AdbUtils.h | 30 ++- apk/arc-shot/.gitignore | 4 + apk/arc-shot/README.md | 54 ++++ apk/arc-shot/build.gradle | 33 +++ apk/arc-shot/build_manual.sh | 61 +++++ apk/arc-shot/settings.gradle | 14 + apk/arc-shot/src/main/AndroidManifest.xml | 39 +++ .../java/com/arc/shot/ArcShotService.java | 207 +++++++++++++++ .../main/java/com/arc/shot/BootReceiver.java | 19 ++ .../main/java/com/arc/shot/MainActivity.java | 25 ++ assets/alpha_contact_permission.xml | 80 ------ assets/arc-shot.apk | Bin 0 -> 12715 bytes config.ini | 10 + database/DatabaseManager.cpp | 12 + database/dao/WifiConnectionDAO.cpp | 82 ++++++ database/dao/WifiConnectionDAO.h | 32 +++ migrations.sql | 12 + services/DeviceScreener.cpp | 65 +++-- services/DeviceScreener.h | 6 +- views/device/DeviceWidget.cpp | 15 +- 22 files changed, 952 insertions(+), 103 deletions(-) create mode 100644 apk/arc-shot/.gitignore create mode 100644 apk/arc-shot/README.md create mode 100644 apk/arc-shot/build.gradle create mode 100755 apk/arc-shot/build_manual.sh create mode 100644 apk/arc-shot/settings.gradle create mode 100644 apk/arc-shot/src/main/AndroidManifest.xml create mode 100644 apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java create mode 100644 apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java create mode 100644 apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java delete mode 100644 assets/alpha_contact_permission.xml create mode 100644 assets/arc-shot.apk create mode 100644 database/dao/WifiConnectionDAO.cpp create mode 100644 database/dao/WifiConnectionDAO.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 04e8c4c..6814f59 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,6 +175,7 @@ if (WIN32) 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}/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 занят file(GLOB _adb_files "${CMAKE_CURRENT_SOURCE_DIR}/tools/adb/windows/*") foreach(_adb_file ${_adb_files}) @@ -230,6 +231,10 @@ if (WIN32) COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_CURRENT_SOURCE_DIR}/assets/ADBKeyboard.apk" "$/assets/ADBKeyboard.apk" + + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/assets/arc-shot.apk" + "$/assets/arc-shot.apk" ) # 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}/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/arc-shot.apk" DESTINATION "${CMAKE_BINARY_DIR}/assets") endif () target_include_directories(ARC PRIVATE diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 9aa7c52..2fe9df7 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -6,9 +6,62 @@ #include #include #include +#include +#include +#include +#include +#include #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 g_ipCache; +QHash g_ipCacheTs; + +// Негативный кэш: serial → unix ts последней неудачи HTTP. Пока не истёк бэкофф, +// идём сразу в exec-out fallback, не дёргая screencap+IP+таймаут каждый цикл. +constexpr qint64 kHttpFailBackoffSecs = 60; +QMutex g_httpFailMutex; +QHash g_httpFailAt; + +// Лог смены пути скриншота (http ↔ execout) для наблюдаемости — без спама каждые 4с. +QMutex g_modeMutex; +QHash 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) { } @@ -449,6 +502,36 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) { } 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; process.setWorkingDirectory(adbDir()); process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"}); @@ -464,6 +547,172 @@ QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) { 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 ..." + 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(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) { const QString adb = adbPath(); struct Step { QStringList args; }; diff --git a/android/adb/AdbUtils.h b/android/adb/AdbUtils.h index 8113c49..15babde 100644 --- a/android/adb/AdbUtils.h +++ b/android/adb/AdbUtils.h @@ -27,9 +27,26 @@ public: 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); + // Скриншот через 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 makeTap(const QString &deviceId, int x, int y); @@ -73,4 +90,15 @@ private: static QString adbDir(); static bool startProcess(const QString &program, const QStringList &args, 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); }; diff --git a/apk/arc-shot/.gitignore b/apk/arc-shot/.gitignore new file mode 100644 index 0000000..02ce11d --- /dev/null +++ b/apk/arc-shot/.gitignore @@ -0,0 +1,4 @@ +build/ +.gradle/ +*.keystore +local.properties diff --git a/apk/arc-shot/README.md b/apk/arc-shot/README.md new file mode 100644 index 0000000..929081e --- /dev/null +++ b/apk/arc-shot/README.md @@ -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 /.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 install -r build/arc-shot.apk +# доступ к /sdcard без диалога на устройстве: +adb -s shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow +# запустить сервис: +adb -s shell am start -n com.arc.shot/.MainActivity +``` + +## Проверка + +```bash +adb -s shell screencap -p /sdcard/arc_shot.png +IP=$(adb -s 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 недоступен). diff --git a/apk/arc-shot/build.gradle b/apk/arc-shot/build.gradle new file mode 100644 index 0000000..3c5895e --- /dev/null +++ b/apk/arc-shot/build.gradle @@ -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 + } +} diff --git a/apk/arc-shot/build_manual.sh b/apk/arc-shot/build_manual.sh new file mode 100755 index 0000000..3b4c909 --- /dev/null +++ b/apk/arc-shot/build_manual.sh @@ -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 diff --git a/apk/arc-shot/settings.gradle b/apk/arc-shot/settings.gradle new file mode 100644 index 0000000..0431bf4 --- /dev/null +++ b/apk/arc-shot/settings.gradle @@ -0,0 +1,14 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} +rootProject.name = 'arc-shot' diff --git a/apk/arc-shot/src/main/AndroidManifest.xml b/apk/arc-shot/src/main/AndroidManifest.xml new file mode 100644 index 0000000..214ac97 --- /dev/null +++ b/apk/arc-shot/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java b/apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java new file mode 100644 index 0000000..bc5bce4 --- /dev/null +++ b/apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java @@ -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) { + } + } + } +} diff --git a/apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java b/apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java new file mode 100644 index 0000000..6189da1 --- /dev/null +++ b/apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java @@ -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); + } + } +} diff --git a/apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java b/apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java new file mode 100644 index 0000000..ad4eb17 --- /dev/null +++ b/apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java @@ -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(); + } +} diff --git a/assets/alpha_contact_permission.xml b/assets/alpha_contact_permission.xml deleted file mode 100644 index 0855e70..0000000 --- a/assets/alpha_contact_permission.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/arc-shot.apk b/assets/arc-shot.apk new file mode 100644 index 0000000000000000000000000000000000000000..2fa2276f234496ff4c0e9b459f807aa23716ea28 GIT binary patch literal 12715 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 literal 0 HcmV?d00001 diff --git a/config.ini b/config.ini index d425f46..3725dba 100644 --- a/config.ini +++ b/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 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] android_package_name=app.blackwallet.ar currency=ARS diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 3782704..3fcc171 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -259,6 +259,18 @@ void DatabaseManager::initSchema() { 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(); } diff --git a/database/dao/WifiConnectionDAO.cpp b/database/dao/WifiConnectionDAO.cpp new file mode 100644 index 0000000..c05d42c --- /dev/null +++ b/database/dao/WifiConnectionDAO.cpp @@ -0,0 +1,82 @@ +#include "WifiConnectionDAO.h" + +#include +#include +#include +#include + +#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 WifiConnectionDAO::getAll() { + QList 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; +} diff --git a/database/dao/WifiConnectionDAO.h b/database/dao/WifiConnectionDAO.h new file mode 100644 index 0000000..57aafbd --- /dev/null +++ b/database/dao/WifiConnectionDAO.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +// Реквизиты подключения устройства по 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 getAll(); + + static bool remove(const QString &androidId); +}; diff --git a/migrations.sql b/migrations.sql index 8627f1d..4cf3278 100644 --- a/migrations.sql +++ b/migrations.sql @@ -145,3 +145,15 @@ CREATE TABLE IF NOT EXISTS settings key TEXT PRIMARY KEY, 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 +); diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index 3b856df..b627c85 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include "adb/AdbUtils.h" @@ -334,8 +335,25 @@ void DeviceScreener::start() { QThreadPool scanPool; 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) { - QList > devices = readAdbDevices(); + bool adbOk = true; + QList > devices = readAdbDevices(&adbOk); + // Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит, + // что устройства пропали. Иначе один зависший вызов (напр. из-за медленных + // screencap'ов по Wi-Fi) гасит всю ферму разом (mass-offline каскад). + // Пропускаем цикл, сохраняя прошлое online/offline-состояние. + if (!adbOk) { + QThread::msleep(1000); + continue; + } QSet liveSet; // устройства, видимые как "device" в текущем скане const qint64 nowSec = QDateTime::currentSecsSinceEpoch(); @@ -356,11 +374,15 @@ void DeviceScreener::start() { appsDue.insert(serial); } } - // С каких устройств пора снять превью-кадр (троттл screencap, см. - // kScreencapIntervalSecs) — чтобы не грузить adb каждый цикл. + // С каких устройств пора снять превью-кадр. Интервал зависит от режима: + // http (мимо adb) — чаще (shotIntervalHttpSecs), exec-out — реже + // (shotIntervalSecs), чтобы не грузить adb. Режим известен только после + // первого кадра — до того идём по обычному интервалу. QSet shotDue; 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); } } @@ -425,6 +447,18 @@ void DeviceScreener::start() { 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). // Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след. // цикле и не пометить все приложения как отсутствующие. @@ -572,21 +606,10 @@ QByteArray convertPngToJpeg(const QByteArray &pngData, const int quality = 90) { } QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) { - QProcess process; - process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); - QStringList arguments; - - 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(); + // Единая стратегия: http через APK arc-shot (мимо adb) с fallback на exec-out. + // Раньше здесь был прямой exec-out screencap — по Wi-Fi он виснет и душит adb + // (каскад adb devices timeout → mass-offline). + const QByteArray imageData = AdbUtils::captureScreenshotBytes(deviceId); if (imageData.isEmpty()) { qWarning() << "Скриншот пустой для " << deviceId; @@ -596,7 +619,8 @@ QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) { return convertPngToJpeg(imageData, 90); } -QList > DeviceScreener::readAdbDevices() { +QList > DeviceScreener::readAdbDevices(bool *adbOk) { + if (adbOk) *adbOk = false; QProcess process; process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); process.start(AdbUtils::adbPath(), QStringList() << "devices"); @@ -612,6 +636,7 @@ QList > DeviceScreener::readAdbDevices() { return {}; } + if (adbOk) *adbOk = true; const QString output = process.readAllStandardOutput(); QList > devices; const QStringList lines = output.split('\n'); diff --git a/services/DeviceScreener.h b/services/DeviceScreener.h index e104c22..4fb847d 100644 --- a/services/DeviceScreener.h +++ b/services/DeviceScreener.h @@ -65,6 +65,7 @@ private: QMap m_staticInfoCache; QMap m_lastAppsCheck; // adbSerial → unix ts последнего getListApps QMap m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью) + QMap m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль) // Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле // потоков). Не содержит ничего, что требует БД/членов класса. @@ -77,7 +78,10 @@ private: bool appsRead = false; // true → getListApps вызывался в этом цикле }; - static QList > readAdbDevices(); + // adbOk (если передан) выставляется в false, когда сам `adb devices` не + // отработал (не стартовал / таймаут) — в этом случае возвращается пустой + // список, но цикл НЕ должен трактовать это как «все устройства пропали». + static QList > readAdbDevices(bool *adbOk = nullptr); static QByteArray takeScreenshot(const QString &deviceId); diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index 591ed9b..d574e79 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -338,7 +338,20 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p imageLabel->setAlignment(Qt::AlignCenter); 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 += " скрин: http"; + } else if (shotMode.startsWith("exec-out")) { + statusText += " скрин: adb"; + } + } + auto *statusLabel = new QLabel(statusText, this); + statusLabel->setTextFormat(Qt::RichText); statusLabel->setWordWrap(true); statusLabel->setMaximumWidth(300); statusLabel->setStyleSheet(