1
0
forked from BRT/arc

Add ADB-over-Wi-Fi failover and fix screenshot/reconnect performance

This commit is contained in:
Kosh 2026-06-20 11:11:57 +05:00
parent 3c1db3c36c
commit 5ea0b42804
14 changed files with 906 additions and 26 deletions

View File

@ -38,6 +38,7 @@ cmake --build .
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт | | [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) | | [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса | | [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
| [docs/wifi-adb.md](docs/wifi-adb.md) | Автоматический ADB-over-Wi-Fi: USB-приоритет, Wi-Fi как горячий резерв |
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) | | [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon | | [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |

View File

@ -540,7 +540,10 @@ QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) {
<< "error:" << process.errorString(); << "error:" << process.errorString();
return {}; return {};
} }
if (!process.waitForFinished(10000) || process.exitCode() != 0) { // По Wi-Fi (ip:port) exec-out screencap стримит PNG через сеть медленнее, чем по
// USB — даём больше времени, иначе кадр обрывается «пустым». По USB оставляем 10с.
const int timeoutMs = isWirelessSerial(deviceId) ? 12000 : 10000;
if (!process.waitForFinished(timeoutMs) || process.exitCode() != 0) {
qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId; qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId;
return {}; return {};
} }
@ -674,15 +677,20 @@ void AdbUtils::kickArcShot(const QString &deviceId) {
qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed(); qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed();
return; 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; qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId;
} }
// 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде // 3a. Доступ к /sdcard — БЕЗУСЛОВНО (а не только при установке): файл скриншота
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор // создаёт adb screencap как root:everybody (0660), и без MANAGE_EXTERNAL_STORAGE
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает. // сервис не может его прочитать — readFile() кидает IOException, соединение
// закрывается без HTTP-ответа (/health при этом отвечает). Для уже установленного
// arc-shot грант раньше не выполнялся — отсюда вечный фейл http-скриншота.
startProcess(adb, {"-s", deviceId, "shell", "appops", "set",
"com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"});
// 3b. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде
// TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор
// замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает.
startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist", startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist",
"+com.arc.shot"}); "+com.arc.shot"});
startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot", startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot",
@ -1121,3 +1129,159 @@ void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, cons
qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId; qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId;
} }
} }
// --- ADB через Wi-Fi (TCP/IP) ---
bool AdbUtils::isWirelessSerial(const QString &serial) {
// Сетевой endpoint вида "192.168.1.50:5555". USB-серийники — hex без точек,
// эмулятор — "emulator-5554" (есть дефис, нет точек/двоеточия с ip).
static const QRegularExpression re(R"(^\d{1,3}(\.\d{1,3}){3}:\d+$)");
return re.match(serial).hasMatch();
}
QString AdbUtils::getDeviceWifiIp(const QString &serial) {
if (serial.isEmpty()) {
return {};
}
const QString adb = adbPath();
// Основной путь: ip-адрес интерфейса wlan0.
// inet 192.168.1.50/24 brd ... scope global wlan0
QString out;
if (startProcess(adb, {"-s", serial, "shell", "ip", "-f", "inet", "addr", "show", "wlan0"}, &out)) {
static const QRegularExpression reInet(R"(inet\s+(\d{1,3}(?:\.\d{1,3}){3}))");
if (const auto m = reInet.match(out); m.hasMatch()) {
return m.captured(1);
}
}
// Фолбэк: маршрут по умолчанию через wlan0 (часть прошивок не отдаёт `ip addr`).
// 192.168.1.0/24 dev wlan0 ... src 192.168.1.50
out.clear();
if (startProcess(adb, {"-s", serial, "shell", "ip", "route"}, &out)) {
static const QRegularExpression reSrc(R"(wlan0[^\n]*\bsrc\s+(\d{1,3}(?:\.\d{1,3}){3}))");
if (const auto m = reSrc.match(out); m.hasMatch()) {
return m.captured(1);
}
}
return {};
}
QString AdbUtils::getDeviceMac(const QString &serial) {
if (serial.isEmpty()) {
return {};
}
const QString adb = adbPath();
// Основной путь: `ip addr show wlan0` — берём строку link/ether. Это та же команда,
// что и для IP в getDeviceWifiIp, и она проходит без root, включая MIUI/HyperOS
// (где `cat /sys/.../address` и `ip link show` отдают Permission denied).
// Прим.: на Android 10+ это обычно рандомизированный per-SSID MAC, но он стабилен
// для данной сети и именно его видит ARP-таблица ПК — что нам и нужно для поиска.
QString out;
if (startProcess(adb, {"-s", serial, "shell", "ip", "addr", "show", "wlan0"}, &out)) {
static const QRegularExpression reEther(
R"(link/ether\s+([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}))");
if (const auto m = reEther.match(out); m.hasMatch()) {
return m.captured(1).toLower();
}
}
// Фолбэк: `cmd wifi status` → строка "MAC: aa:bb:cc:dd:ee:ff".
out.clear();
if (startProcess(adb, {"-s", serial, "shell", "cmd", "wifi", "status"}, &out)) {
static const QRegularExpression reWifiMac(
R"(MAC:\s*([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}))");
if (const auto m = reWifiMac.match(out); m.hasMatch()) {
return m.captured(1).toLower();
}
}
// Старые методы не проходят без root на MIUI (Permission denied), оставлены в
// истории: cat /sys/class/net/wlan0/address ; ip link show wlan0
return {};
}
bool AdbUtils::enableTcpip(const QString &serial, const int port) {
if (serial.isEmpty() || port <= 0) {
return false;
}
QString out, err;
// adb tcpip перезапускает adbd на устройстве в TCP-режиме. Печатает
// "restarting in TCP mode port: <port>". Иногда возвращает ненулевой код,
// хотя режим переключён — поэтому считаем успехом и exit 0, и наличие маркера.
const bool ok = startProcess(adbPath(), {"-s", serial, "tcpip", QString::number(port)}, &out, &err);
const bool restarted = out.contains("restarting", Qt::CaseInsensitive)
|| err.contains("restarting", Qt::CaseInsensitive);
if (!ok && !restarted) {
qWarning() << "[AdbUtils] enableTcpip failed for" << serial << "port" << port
<< "out:" << out.trimmed() << "err:" << err.trimmed();
return false;
}
qInfo() << "[AdbUtils] enableTcpip ok for" << serial << "port" << port << ":" << out.trimmed();
// adbd перезапускается не мгновенно — даём ему подняться перед adb connect.
QThread::msleep(1500);
return true;
}
bool AdbUtils::connectWireless(const QString &endpoint) {
if (endpoint.isEmpty()) {
return false;
}
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {"connect", endpoint});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] connectWireless failed to start for" << endpoint
<< "error:" << process.errorString();
return false;
}
waitOrKill(process, 10000, "connectWireless");
const QString out = QString::fromUtf8(process.readAllStandardOutput()).trimmed();
// Текст вывода — лишь предварительный фильтр: явный провал ("cannot connect ...",
// "failed to connect ...") отсекаем сразу. В остальных случаях (включая лукавое
// "already connected to ...") подтверждаем успех ПО ФАКТУ: эндпоинт должен реально
// перейти в статус "device" в `adb devices`. Это лечит ложный успех на залипшем
// offline-эндпоинте — голый connect его не поднимает, и теперь мы это видим.
if (out.contains("cannot", Qt::CaseInsensitive)
|| out.contains("failed", Qt::CaseInsensitive)) {
qWarning() << "[AdbUtils] connectWireless" << endpoint << "" << out;
return false;
}
// Эндпоинту даём несколько секунд «дозреть» (offline/connecting → device),
// опрашивая adb devices. Здоровый эндпоинт пройдёт уже на первой итерации.
for (int attempt = 0; attempt < 4; ++attempt) {
for (const auto &[serial, status] : getListDevices()) {
if (serial != endpoint) {
continue;
}
if (status == "device") {
qInfo() << "[AdbUtils] connectWireless" << endpoint << "→ device (" << out << ")";
return true;
}
break; // эндпоинт есть, но ещё offline/connecting — ждём следующую итерацию
}
QThread::msleep(800);
}
qWarning() << "[AdbUtils] connectWireless" << endpoint
<< "→ не перешёл в 'device' после connect (" << out << ")";
return false;
}
bool AdbUtils::disconnectWireless(const QString &endpoint) {
if (endpoint.isEmpty()) {
return false;
}
QProcess process;
process.setWorkingDirectory(adbDir());
process.start(adbPath(), {"disconnect", endpoint});
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] disconnectWireless failed to start for" << endpoint
<< "error:" << process.errorString();
return false;
}
waitOrKill(process, 10000, "disconnectWireless");
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
}

