From 5ea0b42804b02d2667fc731698feb1989e78e672 Mon Sep 17 00:00:00 2001 From: Kosh Date: Sat, 20 Jun 2026 11:11:57 +0500 Subject: [PATCH] Add ADB-over-Wi-Fi failover and fix screenshot/reconnect performance --- README.md | 1 + android/adb/AdbUtils.cpp | 178 ++++++++++++++++- android/adb/AdbUtils.h | 28 +++ android/adb/WifiAdbManager.cpp | 299 +++++++++++++++++++++++++++++ android/adb/WifiAdbManager.h | 72 +++++++ config.ini | 20 ++ database/DatabaseManager.cpp | 5 + database/dao/WifiConnectionDAO.cpp | 9 +- database/dao/WifiConnectionDAO.h | 3 +- docs/wifi-adb.md | 155 +++++++++++++++ images/wifi.png | Bin 0 -> 19977 bytes services/DeviceScreener.cpp | 126 ++++++++++-- services/DeviceScreener.h | 13 +- views/device/DeviceWidget.cpp | 23 +++ 14 files changed, 906 insertions(+), 26 deletions(-) create mode 100644 android/adb/WifiAdbManager.cpp create mode 100644 android/adb/WifiAdbManager.h create mode 100644 docs/wifi-adb.md create mode 100644 images/wifi.png diff --git a/README.md b/README.md index 6f566f9..a664a3d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ cmake --build . | [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт | | [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/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/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon | diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 2fe9df7..1eb79cb 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -540,7 +540,10 @@ QByteArray AdbUtils::captureScreenshotExecOut(const QString &deviceId) { << "error:" << process.errorString(); 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; return {}; } @@ -674,15 +677,20 @@ void AdbUtils::kickArcShot(const QString &deviceId) { 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 не отвечает. + // 3a. Доступ к /sdcard — БЕЗУСЛОВНО (а не только при установке): файл скриншота + // создаёт adb screencap как root:everybody (0660), и без MANAGE_EXTERNAL_STORAGE + // сервис не может его прочитать — 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", "+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; } } + +// --- 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: ". Иногда возвращает ненулевой код, + // хотя режим переключён — поэтому считаем успехом и 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; +} diff --git a/android/adb/AdbUtils.h b/android/adb/AdbUtils.h index 15babde..eeacb52 100644 --- a/android/adb/AdbUtils.h +++ b/android/adb/AdbUtils.h @@ -86,6 +86,34 @@ public: // экран в портрете (изначально или после форсирования). 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 tcpip ). + // Вызывается на устройстве, подключённом по USB. ВНИМАНИЕ: перезапускает adbd и + // кратковременно рвёт текущее соединение — запускать только на простаивающем устройстве. + static bool enableTcpip(const QString &serial, int port); + + // adb connect (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 . + static bool disconnectWireless(const QString &endpoint); + private: static QString adbDir(); static bool startProcess(const QString &program, const QStringList &args, diff --git a/android/adb/WifiAdbManager.cpp b/android/adb/WifiAdbManager.cpp new file mode 100644 index 0000000..360f0a5 --- /dev/null +++ b/android/adb/WifiAdbManager.cpp @@ -0,0 +1,299 @@ +#include "WifiAdbManager.h" + +#include +#include +#include +#include +#include +#include +#include + +#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(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 g_failCount; // androidId → неудач подряд +QHash 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 WifiAdbManager::loadEndpoints() { + QMap 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 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"); +} diff --git a/android/adb/WifiAdbManager.h b/android/adb/WifiAdbManager.h new file mode 100644 index 0000000..75c5e51 --- /dev/null +++ b/android/adb/WifiAdbManager.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +// Автоматическое поднятие/восстановление беспроводного (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 alreadyOnline; // android_id, уже видимые онлайн — пропускаем + QSet staleEndpoints; // "ip:port" в статусе offline — disconnect перед connect + bool macRediscover = false; // при неудаче искать новый IP устройства по MAC + bool subnetSweep = false; // разрешить ping-скан подсети для прогрева ARP (медленно/шумно) + }; + + // Сохранённые эндпоинты: android_id → WifiEndpoint. + static QMap 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 → определить 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); +}; diff --git a/config.ini b/config.ini index 3725dba..aa6da6a 100644 --- a/config.ini +++ b/config.ini @@ -44,6 +44,26 @@ remotePath=/sdcard/arc_shot.png previewIntervalSecs=4 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] android_package_name=app.blackwallet.ar currency=ARS diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 3fcc171..57eb555 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -267,9 +267,14 @@ void DatabaseManager::initSchema() { ip TEXT, port INTEGER, code TEXT, + mac TEXT, updated_at DATETIME, 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(); } diff --git a/database/dao/WifiConnectionDAO.cpp b/database/dao/WifiConnectionDAO.cpp index c05d42c..8e1528b 100644 --- a/database/dao/WifiConnectionDAO.cpp +++ b/database/dao/WifiConnectionDAO.cpp @@ -14,27 +14,30 @@ WifiConnection parseRow(const QSqlQuery &query) { wc.ip = query.value("ip").toString(); wc.port = query.value("port").toInt(); wc.code = query.value("code").toString(); + wc.mac = query.value("mac").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) { + const QString &code, const QString &mac) { 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) + INSERT INTO wifi_connections (android_id, ip, port, code, mac, updated_at) + VALUES (:android_id, :ip, :port, :code, :mac, :updated_at) ON CONFLICT(android_id) DO UPDATE SET ip = excluded.ip, port = excluded.port, code = excluded.code, + mac = excluded.mac, updated_at = excluded.updated_at )"); query.bindValue(":android_id", androidId); query.bindValue(":ip", ip); query.bindValue(":port", port); query.bindValue(":code", code); + query.bindValue(":mac", mac); query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); if (!query.exec()) { qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text(); diff --git a/database/dao/WifiConnectionDAO.h b/database/dao/WifiConnectionDAO.h index 57aafbd..122402c 100644 --- a/database/dao/WifiConnectionDAO.h +++ b/database/dao/WifiConnectionDAO.h @@ -10,6 +10,7 @@ struct WifiConnection { QString ip; // последний известный IP телефона в Wi-Fi int port = 0; // порт для adb connect (5555 для legacy tcpip) QString code; // одноразовый pairing-код (пусто для legacy tcpip) + QString mac; // нормализованный MAC wlan0 — для поиска нового IP по ARP при смене DHCP QString updatedAt; // ISO-время последнего обновления }; @@ -20,7 +21,7 @@ class WifiConnectionDAO { public: // Вставка/обновление по android_id (PRIMARY KEY). static bool upsert(const QString &androidId, const QString &ip, int port, - const QString &code = {}); + const QString &code = {}, const QString &mac = {}); // Запись по android_id (пустой androidId в результате → не найдено). [[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId); diff --git a/docs/wifi-adb.md b/docs/wifi-adb.md new file mode 100644 index 0000000..3d1f4dd --- /dev/null +++ b/docs/wifi-adb.md @@ -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 `); + - подключается по сети (`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 (у офлайн-карточки не горит, + чтобы не вводить оператора в заблуждение). diff --git a/images/wifi.png b/images/wifi.png new file mode 100644 index 0000000000000000000000000000000000000000..f63b0ca06f5ee6045aa8f5f32322952e09ddc578 GIT binary patch literal 19977 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 literal 0 HcmV?d00001 diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index b627c85..d46eb5f 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -19,11 +19,12 @@ #include #include #include -#include #include #include "adb/AdbUtils.h" +#include "adb/WifiAdbManager.h" #include "dao/BankProfileDAO.h" +#include "dao/EventDAO.h" #include "dao/MaterialDAO.h" #include "dao/SettingsDAO.h" #include "net/NetworkErrorUtils.h" @@ -34,6 +35,18 @@ DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) { m_urlAppUpdate = settings.value("net/appInfoUpdate").toString(); m_token = settings.value("common/token").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(m_wifiReconnectIntervalSecs)).toLongLong(); + m_wifiBootstrapRetrySecs = + settings.value("wifi/bootstrapRetryIntervalSecs", static_cast(m_wifiBootstrapRetrySecs)).toLongLong(); + m_wifiMacRediscover = settings.value("wifi/macRediscoverEnabled", m_wifiMacRediscover).toBool(); + m_wifiMacRediscoverIntervalSecs = + settings.value("wifi/macRediscoverIntervalSecs", static_cast(m_wifiMacRediscoverIntervalSecs)).toLongLong(); + m_wifiSubnetSweep = settings.value("wifi/subnetSweepEnabled", m_wifiSubnetSweep).toBool(); } DeviceScreener::~DeviceScreener() = default; @@ -344,6 +357,34 @@ void DeviceScreener::start() { shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt(); 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 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; QList > devices = readAdbDevices(&adbOk); // Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит, @@ -355,7 +396,6 @@ void DeviceScreener::start() { continue; } QSet liveSet; // устройства, видимые как "device" в текущем скане - const qint64 nowSec = QDateTime::currentSecsSinceEpoch(); // --- Фаза A: чистый adb-I/O параллельно по всем "device". // Устройства не в статусе "device" (offline/unauthorized) игнорируем — они @@ -393,11 +433,37 @@ void DeviceScreener::start() { 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 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: всё, что трогает БД/члены класса — последовательно в этом потоке. for (const ScanResult &scan : scans) { // android_id не прочитался — пропускаем устройство в этом цикле. 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; device.status = "device"; device.updateTime = QDateTime::currentDateTime(); @@ -447,18 +513,6 @@ 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 — не двигаем метку, чтобы перечитать на след. // цикле и не пометить все приложения как отсутствующие. @@ -551,6 +605,12 @@ void DeviceScreener::start() { goneOffline.insert(id); } } + // Устройства с активной/ожидающей задачей не «кикаем»: disconnect порвал бы + // выполняющийся bank-скрипт. Тот же сигнал занятости, что и для bootstrap. + // Считаем один раз и только если есть кого переводить в офлайн. + const QSet busyForKick = + (m_wifiEnabled && !goneOffline.isEmpty()) ? EventDAO::getDevicesWithPendingEvents() + : QSet(); for (const QString &id : goneOffline) { m_onlineDevices.remove(id); m_offlineSince.remove(id); @@ -572,6 +632,15 @@ void DeviceScreener::start() { updateDeviceOnApi(offlineCopy); } 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-онлайн. @@ -586,6 +655,35 @@ void DeviceScreener::start() { qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices; emit firstPassDone(); } + + // --- Wi-Fi bootstrap: для простаивающих USB-устройств без живого Wi-Fi-резерва + // автоматически поднимаем беспроводной эндпоинт (tcpip → ip → connect). + // tcpip перезапускает adbd и рвёт соединение, поэтому делаем это ТОЛЬКО на + // устройствах без активной/ожидающей задачи (тот же сигнал занятости, по + // которому EventHandler не шлёт задачи). В самом конце цикла, чтобы + // блокирующий tcpip+connect не задерживал детект online/offline. + if (m_wifiEnabled) { + QSet wifiLiveIds; // android_id уже с живым Wi-Fi-эндпоинтом + QMap 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 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); } emit finished(); diff --git a/services/DeviceScreener.h b/services/DeviceScreener.h index 4fb847d..5c15601 100644 --- a/services/DeviceScreener.h +++ b/services/DeviceScreener.h @@ -65,7 +65,18 @@ private: QMap m_staticInfoCache; QMap m_lastAppsCheck; // adbSerial → unix ts последнего getListApps QMap m_lastScreencap; // adbSerial → unix ts последнего screencap (троттл превью) - QMap m_lastPersistedIp; // android_id → последний записанный в wifi_connections IP (анти-дубль) + + // --- ADB через 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 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, выполняется в пуле // потоков). Не содержит ничего, что требует БД/членов класса. diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index d574e79..4030035 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -656,6 +656,29 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p topRightLayout->addWidget(settingsBtn); // topRightLayout->addWidget(deleteBtn); 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); if (device.status != "device") {