Изменил работу со скриншотами
This commit is contained in:
parent
7257a5207e
commit
da72c4eda6
@ -175,6 +175,7 @@ if (WIN32)
|
||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" DESTINATION "${CMAKE_BINARY_DIR}")
|
||||
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/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"
|
||||
"$<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)
|
||||
@ -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
|
||||
|
||||
@ -6,9 +6,62 @@
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
#include <QSettings>
|
||||
#include <QTcpSocket>
|
||||
#include <QMutex>
|
||||
#include <QDateTime>
|
||||
#include <QHostAddress>
|
||||
|
||||
#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) {
|
||||
}
|
||||
|
||||
@ -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 <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) {
|
||||
const QString adb = adbPath();
|
||||
struct Step { QStringList args; };
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
4
apk/arc-shot/.gitignore
vendored
Normal file
4
apk/arc-shot/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
build/
|
||||
.gradle/
|
||||
*.keystore
|
||||
local.properties
|
||||
54
apk/arc-shot/README.md
Normal file
54
apk/arc-shot/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
# arc-shot
|
||||
|
||||
Крошечное Android-приложение для фермы: foreground-сервис + HTTP-файл-сервер на
|
||||
порту **8099**, раздающий скриншоты с `/sdcard`. Нужно, чтобы десктоп качал кадры
|
||||
по HTTP **напрямую с телефона, мимо adb** — иначе `adb exec-out screencap` по Wi-Fi
|
||||
виснет и душит adb-сервер (каскад `adb devices timeout` → mass-offline).
|
||||
|
||||
Экран приложение **не снимает** (это требовало бы MediaProjection с диалогом
|
||||
согласия) — кадр делает десктоп через `adb shell screencap -p /sdcard/arc_shot.png`,
|
||||
а arc-shot только отдаёт готовый файл по HTTP. Без внешних зависимостей (raw
|
||||
`ServerSocket`).
|
||||
|
||||
## Эндпоинты
|
||||
|
||||
- `GET /health` → `200 ok`
|
||||
- `GET /<file>.png` → файл из `/sdcard` (например `/arc_shot.png`)
|
||||
|
||||
## Сборка
|
||||
|
||||
**Офлайн, без Gradle/AGP** (только Android SDK build-tools):
|
||||
|
||||
```bash
|
||||
./build_manual.sh
|
||||
# -> build/arc-shot.apk
|
||||
```
|
||||
|
||||
Переменные: `BT_VER` (build-tools, дефолт 35.0.1), `PLATFORM` (дефолт android-34),
|
||||
`MIN_SDK`/`TARGET_SDK`, `ANDROID_SDK_ROOT`.
|
||||
|
||||
**Через Android Studio / Gradle** — см. `build.gradle`. Важно: для AGP-сборки убрать
|
||||
атрибут `package="com.arc.shot"` из `AndroidManifest.xml` (AGP берёт пакет из
|
||||
`namespace`). Ручной `build_manual.sh`, наоборот, требует `package=` в манифесте.
|
||||
|
||||
## Установка и выдача прав (с десктопа, через adb)
|
||||
|
||||
```bash
|
||||
adb -s <serial> install -r build/arc-shot.apk
|
||||
# доступ к /sdcard без диалога на устройстве:
|
||||
adb -s <serial> shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
|
||||
# запустить сервис:
|
||||
adb -s <serial> shell am start -n com.arc.shot/.MainActivity
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
adb -s <serial> shell screencap -p /sdcard/arc_shot.png
|
||||
IP=$(adb -s <serial> shell ip route get 1.1.1.1 | sed -n 's/.*src \([0-9.]*\).*/\1/p')
|
||||
curl -s "http://$IP:8099/arc_shot.png" -o shot.png && file shot.png
|
||||
curl -s "http://$IP:8099/health" # -> ok
|
||||
```
|
||||
|
||||
Десктоп использует этот путь автоматически при `config.ini [screenshot] transport=http`
|
||||
(с fallback на `exec-out`, если arc-shot недоступен).
|
||||
33
apk/arc-shot/build.gradle
Normal file
33
apk/arc-shot/build.gradle
Normal file
@ -0,0 +1,33 @@
|
||||
// Стандартная сборка через Android Studio / Gradle (онлайн, тянет AGP с Google Maven).
|
||||
// Офлайн-сборка без AGP — см. build_manual.sh.
|
||||
//
|
||||
// ВНИМАНИЕ: AGP 8 берёт package из namespace и НЕ допускает атрибут package= в
|
||||
// AndroidManifest.xml. Для gradle-сборки убери `package="com.arc.shot"` из манифеста
|
||||
// (он нужен только ручному build_manual.sh).
|
||||
plugins {
|
||||
id 'com.android.application' version '8.5.2'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.arc.shot'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'com.arc.shot'
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName '1.0'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
61
apk/arc-shot/build_manual.sh
Executable file
61
apk/arc-shot/build_manual.sh
Executable file
@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# Офлайн-сборка APK arc-shot напрямую через Android build-tools (без Gradle/AGP).
|
||||
# Использует только локальный SDK + android.jar. Требует: javac (JDK 17+), Android SDK.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}"
|
||||
BT_VER="${BT_VER:-35.0.1}"
|
||||
PLATFORM="${PLATFORM:-android-34}"
|
||||
MIN_SDK="${MIN_SDK:-24}"
|
||||
TARGET_SDK="${TARGET_SDK:-34}"
|
||||
|
||||
BT="$SDK/build-tools/$BT_VER"
|
||||
ANDROID_JAR="$SDK/platforms/$PLATFORM/android.jar"
|
||||
|
||||
[ -d "$BT" ] || { echo "build-tools $BT_VER not found at $BT"; exit 1; }
|
||||
[ -f "$ANDROID_JAR" ] || { echo "android.jar not found at $ANDROID_JAR"; exit 1; }
|
||||
|
||||
OUT="build"
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT/classes" "$OUT/dex"
|
||||
|
||||
echo "[1/6] javac"
|
||||
find src/main/java -name '*.java' > "$OUT/sources.txt"
|
||||
javac --release 17 -classpath "$ANDROID_JAR" -d "$OUT/classes" @"$OUT/sources.txt"
|
||||
|
||||
echo "[2/6] d8 -> classes.dex"
|
||||
CLASSES=$(find "$OUT/classes" -name '*.class')
|
||||
"$BT/d8" --min-api "$MIN_SDK" --lib "$ANDROID_JAR" --output "$OUT/dex" $CLASSES
|
||||
|
||||
echo "[3/6] aapt2 link"
|
||||
"$BT/aapt2" link \
|
||||
-o "$OUT/app-unsigned.apk" \
|
||||
-I "$ANDROID_JAR" \
|
||||
--manifest src/main/AndroidManifest.xml \
|
||||
--min-sdk-version "$MIN_SDK" \
|
||||
--target-sdk-version "$TARGET_SDK"
|
||||
|
||||
echo "[4/6] add classes.dex"
|
||||
cp "$OUT/dex/classes.dex" "$OUT/classes.dex"
|
||||
( cd "$OUT" && zip -j -q app-unsigned.apk classes.dex )
|
||||
|
||||
echo "[5/6] zipalign"
|
||||
"$BT/zipalign" -f -p 4 "$OUT/app-unsigned.apk" "$OUT/app-aligned.apk"
|
||||
|
||||
echo "[6/6] sign"
|
||||
# Keystore держим ВНЕ build/ — иначе rm -rf build стирает ключ и подпись меняется
|
||||
# каждую пересборку (INSTALL_FAILED_UPDATE_INCOMPATIBLE при обновлении на устройстве).
|
||||
KS="debug.keystore"
|
||||
if [ ! -f "$KS" ]; then
|
||||
keytool -genkeypair -keystore "$KS" -storepass android -keypass android \
|
||||
-alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
|
||||
fi
|
||||
"$BT/apksigner" sign \
|
||||
--ks "$KS" --ks-pass pass:android --ks-key-alias androiddebugkey --key-pass pass:android \
|
||||
--out "$OUT/arc-shot.apk" "$OUT/app-aligned.apk"
|
||||
|
||||
echo "OK -> $OUT/arc-shot.apk"
|
||||
"$BT/aapt2" dump badging "$OUT/arc-shot.apk" 2>/dev/null | grep -E "package:|launchable-activity:|uses-permission: name='android.permission.(MANAGE_EXTERNAL_STORAGE|FOREGROUND_SERVICE)'" || true
|
||||
14
apk/arc-shot/settings.gradle
Normal file
14
apk/arc-shot/settings.gradle
Normal file
@ -0,0 +1,14 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'arc-shot'
|
||||
39
apk/arc-shot/src/main/AndroidManifest.xml
Normal file
39
apk/arc-shot/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.arc.shot">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="arc-shot"
|
||||
android:allowBackup="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".ArcShotService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
207
apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java
Normal file
207
apk/arc-shot/src/main/java/com/arc/shot/ArcShotService.java
Normal file
@ -0,0 +1,207 @@
|
||||
package com.arc.shot;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Foreground-сервис arc-shot: крошечный HTTP-файл-сервер, раздающий скриншоты
|
||||
* с /sdcard. Десктоп снимает кадр через `adb shell screencap -p /sdcard/arc_shot.png`
|
||||
* (дёшево, без передачи байтов через adb) и качает файл по HTTP напрямую с телефона —
|
||||
* так мегабайты PNG идут по Wi-Fi мимо adb и не душат adb-сервер.
|
||||
*
|
||||
* Экран сам НЕ снимает (это требовало бы MediaProjection с диалогом согласия) —
|
||||
* только отдаёт уже снятый adb'ом файл. Доступ к /sdcard выдаётся через adb:
|
||||
* adb shell appops set com.arc.shot MANAGE_EXTERNAL_STORAGE allow
|
||||
*/
|
||||
public class ArcShotService extends Service {
|
||||
private static final String TAG = "ArcShot";
|
||||
static final int PORT = 8099;
|
||||
private static final String BASE_DIR = "/sdcard";
|
||||
private static final String CHANNEL_ID = "arc_shot";
|
||||
|
||||
private volatile boolean running = false;
|
||||
private ServerSocket serverSocket;
|
||||
private Thread serverThread;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
startForegroundCompat();
|
||||
startServer();
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
running = false;
|
||||
closeQuietly(serverSocket);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void startForegroundCompat() {
|
||||
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
|
||||
NotificationChannel ch = new NotificationChannel(
|
||||
CHANNEL_ID, "arc-shot", NotificationManager.IMPORTANCE_MIN);
|
||||
nm.createNotificationChannel(ch);
|
||||
}
|
||||
Notification.Builder b = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
? new Notification.Builder(this, CHANNEL_ID)
|
||||
: new Notification.Builder(this);
|
||||
Notification n = b.setContentTitle("arc-shot")
|
||||
.setContentText("screenshot server :" + PORT)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||
.setOngoing(true)
|
||||
.build();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(1, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
|
||||
} else {
|
||||
startForeground(1, n);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void startServer() {
|
||||
// Перезапускаем, если поток ещё не создан ИЛИ умер (раньше тут было
|
||||
// `if (running) return`, из-за чего мёртвый accept-поток уже не поднимался,
|
||||
// а порт оставался LISTEN без обработчика — соединения висли без ответа).
|
||||
if (running && serverThread != null && serverThread.isAlive()) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
serverThread = new Thread(this::serverLoop, "arc-shot-http");
|
||||
serverThread.setDaemon(true);
|
||||
serverThread.start();
|
||||
}
|
||||
|
||||
private void serverLoop() {
|
||||
ServerSocket server = null;
|
||||
try {
|
||||
server = new ServerSocket();
|
||||
server.setReuseAddress(true);
|
||||
server.bind(new InetSocketAddress(PORT));
|
||||
serverSocket = server;
|
||||
Log.i(TAG, "listening on :" + PORT);
|
||||
while (running) {
|
||||
try {
|
||||
final Socket sock = server.accept();
|
||||
// Каждое соединение — в своём потоке: медленный/битый клиент не
|
||||
// блокирует accept (раньше один зависший handle вешал весь сервер).
|
||||
Thread t = new Thread(() -> handle(sock), "arc-shot-conn");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
} catch (Throwable t) {
|
||||
if (!running) break;
|
||||
// Одна сбойная итерация не должна ронять сервер.
|
||||
Log.w(TAG, "accept error: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "server fatal: " + t.getMessage());
|
||||
} finally {
|
||||
// Освобождаем сокет, чтобы следующий startServer() (onStartCommand от
|
||||
// desktop-kick) пересоздал слушатель начисто.
|
||||
running = false;
|
||||
closeQuietly(server);
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket sock) {
|
||||
try (Socket s = sock) {
|
||||
s.setSoTimeout(5000);
|
||||
BufferedReader in = new BufferedReader(
|
||||
new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
|
||||
OutputStream out = s.getOutputStream();
|
||||
|
||||
String line = in.readLine();
|
||||
if (line == null) {
|
||||
return;
|
||||
}
|
||||
String[] parts = line.split(" ");
|
||||
if (parts.length < 2 || !"GET".equals(parts[0])) {
|
||||
writeResponse(out, "400 Bad Request", "text/plain", new byte[0]);
|
||||
return;
|
||||
}
|
||||
String path = parts[1];
|
||||
int q = path.indexOf('?');
|
||||
if (q >= 0) {
|
||||
path = path.substring(0, q);
|
||||
}
|
||||
if ("/health".equals(path)) {
|
||||
writeResponse(out, "200 OK", "text/plain", "ok".getBytes(StandardCharsets.UTF_8));
|
||||
return;
|
||||
}
|
||||
|
||||
String name = path.startsWith("/") ? path.substring(1) : path;
|
||||
File file = new File(BASE_DIR, name);
|
||||
// Защита от path traversal — файл должен лежать внутри BASE_DIR.
|
||||
// /sdcard — симлинк на /storage/emulated/0, поэтому сравниваем canonical
|
||||
// обеих сторон, а не с литералом "/sdcard".
|
||||
String baseCanon = new File(BASE_DIR).getCanonicalPath();
|
||||
String canon = file.getCanonicalPath();
|
||||
if (!canon.startsWith(baseCanon + "/") || !file.isFile()) {
|
||||
writeResponse(out, "404 Not Found", "text/plain", new byte[0]);
|
||||
return;
|
||||
}
|
||||
writeResponse(out, "200 OK", "image/png", readFile(file));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "handle error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readFile(File f) throws IOException {
|
||||
try (FileInputStream fis = new FileInputStream(f);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = fis.read(buf)) > 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeResponse(OutputStream out, String status, String ctype, byte[] body)
|
||||
throws IOException {
|
||||
String header = "HTTP/1.0 " + status + "\r\n"
|
||||
+ "Content-Type: " + ctype + "\r\n"
|
||||
+ "Content-Length: " + body.length + "\r\n"
|
||||
+ "Connection: close\r\n\r\n";
|
||||
out.write(header.getBytes(StandardCharsets.UTF_8));
|
||||
if (body.length > 0) {
|
||||
out.write(body);
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static void closeQuietly(ServerSocket s) {
|
||||
if (s != null) {
|
||||
try {
|
||||
s.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java
Normal file
19
apk/arc-shot/src/main/java/com/arc/shot/BootReceiver.java
Normal file
@ -0,0 +1,19 @@
|
||||
package com.arc.shot;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
/** Поднимает arc-shot после перезагрузки телефона. */
|
||||
public class BootReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context ctx, Intent intent) {
|
||||
Intent i = new Intent(ctx, ArcShotService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(i);
|
||||
} else {
|
||||
ctx.startService(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java
Normal file
25
apk/arc-shot/src/main/java/com/arc/shot/MainActivity.java
Normal file
@ -0,0 +1,25 @@
|
||||
package com.arc.shot;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
/**
|
||||
* Служебное Activity без UI: при запуске поднимает foreground-сервис и закрывается.
|
||||
* Нужно, чтобы у приложения был launcher-значок и чтобы сервис можно было поднять
|
||||
* через `adb shell am start -n com.arc.shot/.MainActivity` или тапом.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Intent i = new Intent(this, ArcShotService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(i);
|
||||
} else {
|
||||
startService(i);
|
||||
}
|
||||
finish();
|
||||
}
|
||||
}
|
||||
@ -1,80 +0,0 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
|
||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout"
|
||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
|
||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false" checked="false"
|
||||
clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
|
||||
long-clickable="false" password="false" selected="false" bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="" class="android.widget.ScrollView"
|
||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
|
||||
checked="false" clickable="false" enabled="true" focusable="true" focused="false"
|
||||
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||
bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_singleton"
|
||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false" password="false"
|
||||
selected="false" bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/grant_dialog"
|
||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[18,532][702,1060]">
|
||||
<node index="0" text="" resource-id="com.android.permissioncontroller:id/content_container"
|
||||
class="android.widget.LinearLayout" package="com.google.android.permissioncontroller"
|
||||
content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[48,562][672,816]">
|
||||
<node index="0" text="" resource-id="" class="android.widget.LinearLayout"
|
||||
package="com.google.android.permissioncontroller" content-desc=""
|
||||
checkable="false" checked="false" clickable="false" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[93,607][627,771]">
|
||||
<node index="0" text=""
|
||||
resource-id="com.android.permissioncontroller:id/permission_icon"
|
||||
class="android.widget.ImageView"
|
||||
package="com.google.android.permissioncontroller" content-desc=""
|
||||
checkable="false" checked="false" clickable="false" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[337,607][382,652]"/>
|
||||
<node index="1" text="Allow Альфа-Банк to access your contacts?"
|
||||
resource-id="com.android.permissioncontroller:id/permission_message"
|
||||
class="android.widget.TextView"
|
||||
package="com.google.android.permissioncontroller" content-desc=""
|
||||
checkable="false" checked="false" clickable="false" enabled="true"
|
||||
focusable="false" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[93,675][627,771]"/>
|
||||
</node>
|
||||
</node>
|
||||
<node index="1" text="" resource-id="" class="android.widget.LinearLayout"
|
||||
package="com.google.android.permissioncontroller" content-desc="" checkable="false"
|
||||
checked="false" clickable="false" enabled="true" focusable="false" focused="false"
|
||||
scrollable="false" long-clickable="false" password="false" selected="false"
|
||||
bounds="[48,818][672,1030]">
|
||||
<node index="0" text="ALLOW"
|
||||
resource-id="com.android.permissioncontroller:id/permission_allow_button"
|
||||
class="android.widget.Button" package="com.google.android.permissioncontroller"
|
||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
||||
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[48,818][672,923]"/>
|
||||
<node index="1" text="DON’T ALLOW"
|
||||
resource-id="com.android.permissioncontroller:id/permission_deny_button"
|
||||
class="android.widget.Button" package="com.google.android.permissioncontroller"
|
||||
content-desc="" checkable="false" checked="false" clickable="true" enabled="true"
|
||||
focusable="true" focused="false" scrollable="false" long-clickable="false"
|
||||
password="false" selected="false" bounds="[48,925][672,1030]"/>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</node>
|
||||
</hierarchy>
|
||||
BIN
assets/arc-shot.apk
Normal file
BIN
assets/arc-shot.apk
Normal file
Binary file not shown.
10
config.ini
10
config.ini
@ -34,6 +34,16 @@ manifestUrl=http://142.93.102.40/updates/test/version.json
|
||||
manifestUrlTest=http://142.93.102.40/updates/test/version.json
|
||||
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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
82
database/dao/WifiConnectionDAO.cpp
Normal file
82
database/dao/WifiConnectionDAO.cpp
Normal file
@ -0,0 +1,82 @@
|
||||
#include "WifiConnectionDAO.h"
|
||||
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlError>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
|
||||
#include "DatabaseManager.h"
|
||||
|
||||
namespace {
|
||||
WifiConnection parseRow(const QSqlQuery &query) {
|
||||
WifiConnection wc;
|
||||
wc.androidId = query.value("android_id").toString();
|
||||
wc.ip = query.value("ip").toString();
|
||||
wc.port = query.value("port").toInt();
|
||||
wc.code = query.value("code").toString();
|
||||
wc.updatedAt = query.value("updated_at").toString();
|
||||
return wc;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port,
|
||||
const QString &code) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
INSERT INTO wifi_connections (android_id, ip, port, code, updated_at)
|
||||
VALUES (:android_id, :ip, :port, :code, :updated_at)
|
||||
ON CONFLICT(android_id) DO UPDATE SET
|
||||
ip = excluded.ip,
|
||||
port = excluded.port,
|
||||
code = excluded.code,
|
||||
updated_at = excluded.updated_at
|
||||
)");
|
||||
query.bindValue(":android_id", androidId);
|
||||
query.bindValue(":ip", ip);
|
||||
query.bindValue(":port", port);
|
||||
query.bindValue(":code", code);
|
||||
query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||
if (!query.exec()) {
|
||||
qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
WifiConnection WifiConnectionDAO::getByAndroidId(const QString &androidId) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare("SELECT * FROM wifi_connections WHERE android_id = :android_id LIMIT 1");
|
||||
query.bindValue(":android_id", androidId);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "WifiConnectionDAO::getByAndroidId error:" << query.lastError().text();
|
||||
return {};
|
||||
}
|
||||
if (query.next()) {
|
||||
return parseRow(query);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QList<WifiConnection> WifiConnectionDAO::getAll() {
|
||||
QList<WifiConnection> result;
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
if (!query.exec("SELECT * FROM wifi_connections")) {
|
||||
qWarning() << "WifiConnectionDAO::getAll error:" << query.lastError().text();
|
||||
return result;
|
||||
}
|
||||
while (query.next()) {
|
||||
result.append(parseRow(query));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool WifiConnectionDAO::remove(const QString &androidId) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare("DELETE FROM wifi_connections WHERE android_id = :android_id");
|
||||
query.bindValue(":android_id", androidId);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "WifiConnectionDAO::remove error:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
32
database/dao/WifiConnectionDAO.h
Normal file
32
database/dao/WifiConnectionDAO.h
Normal file
@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QList>
|
||||
|
||||
// Реквизиты подключения устройства по Wi-Fi. Связь с устройством — по android_id
|
||||
// (= devices.id), т.е. одна запись на физический аппарат, независимо от транспорта.
|
||||
struct WifiConnection {
|
||||
QString androidId; // = devices.id
|
||||
QString ip; // последний известный IP телефона в Wi-Fi
|
||||
int port = 0; // порт для adb connect (5555 для legacy tcpip)
|
||||
QString code; // одноразовый pairing-код (пусто для legacy tcpip)
|
||||
QString updatedAt; // ISO-время последнего обновления
|
||||
};
|
||||
|
||||
// DAO таблицы wifi_connections. Задел под будущий авто-реконнект по Wi-Fi:
|
||||
// хранит ip/port/code per-device, чтобы переподключать устройство без повторного
|
||||
// поиска. В текущей итерации — только хранилище (воркер реконнекта отложен).
|
||||
class WifiConnectionDAO {
|
||||
public:
|
||||
// Вставка/обновление по android_id (PRIMARY KEY).
|
||||
static bool upsert(const QString &androidId, const QString &ip, int port,
|
||||
const QString &code = {});
|
||||
|
||||
// Запись по android_id (пустой androidId в результате → не найдено).
|
||||
[[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId);
|
||||
|
||||
// Все сохранённые реквизиты.
|
||||
[[nodiscard]] static QList<WifiConnection> getAll();
|
||||
|
||||
static bool remove(const QString &androidId);
|
||||
};
|
||||
@ -145,3 +145,15 @@ CREATE TABLE IF NOT EXISTS settings
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id).
|
||||
-- ip+port — для adb connect; code — одноразовый pairing-код (только для первого adb pair).
|
||||
CREATE TABLE IF NOT EXISTS wifi_connections
|
||||
(
|
||||
android_id TEXT PRIMARY KEY,
|
||||
ip TEXT,
|
||||
port INTEGER,
|
||||
code TEXT,
|
||||
updated_at DATETIME,
|
||||
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <dao/DeviceDAO.h>
|
||||
#include <dao/WifiConnectionDAO.h>
|
||||
#include <db/DeviceInfo.h>
|
||||
|
||||
#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<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" в текущем скане
|
||||
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<QString> 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<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
|
||||
QList<QPair<QString, QString> > 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<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (adbOk) *adbOk = true;
|
||||
const QString output = process.readAllStandardOutput();
|
||||
QList<QPair<QString, QString> > devices;
|
||||
const QStringList lines = output.split('\n');
|
||||
|
||||
@ -65,6 +65,7 @@ private:
|
||||
QMap<QString, DeviceInfo> m_staticInfoCache;
|
||||
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
|
||||
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
|
||||
QMap<QString, QString> m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль)
|
||||
|
||||
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
|
||||
// потоков). Не содержит ничего, что требует БД/членов класса.
|
||||
@ -77,7 +78,10 @@ private:
|
||||
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);
|
||||
|
||||
|
||||
@ -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 += " <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->setMaximumWidth(300);
|
||||
statusLabel->setStyleSheet(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user