View File

@ -86,6 +86,34 @@ public:
// экран в портрете (изначально или после форсирования). // экран в портрете (изначально или после форсирования).
static bool ensurePortraitOrientation(const QString &deviceId); static bool ensurePortraitOrientation(const QString &deviceId);
// --- ADB через Wi-Fi (TCP/IP) ---
// Эвристика: serial выглядит как сетевой endpoint "ip:port" (Wi-Fi-подключение),
// а не USB-серийник (hex) или emulator-XXXX.
static bool isWirelessSerial(const QString &serial);
// Возвращает IPv4-адрес устройства в сети Wi-Fi (интерфейс wlan0), либо пустую
// строку, если устройство не в Wi-Fi / адрес не прочитался.
static QString getDeviceWifiIp(const QString &serial);
// Возвращает MAC-адрес интерфейса wlan0 в нормализованном виде (lower-case,
// разделитель ':'), либо пустую строку. Нужен, чтобы находить устройство в сети
// после смены IP по DHCP (поиск по ARP-таблице).
static QString getDeviceMac(const QString &serial);
// Переводит adbd устройства в TCP-режим на указанном порту (adb -s <serial> tcpip <port>).
// Вызывается на устройстве, подключённом по USB. ВНИМАНИЕ: перезапускает adbd и
// кратковременно рвёт текущее соединение — запускать только на простаивающем устройстве.
static bool enableTcpip(const QString &serial, int port);
// adb connect <endpoint> (endpoint вида "192.168.1.50:5555").
// Возвращает true при "connected"/"already connected". Exit-code adb недостаточен —
// adb connect возвращает 0 даже при "failed to connect", поэтому разбираем stdout.
static bool connectWireless(const QString &endpoint);
// adb disconnect <endpoint>.
static bool disconnectWireless(const QString &endpoint);
private: private:
static QString adbDir(); static QString adbDir();
static bool startProcess(const QString &program, const QStringList &args, static bool startProcess(const QString &program, const QStringList &args,

View File

@ -0,0 +1,299 @@
#include "WifiAdbManager.h"
#include <QProcess>
#include <QRegularExpression>
#include <QtConcurrent>
#include <QTcpSocket>
#include <QDateTime>
#include <QHash>
#include <QDebug>
#include "adb/AdbUtils.h"
#include "dao/WifiConnectionDAO.h"
namespace {
// Быстрая TCP-проба достижимости эндпоинта МИМО adb: `adb connect` к мёртвому хосту
// висит до 10с, поэтому сперва открываем сокет с коротким таймаутом. Без event-loop —
// безопасно из фонового потока (как AdbUtils::httpGet).
constexpr int kReachProbeMs = 600;
bool tcpReachable(const QString &ip, int port, int timeoutMs) {
if (ip.isEmpty() || port <= 0) {
return false;
}
QTcpSocket sock;
sock.connectToHost(ip, static_cast<quint16>(port));
const bool ok = sock.waitForConnected(timeoutMs);
sock.abort();
return ok;
}
// Пер-эндпоинт backoff: офлайн-устройство не долбим каждый проход 10-секундными
// connect'ами. Задержка растёт 30→60→120→240→300с (потолок) по числу неудач подряд,
// сбрасывается при успехе/онлайне. Состояние живёт только в потоке DeviceScreener
// (reconnectKnown/kickEndpoint зовутся лишь оттуда) — без мьютекса.
constexpr qint64 kBackoffBaseSecs = 30;
constexpr qint64 kBackoffMaxSecs = 300;
QHash<QString, int> g_failCount; // androidId → неудач подряд
QHash<QString, qint64> g_nextRetryAt; // androidId → unix ts, до которого пропускаем
void noteFailure(const QString &androidId) {
const int n = ++g_failCount[androidId];
const qint64 delay = qMin(kBackoffBaseSecs * (1LL << qMin(n - 1, 4)), kBackoffMaxSecs);
g_nextRetryAt[androidId] = QDateTime::currentSecsSinceEpoch() + delay;
}
void noteSuccess(const QString &androidId) {
g_failCount.remove(androidId);
g_nextRetryAt.remove(androidId);
}
} // namespace
QMap<QString, WifiEndpoint> WifiAdbManager::loadEndpoints() {
QMap<QString, WifiEndpoint> result;
for (const WifiConnection &wc : WifiConnectionDAO::getAll()) {
if (wc.androidId.isEmpty() || wc.ip.isEmpty() || wc.port <= 0) {
continue;
}
WifiEndpoint ep;
ep.endpoint = wc.ip + ":" + QString::number(wc.port);
ep.mac = wc.mac;
result.insert(wc.androidId, ep);
}
return result;
}
void WifiAdbManager::saveEndpoint(const QString &androidId, const QString &endpoint, const QString &mac) {
if (androidId.isEmpty() || endpoint.isEmpty()) {
return;
}
const WifiConnection existing = WifiConnectionDAO::getByAndroidId(androidId);
// MAC мог не прочитаться при этом вызове — тогда не затираем уже сохранённый.
const QString mergedMac = !mac.isEmpty() ? mac : existing.mac;
const QString ip = ipOf(endpoint);
const QString port = portOf(endpoint);
if (ip.isEmpty() || port.isEmpty()) {
return;
}
const QString existingEndpoint = existing.ip.isEmpty()
? QString()
: existing.ip + ":" + QString::number(existing.port);
if (existingEndpoint == endpoint && existing.mac == mergedMac) {
return; // ничего не изменилось — не дёргаем БД
}
// code не трогаем (задел под pairing) — WifiAdbManager его не использует, пишем "".
if (WifiConnectionDAO::upsert(androidId, ip, port.toInt(), {}, mergedMac)) {
qInfo() << "[WifiAdbManager] saved endpoint" << endpoint << "mac" << mergedMac
<< "for device" << androidId;
}
}
int WifiAdbManager::reconnectKnown(const ReconnectOptions &opts) {
const QMap<QString, WifiEndpoint> endpoints = loadEndpoints();
const qint64 now = QDateTime::currentSecsSinceEpoch();
int connected = 0;
for (auto it = endpoints.constBegin(); it != endpoints.constEnd(); ++it) {
const QString &androidId = it.key();
const WifiEndpoint &ep = it.value();
// Устройство уже онлайн (по USB или по этому же Wi-Fi-эндпоинту) — не дёргаем,
// сбрасываем счётчик неудач (вернулось — backoff больше не нужен).
if (opts.alreadyOnline.contains(androidId)) {
noteSuccess(androidId);
continue;
}
// п.2 — пер-эндпоинт backoff: мёртвый девайс не долбим каждый проход.
if (now < g_nextRetryAt.value(androidId, 0)) {
continue;
}
// п.1 — быстрая TCP-проба сохранённого ip:port вместо 10-секундного adb connect
// к мёртвому хосту. Достижим → пробуем реальный connect (с disconnect для stale).
bool ok = false;
if (tcpReachable(ipOf(ep.endpoint), portOf(ep.endpoint).toInt(), kReachProbeMs)) {
// Протухший offline-эндпоинт: adb держит запись, но канал мёртв. Голый connect
// вернул бы "already connected" — сначала disconnect, затем чистый connect.
if (opts.staleEndpoints.contains(ep.endpoint)) {
AdbUtils::disconnectWireless(ep.endpoint);
}
ok = AdbUtils::connectWireless(ep.endpoint);
}
// Сохранённый IP недостижим/connect не поднялся — возможно, адрес сменился по DHCP.
// Если знаем MAC, ищем устройство в сети по нему и переподключаемся на новый IP.
if (!ok && opts.macRediscover && !ep.mac.isEmpty()) {
const QString newIp = findIpByMac(ep.mac, ipOf(ep.endpoint), opts.subnetSweep);
if (!newIp.isEmpty()) {
const QString newEndpoint = newIp + ":" + portOf(ep.endpoint);
if (newEndpoint != ep.endpoint
&& tcpReachable(newIp, portOf(ep.endpoint).toInt(), kReachProbeMs)
&& AdbUtils::connectWireless(newEndpoint)) {
saveEndpoint(androidId, newEndpoint, ep.mac);
qInfo() << "[WifiAdbManager] device" << androidId << "moved"
<< ep.endpoint << "" << newEndpoint << "(found by MAC)";
ok = true;
}
}
}
if (ok) {
noteSuccess(androidId);
++connected;
} else {
// Неудача не критична (устройство выключено / потеряло tcpip после ребута):
// эндпоинт оставляем, наращиваем backoff, чтобы не блокировать цикл опроса.
noteFailure(androidId);
}
}
return connected;
}
bool WifiAdbManager::kickEndpoint(const QString &androidId) {
if (androidId.isEmpty()) {
return false;
}
const WifiConnection wc = WifiConnectionDAO::getByAndroidId(androidId);
if (wc.ip.isEmpty() || wc.port <= 0) {
return false; // нет Wi-Fi-эндпоинта — офлайн не по нашей части (например, USB)
}
const QString endpoint = wc.ip + ":" + QString::number(wc.port);
// Быстрая проба (п.1): если порт недостижим — пересоздавать нечего, не вешаем цикл
// на 10с adb disconnect/connect. У «залипшего» эндпоинта adbd-порт обычно ещё слушает.
if (!tcpReachable(wc.ip, wc.port, kReachProbeMs)) {
return false;
}
// Полумёртвое соединение adb держит как "device", но команды виснут; голый connect
// вернул бы "already connected" и не вылечил. Принудительно пересоздаём TCP.
AdbUtils::disconnectWireless(endpoint);
const bool ok = AdbUtils::connectWireless(endpoint);
qInfo() << "[WifiAdbManager] kicked wedged endpoint" << endpoint
<< "for device" << androidId << "" << (ok ? "reconnected" : "still down");
return ok;
}
QString WifiAdbManager::tryBootstrap(const QString &usbSerial, const QString &androidId, const int port) {
if (usbSerial.isEmpty() || androidId.isEmpty() || port <= 0) {
return {};
}
// Сначала узнаём IP по Wi-Fi: если устройство не в Wi-Fi — нет смысла дёргать
// tcpip (он бы зря порвал adbd). Делаем это ДО tcpip.
const QString ip = AdbUtils::getDeviceWifiIp(usbSerial);
if (ip.isEmpty()) {
qInfo() << "[WifiAdbManager] device" << androidId << "(" << usbSerial
<< ") has no wlan0 IP — skip Wi-Fi bootstrap";
return {};
}
const QString endpoint = ip + ":" + QString::number(port);
// MAC снимаем по USB (до tcpip) — пригодится для поиска устройства при смене IP.
const QString mac = AdbUtils::getDeviceMac(usbSerial);
// Переключаем adbd в TCP-режим (рвёт текущее соединение — вызывающий гарантирует,
// что устройство простаивает) и подключаемся по сети.
if (!AdbUtils::enableTcpip(usbSerial, port)) {
return {};
}
if (!AdbUtils::connectWireless(endpoint)) {
return {};
}
saveEndpoint(androidId, endpoint, mac);
qInfo() << "[WifiAdbManager] Wi-Fi endpoint up for device" << androidId << "" << endpoint;
return endpoint;
}
void WifiAdbManager::forget(const QString &androidId) {
if (androidId.isEmpty()) {
return;
}
const WifiConnection wc = WifiConnectionDAO::getByAndroidId(androidId);
if (wc.ip.isEmpty() || wc.port <= 0) {
return;
}
AdbUtils::disconnectWireless(wc.ip + ":" + QString::number(wc.port));
WifiConnectionDAO::remove(androidId);
qInfo() << "[WifiAdbManager] forgot Wi-Fi endpoint for device" << androidId;
}
QString WifiAdbManager::findIpByMac(const QString &mac, const QString &subnetHintIp, const bool allowSweep) {
const QString norm = normalizeMac(mac);
if (norm.isEmpty()) {
return {};
}
// Быстрый путь: устройство уже есть в ARP-таблице ОС (недавно общались).
QString ip = arpLookupIp(norm);
if (!ip.isEmpty()) {
return ip;
}
// ARP-таблица не содержит устройство — опционально прогреваем её ping-сканом
// подсети /24 (медленно и шумно, поэтому только по явному флагу), затем повторяем.
if (allowSweep && !subnetHintIp.isEmpty()) {
pingSweepSubnet(subnetHintIp);
ip = arpLookupIp(norm);
}
return ip;
}
QString WifiAdbManager::arpLookupIp(const QString &normalizedMac) {
QProcess arp;
arp.start("arp", {"-a"});
if (!arp.waitForStarted(3000)) {
qWarning() << "[WifiAdbManager] failed to start arp:" << arp.errorString();
return {};
}
arp.waitForFinished(8000);
const QString out = QString::fromLocal8Bit(arp.readAllStandardOutput());
// Строки вида (Windows): " 192.168.1.50 aa-bb-cc-dd-ee-ff dynamic"
// (unix): "? (192.168.1.50) at aa:bb:cc:dd:ee:ff [ether] on en0"
static const QRegularExpression reRow(
R"((\d{1,3}(?:\.\d{1,3}){3})\D+?([0-9a-fA-F]{2}(?:[-:][0-9a-fA-F]{2}){5}))");
auto it = reRow.globalMatch(out);
while (it.hasNext()) {
const auto m = it.next();
if (normalizeMac(m.captured(2)) == normalizedMac) {
return m.captured(1);
}
}
return {};
}
void WifiAdbManager::pingSweepSubnet(const QString &subnetHintIp) {
// /24 из hint-IP: 192.168.1.X → пинги по 192.168.1.{1..254}.
const int lastDot = subnetHintIp.lastIndexOf('.');
if (lastDot <= 0) {
return;
}
const QString prefix = subnetHintIp.left(lastDot + 1);
QStringList hosts;
for (int i = 1; i <= 254; ++i) {
hosts << prefix + QString::number(i);
}
qInfo() << "[WifiAdbManager] ARP warm-up: ping sweep of" << prefix + "0/24";
// Параллельно (глобальный пул QtConcurrent), чтобы не блокировать цикл надолго.
// Ответ нам не важен — нужно лишь, чтобы ОС записала MAC соседей в ARP-таблицу.
QtConcurrent::blockingMap(hosts, [](const QString &host) {
QProcess p;
#ifdef Q_OS_WIN
p.start("ping", {"-n", "1", "-w", "300", host});
#else
p.start("ping", {"-c", "1", "-W", "1", host});
#endif
if (p.waitForStarted(2000)) {
p.waitForFinished(2000);
}
});
}
QString WifiAdbManager::normalizeMac(const QString &mac) {
QString m = mac.trimmed().toLower();
m.replace('-', ':');
return m;
}
QString WifiAdbManager::ipOf(const QString &endpoint) {
const int c = endpoint.lastIndexOf(':');
return c > 0 ? endpoint.left(c) : endpoint;
}
QString WifiAdbManager::portOf(const QString &endpoint) {
const int c = endpoint.lastIndexOf(':');
return c > 0 ? endpoint.mid(c + 1) : QStringLiteral("5555");
}

View File

@ -0,0 +1,72 @@
#pragma once
#include <QMap>
#include <QSet>
#include <QString>
// Автоматическое поднятие/восстановление беспроводного (Wi-Fi, ADB over TCP/IP)
// подключения к Android-устройствам. Без участия оператора: USB остаётся основным
// транспортом, Wi-Fi — горячий резерв, который подхватывается при отключении кабеля.
//
// Сохранённые эндпоинты хранятся в реляционной таблице wifi_connections
// (WifiConnectionDAO), по строке на android_id: ip/port (= endpoint "ip:port") + mac.
// MAC нужен, чтобы найти устройство после смены IP по DHCP (поиск в ARP-таблице).
// WifiAdbManager — единственный владелец записи в этой таблице.
//
// Все методы статические и блокирующие (дёргают adb/arp/ping через QProcess) —
// вызывать из фонового потока (DeviceScreener), не из GUI-потока.
// Сохранённый беспроводной эндпоинт устройства.
struct WifiEndpoint {
QString endpoint; // "ip:port"
QString mac; // нормализованный MAC (lower-case, разделитель ':'); может быть пустым
};
class WifiAdbManager {
public:
// Параметры одного прохода переподключения сохранённых эндпоинтов.
struct ReconnectOptions {
QSet<QString> alreadyOnline; // android_id, уже видимые онлайн — пропускаем
QSet<QString> staleEndpoints; // "ip:port" в статусе offline — disconnect перед connect
bool macRediscover = false; // при неудаче искать новый IP устройства по MAC
bool subnetSweep = false; // разрешить ping-скан подсети для прогрева ARP (медленно/шумно)
};
// Сохранённые эндпоинты: android_id → WifiEndpoint.
static QMap<QString, WifiEndpoint> loadEndpoints();
// Пытается `adb connect` по всем сохранённым эндпоинтам (для устройств, которых
// ещё нет онлайн). Возвращает число успешно подключённых.
static int reconnectKnown(const ReconnectOptions &opts);
// Принудительно пересоздаёт TCP-соединение Wi-Fi-эндпоинта устройства
// (disconnect → connect). Нужен, когда соединение «залипло» в полумёртвом
// состоянии: adb держит эндпоинт как "device", но команды к нему виснут (устройство
// уходит в debounced-offline, при этом в `adb devices` НЕ появляется статус
// "offline"). Голый connect в этом случае вернул бы "already connected" и не помог.
// No-op, если у устройства нет сохранённого Wi-Fi-эндпоинта (например, офлайн по USB).
// Возвращает true, если после переподключения соединение поднялось.
static bool kickEndpoint(const QString &androidId);
// Bootstrap беспроводного эндпоинта для устройства, подключённого по USB:
// снять MAC → tcpip <port> → определить wlan0 IP → adb connect → сохранить эндпоинт.
// ВНИМАНИЕ: tcpip рвёт adbd — вызывать только для простаивающего устройства.
// Возвращает "ip:port" при успехе, пустую строку при неудаче.
static QString tryBootstrap(const QString &usbSerial, const QString &androidId, int port);
// Удаляет сохранённый эндпоинт и делает adb disconnect (best-effort).
static void forget(const QString &androidId);
private:
static void saveEndpoint(const QString &androidId, const QString &endpoint, const QString &mac);
// Поиск текущего IP устройства по известному MAC (после смены IP по DHCP).
// subnetHintIp — последний известный IP (для определения /24 при ping-скане).
static QString findIpByMac(const QString &mac, const QString &subnetHintIp, bool allowSweep);
static QString arpLookupIp(const QString &normalizedMac);
static void pingSweepSubnet(const QString &subnetHintIp);
static QString normalizeMac(const QString &mac);
static QString ipOf(const QString &endpoint);
static QString portOf(const QString &endpoint);
};

View File

@ -44,6 +44,26 @@ remotePath=/sdcard/arc_shot.png
previewIntervalSecs=4 previewIntervalSecs=4
previewIntervalHttpSecs=2 previewIntervalHttpSecs=2
[wifi]
# Автоматический ADB-over-Wi-Fi: USB — основной транспорт, Wi-Fi — горячий резерв.
# enabled=false полностью отключает поднятие/переподключение беспроводных эндпоинтов.
enabled=true
# TCP-порт adbd для tcpip-режима (стандартный 5555).
port=5555
# Как часто (сек) переподключать сохранённые Wi-Fi-эндпоинты для устройств не онлайн.
reconnectIntervalSecs=30
# Минимальный интервал (сек) между попытками bootstrap (tcpip) одного устройства —
# чтобы не дёргать tcpip на устройстве без Wi-Fi / где прошивка сбрасывает режим.
bootstrapRetryIntervalSecs=120
# Искать устройство по MAC в ARP-таблице, если connect по сохранённому IP не удался
# (адрес сменился по DHCP). MAC снимается при bootstrap.
macRediscoverEnabled=true
# Минимальный интервал (сек) между попытками поиска по MAC (операция дороже connect).
macRediscoverIntervalSecs=300
# Прогревать ARP-таблицу ping-сканом подсети /24, если устройства нет в ARP. Медленно
# и шумно — по умолчанию выключено; включать только в выделенной сети фермы.
subnetSweepEnabled=false
[black] [black]
android_package_name=app.blackwallet.ar android_package_name=app.blackwallet.ar
currency=ARS currency=ARS

View File

@ -267,9 +267,14 @@ void DatabaseManager::initSchema() {
ip TEXT, ip TEXT,
port INTEGER, port INTEGER,
code TEXT, code TEXT,
mac TEXT,
updated_at DATETIME, updated_at DATETIME,
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
))"); ))");
// Дозаливка колонок для БД, созданных более ранней версией схемы (ошибка
// "duplicate column" игнорируется — колонка уже есть на новых установках).
q.exec("ALTER TABLE wifi_connections ADD COLUMN code TEXT");
q.exec("ALTER TABLE wifi_connections ADD COLUMN mac TEXT");
db.commit(); db.commit();
} }

