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 0000000..f63b0ca Binary files /dev/null and b/images/wifi.png differ 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") {