View File

@ -14,27 +14,30 @@ WifiConnection parseRow(const QSqlQuery &query) {
wc.ip = query.value("ip").toString(); wc.ip = query.value("ip").toString();
wc.port = query.value("port").toInt(); wc.port = query.value("port").toInt();
wc.code = query.value("code").toString(); wc.code = query.value("code").toString();
wc.mac = query.value("mac").toString();
wc.updatedAt = query.value("updated_at").toString(); wc.updatedAt = query.value("updated_at").toString();
return wc; return wc;
} }
} // namespace } // namespace
bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port, bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port,
const QString &code) { const QString &code, const QString &mac) {
QSqlQuery query(DatabaseManager::instance().database()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(
INSERT INTO wifi_connections (android_id, ip, port, code, updated_at) INSERT INTO wifi_connections (android_id, ip, port, code, mac, updated_at)
VALUES (:android_id, :ip, :port, :code, :updated_at) VALUES (:android_id, :ip, :port, :code, :mac, :updated_at)
ON CONFLICT(android_id) DO UPDATE SET ON CONFLICT(android_id) DO UPDATE SET
ip = excluded.ip, ip = excluded.ip,
port = excluded.port, port = excluded.port,
code = excluded.code, code = excluded.code,
mac = excluded.mac,
updated_at = excluded.updated_at updated_at = excluded.updated_at
)"); )");
query.bindValue(":android_id", androidId); query.bindValue(":android_id", androidId);
query.bindValue(":ip", ip); query.bindValue(":ip", ip);
query.bindValue(":port", port); query.bindValue(":port", port);
query.bindValue(":code", code); query.bindValue(":code", code);
query.bindValue(":mac", mac);
query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
if (!query.exec()) { if (!query.exec()) {
qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text(); qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text();

View File

@ -10,6 +10,7 @@ struct WifiConnection {
QString ip; // последний известный IP телефона в Wi-Fi QString ip; // последний известный IP телефона в Wi-Fi
int port = 0; // порт для adb connect (5555 для legacy tcpip) int port = 0; // порт для adb connect (5555 для legacy tcpip)
QString code; // одноразовый pairing-код (пусто для legacy tcpip) QString code; // одноразовый pairing-код (пусто для legacy tcpip)
QString mac; // нормализованный MAC wlan0 — для поиска нового IP по ARP при смене DHCP
QString updatedAt; // ISO-время последнего обновления QString updatedAt; // ISO-время последнего обновления
}; };
@ -20,7 +21,7 @@ class WifiConnectionDAO {
public: public:
// Вставка/обновление по android_id (PRIMARY KEY). // Вставка/обновление по android_id (PRIMARY KEY).
static bool upsert(const QString &androidId, const QString &ip, int port, static bool upsert(const QString &androidId, const QString &ip, int port,
const QString &code = {}); const QString &code = {}, const QString &mac = {});
// Запись по android_id (пустой androidId в результате → не найдено). // Запись по android_id (пустой androidId в результате → не найдено).
[[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId); [[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId);

155
docs/wifi-adb.md Normal file
View File

@ -0,0 +1,155 @@
# ADB через Wi-Fi (TCP/IP)
ARC умеет работать с устройствами не только по USB-кабелю, но и по Wi-Fi
(ADB-over-TCP). Включается автоматически, **без участия пользователя**: USB остаётся
основным транспортом, Wi-Fi — горячий резерв, который подхватывается при отключении
кабеля.
## Поведение
1. Пользователь, как и раньше, подключает телефон по USB.
2. Фоновый `DeviceScreener`, обнаружив **простаивающее** USB-устройство без живого
Wi-Fi-эндпоинта, автоматически:
- читает IP телефона в сети Wi-Fi (`wlan0`) и его MAC;
- переводит adbd в TCP-режим (`adb tcpip <port>`);
- подключается по сети (`adb connect ip:port`);
- сохраняет эндпоинт в `settings` (ключ `wifi_endpoints`, JSON
`{ android_id: { "endpoint": "ip:port", "mac": "aa:bb:.." } }`; старый формат —
значение-строка `"ip:port"` — читается прозрачно).
3. После этого кабель можно отключить — устройство **остаётся онлайн** (статус
привязан к `android_id`, а не к ADB-serial; меняется только `adbSerial` на
`ip:port`).
4. При старте ARC и периодически воркер переподключает сохранённые эндпоинты
(`adb connect`), так что устройство, у которого кабель уже отключён, само
появляется онлайн.
5. Если беспроводной эндпоинт «завис», воркер чинит его пересозданием TCP
(`adb disconnect` → `adb connect`): голый повторный `connect` вернул бы
`already connected` и ничего бы не починил. `disconnect` ещё и расклинивает это
«залипшее» состояние — после него последующие `connect` (в т.ч. из периодического
reconnect) становятся реальными попытками. Срабатывает по двум сигналам:
- эндпоинт виден в `adb devices` со статусом `offline`;
- устройство ушло в debounced-offline (30 c не отвечает), хотя `adb devices` всё ещё
держит его как `device` — частый реальный случай «полумёртвого» соединения при
слабом Wi-Fi / уходе телефона в doze, когда команды виснут, а статус не меняется
(принудительный `disconnect→connect`, «kick», на переходе в debounced-offline).
Починка **пропускается** для устройств с активной/ожидающей задачей (см. инварианты).
6. Если `connect` по сохранённому IP не удался (адрес сменился по DHCP), воркер ищет
устройство в сети по сохранённому **MAC** (ARP-таблица ОС, при включённой опции —
с прогревом через ping-скан /24) и переподключается на новый IP, обновляя эндпоинт.
## Инварианты
- **USB приоритетнее.** Если один `android_id` виден одновременно по USB-serial и по
`ip:port`, каноническим `adbSerial` (по нему ходят банк-скрипты) выбирается USB.
Wi-Fi-эндпоинт остаётся резервом и используется только когда USB пропадёт.
- **Разрывающие операции — только на простаивающих устройствах.** `adb tcpip`
перезапускает adbd, а принудительный `disconnect→connect` (kick) рвёт текущее
соединение — поэтому ни bootstrap, ни kick не выполняются, если на устройстве есть
активная/ожидающая задача (тот же сигнал занятости
`EventDAO::getDevicesWithPendingEvents()`, по которому `EventHandler` не назначает
задачи). Иначе разрыв оборвал бы выполняющийся bank-скрипт.
- **Бесшовное отключение кабеля.** К моменту отключения USB Wi-Fi-эндпоинт уже поднят
и виден в `adb devices`, поэтому перехода в офлайн не происходит.
## Конфигурация (`config.ini`, секция `[wifi]`)
| Ключ | Назначение | Дефолт |
|---|---|---|
| `enabled` | Полностью вкл/выкл функционал | `true` |
| `port` | TCP-порт adbd для tcpip-режима | `5555` |
| `reconnectIntervalSecs` | Как часто переподключать сохранённые эндпоинты | `30` |
| `bootstrapRetryIntervalSecs` | Мин. интервал между попытками bootstrap одного устройства | `120` |
| `macRediscoverEnabled` | Искать устройство по MAC при смене IP (DHCP) | `true` |
| `macRediscoverIntervalSecs` | Мин. интервал между попытками поиска по MAC | `300` |
| `subnetSweepEnabled` | Прогрев ARP ping-сканом /24 (медленно/шумно) | `false` |
## Ограничения
- **Перезагрузка телефона** сбрасывает tcpip-режим (adbd возвращается в USB-only).
Wi-Fi-резерв поднимется заново при следующем подключении кабеля.
- **Смена IP** (новый адрес по DHCP) обрабатывается поиском по MAC: при неуспешном
`adb connect` воркер находит устройство в ARP-таблице по сохранённому MAC и
переподключается на новый IP. Если устройства нет в ARP, помогает либо включённый
`subnetSweepEnabled` (ping-скан /24 — медленно, шумно), либо повторный bootstrap по
USB. Поиск по MAC требует, чтобы ARC и устройство были в одной L2-подсети (ARP не
маршрутизируется), и работает только когда MAC был снят при bootstrap.
- Требуется, чтобы телефон и десктоп были в одной сети Wi-Fi (маршрутизируемой до
`ip:port` устройства).
- **Чтение MAC** идёт через `ip addr show wlan0` (та же команда, что и для IP), фолбэк —
`cmd wifi status`. Оба проходят без root, включая MIUI/HyperOS (где `cat /sys/...` и
`ip link show` отдают Permission denied). Обычно это рандомизированный per-SSID MAC, но
он стабилен для сети и совпадает с тем, что видит ARP-таблица ПК. Если MAC снять не
удалось — эндпоинт хранится без него: теряется только восстановление при смене IP,
остальное (bootstrap, reconnect, kick) работает.
- **Скриншоты по Wi-Fi медленнее.** Основной путь захвата — http через APK arc-shot (байты
идут мимо adb, см. `[screenshot]` в config.ini); при его недоступности — fallback на
`exec-out screencap`, который отдаёт полноразмерный PNG через adb и по сети тяжелее USB.
Для wireless-serial таймаут fallback-захвата поднят (12 c против 10 c по USB). Интервал
превью задаётся `[screenshot]` (`previewIntervalHttpSecs` для http, `previewIntervalSecs`
для exec-out) и от транспорта Wi-Fi не зависит напрямую. На детект online/offline не
влияет; на слабом линке отдельные кадры всё равно могут не успевать.
- **Залипшее соединение vs. статус `offline`.** На практике мёртвый Wi-Fi часто остаётся
в `adb devices` как `device` (команды виснут), а не переходит в `offline` — поэтому
основной триггер починки именно debounced-offline, а не статус `offline` (см. Поведение).
## Требования к сети
Не требуют доработок ARC — это настройки сети/развёртывания.
**Обязательно (иначе Wi-Fi-ADB не работает):**
- ПК с ARC и устройства — в одной L2-подсети (или маршрутизируемой до `ip:5555`).
- На роутере **отключить AP isolation (изоляцию клиентов Wi-Fi)** — иначе порт `5555`
между узлами недоступен.
- Единый `adb` (бандл `tools/adb/...`), один adb-сервер — чтобы списки устройств у ARC и
фоновых операций не расходились.
**Опционально (предсказуемый адрес устройства — снижает число переподключений):**
смену IP по DHCP ARC и так переживает поиском по MAC (см. Ограничения), поэтому ниже —
не требования, а способы сделать адрес стабильным:
| Способ | Где настраивать | Работает на Android <11 |
|---|---|---|
| DHCP-резервация по MAC | роутер | да |
| Статический IP на самом устройстве | каждое устройство | да |
| mDNS-имя (`adb connect <имя>.local`) | устройство + сеть | нет (только 11+; **в ARC не используется**) |
| Поиск по MAC силами ARC (`macRediscoverEnabled`) | — (уже в ARC) | да |
## Безопасность
`adb tcpip` открывает порт **5555 на самом телефоне** (на его Wi-Fi-IP), а не на ПК.
Парк из N устройств = N эндпоинтов `ip:5555`, различаются по IP. Это отладочный доступ к
устройству, достижимый для **любого узла той же L2-подсети**. Доступ всё ещё проходит
RSA-авторизацию adb (чужой ключ упрётся в запрос «Allow debugging?» на экране), но сама
открытая поверхность + контекст (это **банковские приложения**) требуют сетевой гигиены.
На уровне приложения порт закрыть нельзя — на нерутованном устройстве это свойство adbd,
поэтому безопасность здесь **почти целиком инфраструктурная**:
- **Выделенная/изолированная Wi-Fi-сеть для фермы** — без выхода в недоверенный сегмент.
На роутере **отключить AP isolation** (иначе узлы не видят порт 5555 друг друга — это и
про работоспособность), но саму сеть держать сегментированной (VLAN/гостевую-наоборот).
- **Защита `adbkey` управляющего ПК.** RSA-ключ этой машины — фактический «пароль» ко
**всему** парку (особенно при «всегда разрешать с этого компьютера»). Хранить только на
ней, доступ к машине ограничить.
- **Ограничение на уровне роутера/файрвола** — сузить, кто вообще достаёт до `:5555`
устройств (по возможности — только управляющий ПК).
- **Поиск по MAC / `subnetSweepEnabled`** сканируют подсеть — ещё одна причина держать
ферму в выделённой сети (в чужой/общей сети скан нежелателен).
> Главный практический долг по безопасности — организационный: выделенная сеть, AP
> isolation off + сегментация, защита `adbkey`. В коде ARC тут рычагов нет.
## Где в коде
- `android/adb/AdbUtils` — низкоуровневые обёртки (`isWirelessSerial`,
`getDeviceWifiIp`, `getDeviceMac`, `enableTcpip`, `connectWireless`,
`disconnectWireless`).
- `android/adb/WifiAdbManager` — авто-bootstrap (`tryBootstrap`), переподключение
(`reconnectKnown`: починка `offline`-эндпоинтов через disconnect→connect и поиск по MAC
при смене IP), принудительный kick залипшего эндпоинта (`kickEndpoint`), хранение
эндпоинтов (`loadEndpoints`/`forget`).
- `services/DeviceScreener` — интеграция в цикл опроса (reconnect c починкой stale →
dedup USB/Wi-Fi → kick на debounced-offline (с guard по занятости) → bootstrap
простаивающих; увеличенный таймаут/интервал screencap для wireless).
- `views/device/DeviceWidget` — пассивный бейдж «Wi-Fi» на карточке устройства;
показывается **только когда устройство онлайн** по Wi-Fi (у офлайн-карточки не горит,
чтобы не вводить оператора в заблуждение).

BIN
images/wifi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -19,11 +19,12 @@
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <dao/DeviceDAO.h> #include <dao/DeviceDAO.h>
#include <dao/WifiConnectionDAO.h>
#include <db/DeviceInfo.h> #include <db/DeviceInfo.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "adb/WifiAdbManager.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/EventDAO.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "net/NetworkErrorUtils.h" #include "net/NetworkErrorUtils.h"
@ -34,6 +35,18 @@ DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) {
m_urlAppUpdate = settings.value("net/appInfoUpdate").toString(); m_urlAppUpdate = settings.value("net/appInfoUpdate").toString();
m_token = settings.value("common/token").toString(); m_token = settings.value("common/token").toString();
m_apiBase = settings.value("net/apiBase").toString(); m_apiBase = settings.value("net/apiBase").toString();
// ADB через Wi-Fi: все параметры из [wifi] config.ini (дефолты в .h — фолбэк).
m_wifiEnabled = settings.value("wifi/enabled", m_wifiEnabled).toBool();
m_wifiPort = settings.value("wifi/port", m_wifiPort).toInt();
m_wifiReconnectIntervalSecs =
settings.value("wifi/reconnectIntervalSecs", static_cast<qlonglong>(m_wifiReconnectIntervalSecs)).toLongLong();
m_wifiBootstrapRetrySecs =
settings.value("wifi/bootstrapRetryIntervalSecs", static_cast<qlonglong>(m_wifiBootstrapRetrySecs)).toLongLong();
m_wifiMacRediscover = settings.value("wifi/macRediscoverEnabled", m_wifiMacRediscover).toBool();
m_wifiMacRediscoverIntervalSecs =
settings.value("wifi/macRediscoverIntervalSecs", static_cast<qlonglong>(m_wifiMacRediscoverIntervalSecs)).toLongLong();
m_wifiSubnetSweep = settings.value("wifi/subnetSweepEnabled", m_wifiSubnetSweep).toBool();
} }
DeviceScreener::~DeviceScreener() = default; DeviceScreener::~DeviceScreener() = default;
@ -344,6 +357,34 @@ void DeviceScreener::start() {
shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt(); shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt();
while (m_running) { while (m_running) {
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
// Wi-Fi reconnect: до readAdbDevices, чтобы переподключённые по сети
// устройства попали в `adb devices` уже в этом цикле. Троттлим по конфигу.
// Пропускаем уже онлайн (предыдущий debounced-набор) — не дёргаем зря adb.
if (m_wifiEnabled && nowSec - m_lastWifiReconnect >= m_wifiReconnectIntervalSecs) {
m_lastWifiReconnect = nowSec;
// Протухшие offline Wi-Fi-эндпоинты в текущем adb devices: их reconnect чинит
// через disconnect→connect (голый connect вернул бы "already connected").
// Лишний adb devices — только когда сработал троттл reconnect (раз в N сек).
QSet<QString> staleWireless;
for (const auto &[serial, status] : readAdbDevices()) {
if (status == "offline" && AdbUtils::isWirelessSerial(serial)) {
staleWireless.insert(serial);
}
}
WifiAdbManager::ReconnectOptions opts;
opts.alreadyOnline = m_onlineDevices;
opts.staleEndpoints = staleWireless;
opts.subnetSweep = m_wifiSubnetSweep;
opts.macRediscover = m_wifiMacRediscover
&& nowSec - m_lastMacRediscover >= m_wifiMacRediscoverIntervalSecs;
if (opts.macRediscover) {
m_lastMacRediscover = nowSec;
}
WifiAdbManager::reconnectKnown(opts);
}
bool adbOk = true; bool adbOk = true;
QList<QPair<QString, QString> > devices = readAdbDevices(&adbOk); QList<QPair<QString, QString> > devices = readAdbDevices(&adbOk);
// Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит, // Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит,
@ -355,7 +396,6 @@ void DeviceScreener::start() {
continue; continue;
} }
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
// --- Фаза A: чистый adb-I/O параллельно по всем "device". // --- Фаза A: чистый adb-I/O параллельно по всем "device".
// Устройства не в статусе "device" (offline/unauthorized) игнорируем — они // Устройства не в статусе "device" (offline/unauthorized) игнорируем — они
@ -393,11 +433,37 @@ void DeviceScreener::start() {
appsDue.contains(serial), shotDue.contains(serial)); appsDue.contains(serial), shotDue.contains(serial));
}); });
// USB приоритетнее Wi-Fi: один и тот же android_id может быть виден сразу
// по USB-serial и по Wi-Fi-эндпоинту (ip:port). Канонический adbSerial для
// устройства выбираем USB (Wi-Fi — горячий резерв), чтобы скрипты ходили по
// кабелю, а в БД adbSerial не «прыгал» между транспортами. При отключении
// кабеля останется только Wi-Fi-serial → он и станет каноническим, без
// перехода устройства в офлайн (liveSet/онлайн привязаны к android_id).
QMap<QString, QString> preferredSerial; // android_id → выбранный adbSerial
for (const ScanResult &scan : scans) {
if (!scan.identified) continue;
const QString &id = scan.info.id;
if (!preferredSerial.contains(id)) {
preferredSerial[id] = scan.adbSerial;
} else if (AdbUtils::isWirelessSerial(preferredSerial[id])
&& !AdbUtils::isWirelessSerial(scan.adbSerial)) {
preferredSerial[id] = scan.adbSerial; // заменяем Wi-Fi на USB
}
}
// --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке. // --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке.
for (const ScanResult &scan : scans) { for (const ScanResult &scan : scans) {
// android_id не прочитался — пропускаем устройство в этом цикле. // android_id не прочитался — пропускаем устройство в этом цикле.
if (!scan.identified) continue; if (!scan.identified) continue;
// Не-победитель дубля (тот же id виден по другому транспорту): устройство
// всё равно онлайн — отмечаем в liveSet (для дебаунса), но БД/превью/API
// обновляет только победитель, иначе adbSerial будет осциллировать.
if (preferredSerial.value(scan.info.id) != scan.adbSerial) {
liveSet.insert(scan.info.id);
continue;
}
DeviceInfo device = scan.info; DeviceInfo device = scan.info;
device.status = "device"; device.status = "device";
device.updateTime = QDateTime::currentDateTime(); device.updateTime = QDateTime::currentDateTime();
@ -447,18 +513,6 @@ void DeviceScreener::start() {
qDebug() << "Cant update device"; qDebug() << "Cant update device";
} }
// Сохраняем Wi-Fi IP телефона в wifi_connections (по android_id) — задел под
// авто-реконнект. Резолвим напрямую (независимо от http-скриншота, чтобы IP
// был и для exec-out-устройств); resolvePhoneIp кэширует с TTL ~5 мин, так что
// adb дёргается редко. Пишем только при изменении. upsert после upsertDevice —
// FK wifi_connections → devices(id).
if (const QString ip = AdbUtils::resolvePhoneIp(scan.adbSerial);
!ip.isEmpty() && m_lastPersistedIp.value(device.id) != ip) {
if (WifiConnectionDAO::upsert(device.id, ip, 5555)) {
m_lastPersistedIp[device.id] = ip;
}
}
// Проверяем установленные приложения по троттлу (список прочитан в фазе A). // Проверяем установленные приложения по троттлу (список прочитан в фазе A).
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след. // Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
// цикле и не пометить все приложения как отсутствующие. // цикле и не пометить все приложения как отсутствующие.
@ -551,6 +605,12 @@ void DeviceScreener::start() {
goneOffline.insert(id); goneOffline.insert(id);
} }
} }
// Устройства с активной/ожидающей задачей не «кикаем»: disconnect порвал бы
// выполняющийся bank-скрипт. Тот же сигнал занятости, что и для bootstrap.
// Считаем один раз и только если есть кого переводить в офлайн.
const QSet<QString> busyForKick =
(m_wifiEnabled && !goneOffline.isEmpty()) ? EventDAO::getDevicesWithPendingEvents()
: QSet<QString>();
for (const QString &id : goneOffline) { for (const QString &id : goneOffline) {
m_onlineDevices.remove(id); m_onlineDevices.remove(id);
m_offlineSince.remove(id); m_offlineSince.remove(id);
@ -572,6 +632,15 @@ void DeviceScreener::start() {
updateDeviceOnApi(offlineCopy); updateDeviceOnApi(offlineCopy);
} }
emit deviceWentOffline(id); emit deviceWentOffline(id);
// Wi-Fi-эндпоинт мог «залипнуть» в полумёртвом состоянии: adb держит его как
// "device", но команды виснут — поэтому устройство ушло в debounced-offline,
// а статус "offline" в `adb devices` так и не появился (наш staleEndpoints его
// не ловит). На переходе в офлайн форсим пересоздание TCP (disconnect→connect).
// Для офлайна по USB (нет сохранённого Wi-Fi-эндпоинта) это no-op.
if (m_wifiEnabled && !busyForKick.contains(id)) {
WifiAdbManager::kickEndpoint(id);
}
} }
// БД status: считаем онлайном только debounced-онлайн. // БД status: считаем онлайном только debounced-онлайн.
@ -586,6 +655,35 @@ void DeviceScreener::start() {
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices; qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
emit firstPassDone(); emit firstPassDone();
} }
// --- Wi-Fi bootstrap: для простаивающих USB-устройств без живого Wi-Fi-резерва
// автоматически поднимаем беспроводной эндпоинт (tcpip → ip → connect).
// tcpip перезапускает adbd и рвёт соединение, поэтому делаем это ТОЛЬКО на
// устройствах без активной/ожидающей задачи (тот же сигнал занятости, по
// которому EventHandler не шлёт задачи). В самом конце цикла, чтобы
// блокирующий tcpip+connect не задерживал детект online/offline.
if (m_wifiEnabled) {
QSet<QString> wifiLiveIds; // android_id уже с живым Wi-Fi-эндпоинтом
QMap<QString, QString> usbSerialById; // android_id → USB-serial (кандидаты)
for (const ScanResult &scan : scans) {
if (!scan.identified) continue;
if (AdbUtils::isWirelessSerial(scan.adbSerial)) {
wifiLiveIds.insert(scan.info.id);
} else {
usbSerialById[scan.info.id] = scan.adbSerial;
}
}
const QSet<QString> busyDevices = EventDAO::getDevicesWithPendingEvents();
for (auto it = usbSerialById.constBegin(); it != usbSerialById.constEnd(); ++it) {
const QString &id = it.key();
if (wifiLiveIds.contains(id)) continue; // Wi-Fi-резерв уже поднят
if (busyDevices.contains(id)) continue; // занято задачей — tcpip рвал бы её
if (nowSec - m_lastWifiBootstrap.value(id, 0) < m_wifiBootstrapRetrySecs) continue;
m_lastWifiBootstrap[id] = nowSec;
WifiAdbManager::tryBootstrap(it.value(), id, m_wifiPort);
}
}
QThread::msleep(1000); QThread::msleep(1000);
} }
emit finished(); emit finished();

View File

@ -65,7 +65,18 @@ private:
QMap<QString, DeviceInfo> m_staticInfoCache; QMap<QString, DeviceInfo> m_staticInfoCache;
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью) QMap<QString, qint64> m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью)
QMap<QString, QString> m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль)
// --- ADB через Wi-Fi (USB — основной транспорт, Wi-Fi — горячий резерв) ---
bool m_wifiEnabled = true; // [wifi] enabled
int m_wifiPort = 5555; // [wifi] port (tcpip)
qint64 m_wifiReconnectIntervalSecs = 30; // [wifi] reconnectIntervalSecs
qint64 m_wifiBootstrapRetrySecs = 120; // [wifi] bootstrapRetryIntervalSecs
qint64 m_lastWifiReconnect = 0; // unix ts последнего reconnectKnown
QMap<QString, qint64> m_lastWifiBootstrap; // android_id → unix ts последней попытки bootstrap
bool m_wifiMacRediscover = true; // [wifi] macRediscoverEnabled (искать новый IP по MAC)
qint64 m_wifiMacRediscoverIntervalSecs = 300; // [wifi] macRediscoverIntervalSecs (троттл поиска по MAC)
bool m_wifiSubnetSweep = false; // [wifi] subnetSweepEnabled (ping-скан подсети для ARP)
qint64 m_lastMacRediscover = 0; // unix ts последнего поиска по MAC
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле // Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
// потоков). Не содержит ничего, что требует БД/членов класса. // потоков). Не содержит ничего, что требует БД/членов класса.

View File

@ -656,6 +656,29 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
topRightLayout->addWidget(settingsBtn); topRightLayout->addWidget(settingsBtn);
// topRightLayout->addWidget(deleteBtn); // topRightLayout->addWidget(deleteBtn);
topRightLayout->addWidget(codeBtn); topRightLayout->addWidget(codeBtn);
// Пассивный мини-индикатор Wi-Fi: устройство работает по ADB-over-TCP (кабель не
// нужен). Только информация для оператора, без интерактива. Определяем по виду
// adbSerial (ip:port). USB-устройства бейдж не показывают. Оформлен в одном стиле
// с кнопками выше (шестерёнка/код) и стоит под ними, чтобы не перекрывать id.
// Показываем ТОЛЬКО когда устройство онлайн: у офлайн-карточки горящий бейдж
// означал бы активное Wi-Fi-соединение, которого нет — это путало оператора.
if (device.status == "device" && AdbUtils::isWirelessSerial(m_device.adbSerial)) {
auto *wifiBadge = new QLabel(this);
wifiBadge->setAttribute(Qt::WA_TransparentForMouseEvents, true);
wifiBadge->setToolTip(tr("Подключено по Wi-Fi"));
wifiBadge->setFixedSize(28, 28);
wifiBadge->setAlignment(Qt::AlignCenter);
const QPixmap wifiPix(QCoreApplication::applicationDirPath() + "/images/wifi.png");
wifiBadge->setPixmap(wifiPix.scaled(20, 20, Qt::KeepAspectRatio, Qt::SmoothTransformation));
wifiBadge->setStyleSheet(
"background-color: rgba(0, 0, 0, 0.5);"
"border: none;"
"border-radius: 4px;"
);
topRightLayout->addWidget(wifiBadge);
}
layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight); layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight);
if (device.status != "device") { if (device.status != "device") {