#include "AdbUtils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "dump/TaskDumpRecorder.h" namespace { // Конфиг скриншот-стратегии (config.ini [screenshot]). Кэшируется при первом чтении. struct ScreenshotCfg { QString transport; // "http" | "execout" int httpPort = 8099; int httpTimeoutMs = 4000; QString remotePath = "/sdcard/arc_shot.png"; }; const ScreenshotCfg &screenshotCfg() { static ScreenshotCfg cfg = [] { const QSettings s("config.ini", QSettings::IniFormat); ScreenshotCfg c; c.transport = s.value("screenshot/transport", "http").toString().trimmed().toLower(); c.httpPort = s.value("screenshot/httpPort", 8099).toInt(); c.httpTimeoutMs = s.value("screenshot/httpTimeoutMs", 4000).toInt(); c.remotePath = s.value("screenshot/remotePath", "/sdcard/arc_shot.png").toString(); return c; }(); return cfg; } // Кэш IP телефона по serial (резолв дорогой — adb shell). Потокобезопасно. // С TTL: IP редко меняется, перечитываем не чаще kIpTtlSecs (на случай смены DHCP). constexpr qint64 kIpTtlSecs = 300; QMutex g_ipCacheMutex; QHash g_ipCache; QHash g_ipCacheTs; // Негативный кэш: serial → unix ts последней неудачи HTTP. Пока не истёк бэкофф, // идём сразу в exec-out fallback, не дёргая screencap+IP+таймаут каждый цикл. constexpr qint64 kHttpFailBackoffSecs = 60; QMutex g_httpFailMutex; QHash g_httpFailAt; // Лог смены пути скриншота (http ↔ execout) для наблюдаемости — без спама каждые 4с. QMutex g_modeMutex; QHash g_lastMode; void logScreenshotMode(const QString &deviceId, const QString &mode) { QMutexLocker lock(&g_modeMutex); if (g_lastMode.value(deviceId) != mode) { g_lastMode.insert(deviceId, mode); qInfo().noquote() << "[AdbUtils] screenshot path for" << deviceId << "→" << mode; } } } // namespace AdbUtils::AdbUtils(QObject *parent) : QObject(parent) { } QString AdbUtils::adbPath() { #ifdef Q_OS_WIN QString path = QCoreApplication::applicationDirPath() + "/tools/adb/windows/adb.exe"; #else QString path = QCoreApplication::applicationDirPath() + "/tools/adb/macos/adb"; #endif static bool checked = false; if (!checked) { checked = true; QFileInfo fi(path); if (!fi.exists()) { qWarning() << "[AdbUtils] adb not found at:" << path; } else if (!fi.isExecutable()) { qWarning() << "[AdbUtils] adb is not executable:" << path; } else { qDebug() << "[AdbUtils] adb path:" << path; } } return path; } QString AdbUtils::adbDir() { return QFileInfo(adbPath()).absolutePath(); } bool AdbUtils::startProcess(const QString &program, const QStringList &args, QString *stdOut, QString *stdErr) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(program, args); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start process:" << program << args << "error:" << process.errorString(); if (stdErr) { *stdErr = process.errorString(); } return false; } if (!process.waitForFinished(30000) || process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) { if (stdErr) { *stdErr = QString::fromUtf8(process.readAllStandardError()); } qWarning() << "[AdbUtils] Process failed:" << program << args << "exitCode:" << process.exitCode() << "stderr:" << (stdErr ? *stdErr : QString::fromUtf8(process.readAllStandardError())); return false; } if (stdOut) { *stdOut = QString::fromUtf8(process.readAllStandardOutput()); } return true; } // Ждёт завершения процесса с таймаутом. Раньше здесь стоял бесконечный // waitForFinished() — если adb-клиент зависал на неотвечающем устройстве/USB, // рабочий поток блокировался навсегда (накопление зависших потоков → фриз // приложения через несколько часов). При таймауте убиваем зависший процесс и // реапим его, чтобы и поток освободился, и деструктор QProcess не блокировался. // Возвращает true, если процесс завершился сам в отведённое время. static bool waitOrKill(QProcess &process, int timeoutMs, const char *tag) { if (process.waitForFinished(timeoutMs)) { return true; } qWarning() << "[AdbUtils]" << tag << "timed out after" << timeoutMs << "ms — killing wedged adb process"; process.kill(); process.waitForFinished(2000); return false; } // adb shell dumpsys package ru.rshb.dbo | grep permission // TODO доделать обработку пермишинов // adb shell pm revoke ru.rshb.dbo android.permission.WRITE_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.READ_CONTACTS; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_COARSE_LOCATION; adb shell pm revoke ru.rshb.dbo android.permission.ACCESS_FINE_LOCATION // adb shell cmd appops set ru.rshb.dbo POST_NOTIFICATION ignore /** * Возвращает список найденных устройств в виде пар (ID устройства и Статус) * @return [(id, status), ...] */ QList > AdbUtils::getListDevices() { const QString adb = adbPath(); const QString dir = adbDir(); // Убиваем чужой adb-сервер (возможно другой версии) и запускаем свой, // чтобы избежать конфликта версий (например, scrcpy adb 35 vs bundled adb 36) static bool serverRestarted = false; if (!serverRestarted) { serverRestarted = true; QProcess kill; kill.setWorkingDirectory(dir); kill.start(adb, {"kill-server"}); kill.waitForFinished(5000); qDebug() << "[AdbUtils] Killed existing adb server"; QProcess start; start.setWorkingDirectory(dir); start.start(adb, {"start-server"}); start.waitForFinished(10000); qDebug() << "[AdbUtils] Started own adb server"; } QProcess process; process.setWorkingDirectory(dir); process.start(adb, {"devices"}); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb devices:" << process.errorString(); return {}; } process.waitForFinished(10000); const QString output = process.readAllStandardOutput(); QList > devices; const QStringList lines = output.split('\n'); for (int i = 1; i < lines.size(); ++i) { QString line = lines.at(i).trimmed(); if (line.isEmpty()) { continue; } /** * device — устройство готово к использованию. * offline — устройство подключено, но не может быть использовано. * unauthorized — устройство не авторизовано для работы с ADB (нужно подтвердить запрос на подключение на самом устройстве). * recovery — устройство в режиме восстановления. */ if (QStringList parts = line.split('\t'); parts.size() == 2) { const QString &id = parts.at(0); const QString &status = parts.at(1); devices.append(qMakePair(id, status)); } } return devices; } bool AdbUtils::adbPair(const QString &ip, int port, const QString &code) { const QString endpoint = ip + ":" + QString::number(port); QString out, err; startProcess(adbPath(), {"pair", endpoint, code}, &out, &err); const QString combined = out + err; const bool ok = combined.contains("Successfully paired", Qt::CaseInsensitive); if (ok) { qInfo().noquote() << "[AdbUtils] adb pair" << endpoint << "→ ok"; } else { qWarning().noquote() << "[AdbUtils] adb pair" << endpoint << "failed:" << combined.trimmed(); } return ok; } QString AdbUtils::adbConnect(const QString &ip, int port) { const QString endpoint = ip + ":" + QString::number(port); QString out, err; startProcess(adbPath(), {"connect", endpoint}, &out, &err); const QString combined = out + err; // adb connect возвращает exit 0 и при неудаче ("failed to connect"), поэтому // ориентируемся на текст вывода. if (combined.contains("connected to", Qt::CaseInsensitive) || combined.contains("already connected", Qt::CaseInsensitive)) { qInfo().noquote() << "[AdbUtils] adb connect" << endpoint << "→ ok"; return endpoint; } qWarning().noquote() << "[AdbUtils] adb connect" << endpoint << "failed:" << combined.trimmed(); return {}; } bool AdbUtils::adbDisconnect(const QString &endpoint) { QString out, err; startProcess(adbPath(), {"disconnect", endpoint}, &out, &err); return true; // best-effort } QString AdbUtils::mdnsFindConnect(const QString &ip) { QString out; startProcess(adbPath(), {"mdns", "services"}, &out); // Строки вида: adb-XXXX-YY._adb-tls-connect._tcp. 192.168.1.50:41555 static const QRegularExpression reEndpoint(R"((\d+\.\d+\.\d+\.\d+):(\d+))"); const QStringList lines = out.split('\n'); for (const QString &line : lines) { if (!line.contains("_adb-tls-connect")) { continue; } if (const auto m = reEndpoint.match(line); m.hasMatch() && m.captured(1) == ip) { return m.captured(1) + ":" + m.captured(2); } } return {}; } QString AdbUtils::scanFindConnect(const QString &ip, int portStart, int portEnd) { if (portStart < 1) portStart = 1; if (portEnd > 65535) portEnd = 65535; if (portEnd < portStart) return {}; // 1. Параллельно ищем открытые TCP-порты на IP телефона (сырой сокет, без adb). QList ports; ports.reserve(portEnd - portStart + 1); for (int p = portStart; p <= portEnd; ++p) ports.append(p); QThreadPool pool; pool.setMaxThreadCount(256); const QList openPorts = QtConcurrent::blockingFiltered( &pool, ports, [&ip](int p) { QTcpSocket s; s.connectToHost(ip, static_cast(p)); return s.waitForConnected(250); }); if (openPorts.isEmpty()) { qWarning().noquote() << "[AdbUtils] scanFindConnect: no open ports on" << ip; return {}; } qInfo().noquote() << "[AdbUtils] scanFindConnect: open ports on" << ip << ":" << openPorts; // 2. На каждый открытый порт пробуем adb connect (порт спаривания откажет, connect- // порт подключится — pairing уже есть, код не нужен). for (const int p : openPorts) { if (const QString ep = adbConnect(ip, p); !ep.isEmpty()) { return ep; } } return {}; } QString AdbUtils::getAndroidId(const QString &deviceId) { QString out; if (!startProcess(adbPath(), {"-s", deviceId, "shell", "settings", "get", "secure", "android_id"}, &out)) { return {}; } const QString id = out.trimmed(); return (id == "null" || id.isEmpty()) ? QString() : id; } QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "pidof", packageName }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb pidof:" << process.errorString(); return {}; } waitOrKill(process, 10000, "tryToGetRunningAppPid"); QString pid = process.readAllStandardOutput(); return pid.replace("\n", ""); } // Логирует длинный вывод построчно с префиксом, иначе Qt-логгер режет строку. static void logMultiline(const char *tag, const QString &text) { if (text.isEmpty()) { qDebug().noquote() << tag << "(empty)"; return; } const QStringList lines = text.split(QRegularExpression("\r?\n")); for (const QString &line : lines) { if (!line.isEmpty()) qDebug().noquote() << tag << line; } } // Прогоняет adb-команду и возвращает stdout (stderr игнорируется — этим функциям важен только stdout). static QString adbShellStdout(const QStringList &args, int timeoutMs = 10000) { QProcess p; p.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); p.start(AdbUtils::adbPath(), args); if (!p.waitForStarted(5000)) return {}; p.waitForFinished(timeoutMs); return QString::fromUtf8(p.readAllStandardOutput()); } // Возвращает {launcherActivity, summary}. launcherActivity вида "pkg/.MainActivity" или пустая строка. static QPair probePackageState(const QString &deviceId, const QString &packageName) { const QString resolveOut = adbShellStdout( {"-s", deviceId, "shell", "cmd", "package", "resolve-activity", "--brief", packageName}); QString launcher; // Формат: первая строка — приоритет, вторая — "pkg/activity". for (const QString &raw : resolveOut.split(QRegularExpression("\r?\n"))) { const QString line = raw.trimmed(); if (line.contains('/') && line.startsWith(packageName + "/")) { launcher = line; break; } } const QString dump = adbShellStdout( {"-s", deviceId, "shell", "dumpsys", "package", packageName}, 15000); auto pickFlag = [&dump](const QString &key) -> QString { const QRegularExpression re(key + "=(\\S+)"); const auto m = re.match(dump); return m.hasMatch() ? m.captured(1) : QStringLiteral("?"); }; const bool installed = dump.contains("Package [" + packageName + "]"); const QString enabled = pickFlag("enabled"); const QString stopped = pickFlag("stopped"); const QString hidden = pickFlag("hidden"); const bool hasLauncherCategory = dump.contains("android.intent.category.LAUNCHER"); const QString summary = QString( "installed=%1 enabled=%2 stopped=%3 hidden=%4 hasLauncherCategory=%5 resolveActivity=%6") .arg(installed ? "true" : "false", enabled, stopped, hidden, hasLauncherCategory ? "true" : "false", launcher.isEmpty() ? "(none)" : launcher); return {launcher, summary}; } bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) { // Pre-flight: состояние пакета. Без этого по логу нельзя отличить // "нет иконки" от "disabled" от "hidden" от "не установлен". const auto [launcherActivity, stateSummary] = probePackageState(deviceId, packageName); qDebug().noquote() << "[AdbUtils::tryToStartApplication] device=" << deviceId << "pkg=" << packageName << "state:" << stateSummary; QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1" }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb monkey:" << process.errorString(); return false; } waitOrKill(process, 15000, "tryToStartApplication(monkey)"); const QString stdOut = QString::fromUtf8(process.readAllStandardOutput()); const QString stdErr = QString::fromUtf8(process.readAllStandardError()); const int exitCode = process.exitCode(); const QProcess::ExitStatus exitStatus = process.exitStatus(); // Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString, // из-за чего хвост (`Events injected` / `No activities found`) терялся. qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus=" << exitStatus << "exitCode=" << exitCode; logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut); logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr); const bool eventsInjected = stdOut.contains("Events injected: 1"); const bool hadNativeCrash = stdOut.contains("NativeCrash"); if (eventsInjected && !hadNativeCrash) { return true; } // Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code, // в отличие от monkey, который при пустом resolve просто молча печатает "No activities found". // Также используется когда monkey дал "Events injected: 1" но приложение упало (NativeCrash). if (!launcherActivity.isEmpty()) { if (hadNativeCrash) qWarning().noquote() << "[AdbUtils::tryToStartApplication] NativeCrash detected, retrying via am start -n" << launcherActivity; else qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;" << "пробую am start -n" << launcherActivity; QProcess am; am.setWorkingDirectory(adbDir()); am.start(adbPath(), {"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity}); am.waitForStarted(5000); am.waitForFinished(15000); const QString amOut = QString::fromUtf8(am.readAllStandardOutput()); const QString amErr = QString::fromUtf8(am.readAllStandardError()); qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode(); logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut); logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr); // `am start -W` печатает "Status: ok" при успехе. if (am.exitCode() == 0 && amOut.contains("Status: ok")) { return true; } } else { qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName << "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter"; } return false; } bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &packageName) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "am", "force-stop", packageName }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb force-stop:" << process.errorString(); return false; } waitOrKill(process, 15000, "tryToKillApplication"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } // idx - временный лог // adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml' QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) { const QString adb = adbPath(); for (int retry = 0; retry < 3; ++retry) { // 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android) bool dumpOk = false; for (int attempt = 0; attempt < 3; ++attempt) { if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) { dumpOk = true; break; } qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3"; QThread::msleep(1000); } if (!dumpOk) { qWarning() << "Failed to dump UI hierarchy after 3 attempts."; return {}; } // 2. Optionally pull and save the XML file if (screenshot) { if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml", QString("step_%1.xml").arg(idx)})) { qWarning() << "Failed to pull UIDump XML."; return {}; } } // 3. Read the XML content QString xmlOut; if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) { qWarning() << "Failed to read UIDump XML."; return {}; } // 4. Remove temp XML file startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // 5. Минимальная санитарная проверка: дамп не пустой, достаточного размера // и содержит хотя бы одну ноду с непустым text или content-desc. // Этого хватает, чтобы отсеять WebView-shell-only состояния Ozon // (там в шелле labeled нод вообще нет), и при этом не отбрасывать // экраны RN/Compose (Dushanbe, Black), где интерактив строится на // LinearLayout/View с content-desc, без Button/text/EditText. static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")"); const bool hasContent = reNonEmpty.match(xmlOut).hasMatch(); if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("saveXml(idx, cleanXml); } return cleanXml; } qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries"; return {}; } QString AdbUtils::getRawScreenDumpAsXml(const QString &deviceId) { const QString adb = adbPath(); // 1. Дампим иерархию (uiautomator может умирать — до 3 попыток) bool dumpOk = false; for (int attempt = 0; attempt < 3; ++attempt) { if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) { dumpOk = true; break; } QThread::msleep(500); } if (!dumpOk) { qWarning() << "[AdbUtils] getRawScreenDumpAsXml: uiautomator dump failed"; return {}; } // 2. Читаем QString xmlOut; if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) { qWarning() << "[AdbUtils] getRawScreenDumpAsXml: cat failed"; return {}; } startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // 3. Обрезка к началу XML; в отличие от getScreenDumpAsXml — без фильтра по контенту, // оператору важно видеть РЕАЛЬНОЕ состояние экрана (PIN, сплэш, WebView и т.д.). QByteArray xmlBytes = xmlOut.toUtf8(); if (const int start = xmlBytes.indexOf(" ..." QString out; if (startProcess(adbPath(), {"-s", deviceId, "shell", "ip", "route", "get", "1.1.1.1"}, &out)) { static const QRegularExpression reSrc(R"(src\s+(\d+\.\d+\.\d+\.\d+))"); if (const auto m = reSrc.match(out); m.hasMatch()) { ip = m.captured(1); } } } if (!ip.isEmpty()) { QMutexLocker lock(&g_ipCacheMutex); g_ipCache.insert(deviceId, ip); g_ipCacheTs.insert(deviceId, now); } return ip; } QByteArray AdbUtils::httpGet(const QString &ip, int port, const QString &path, int timeoutMs) { QTcpSocket sock; sock.connectToHost(ip, static_cast(port)); if (!sock.waitForConnected(timeoutMs)) { return {}; } const QByteArray req = "GET " + path.toUtf8() + " HTTP/1.0\r\n" "Host: " + ip.toUtf8() + "\r\n" "Connection: close\r\n\r\n"; sock.write(req); if (!sock.waitForBytesWritten(timeoutMs)) { return {}; } // Читаем до закрытия соединения (HTTP/1.0 + Connection: close). QByteArray resp; while (sock.state() == QAbstractSocket::ConnectedState || sock.bytesAvailable() > 0) { if (!sock.waitForReadyRead(timeoutMs)) { break; } resp += sock.readAll(); } resp += sock.readAll(); // Разбор: статус-строка + тело после "\r\n\r\n". const int headerEnd = resp.indexOf("\r\n\r\n"); if (headerEnd < 0) { return {}; } const QByteArray header = resp.left(headerEnd); if (!header.startsWith("HTTP/1.") || !header.contains(" 200")) { return {}; } return resp.mid(headerEnd + 4); } void AdbUtils::kickArcShot(const QString &deviceId) { const QString adb = adbPath(); // 1. Установлен ли arc-shot? (pm path вернёт "package:..." если да) QString pmOut; const bool installed = startProcess(adb, {"-s", deviceId, "shell", "pm", "path", "com.arc.shot"}, &pmOut) && pmOut.contains("package:"); // 2. Не установлен (удалили / новое устройство) — ставим bundled APK рядом с десктопом. if (!installed) { const QString apk = QCoreApplication::applicationDirPath() + "/assets/arc-shot.apk"; if (!QFileInfo::exists(apk)) { qWarning() << "[AdbUtils] arc-shot apk not found at" << apk << "— skip auto-install"; return; } QString err; if (!startProcess(adb, {"-s", deviceId, "install", "-r", apk}, nullptr, &err)) { qWarning() << "[AdbUtils] arc-shot install failed for" << deviceId << ":" << err.trimmed(); return; } // Доступ к /sdcard без диалога на устройстве. startProcess(adb, {"-s", deviceId, "shell", "appops", "set", "com.arc.shot", "MANAGE_EXTERNAL_STORAGE", "allow"}); qInfo().noquote() << "[AdbUtils] arc-shot auto-installed on" << deviceId; } // 3. Защита от заморозки фоновых приложений (Doze + вендорские freezer'ы вроде // TECNO HiOS/MIUI). adb-settable, без диалогов на устройстве. Без этого вендор // замораживает userspace сервиса — порт принимает TCP, но HTTP не отвечает. startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "deviceidle", "whitelist", "+com.arc.shot"}); startProcess(adb, {"-s", deviceId, "shell", "cmd", "appops", "set", "com.arc.shot", "RUN_ANY_IN_BACKGROUND", "allow"}); // 4. Поднять сервис. Свежая установка — через Activity (am start -n .../MainActivity): // надёжно снимает stopped-state, в котором у части вендоров FGS-старт молча // игнорируется. Activity без UI и сразу finish(), сервис стартует из onCreate. // Уже установленный (реанимация) — start-foreground-service, без Activity поверх // работающего банк-скрипта. QString err; const bool ok = !installed ? startProcess(adb, {"-s", deviceId, "shell", "am", "start", "-n", "com.arc.shot/.MainActivity"}, nullptr, &err) : startProcess(adb, {"-s", deviceId, "shell", "am", "start-foreground-service", "-n", "com.arc.shot/.ArcShotService"}, nullptr, &err); if (ok) { qInfo().noquote() << "[AdbUtils] arc-shot" << (installed ? "revive attempt" : "started") << "for" << deviceId; if (!installed) { // Свежая установка: сбрасываем http-бэкофф, чтобы http включился на следующем // кадре (а не ждал 60с) — устройство быстрее уходит с exec-out. QMutexLocker lock(&g_httpFailMutex); g_httpFailAt.remove(deviceId); } } else { qDebug().noquote() << "[AdbUtils] arc-shot kick failed for" << deviceId << ":" << err.trimmed(); } } bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) { const QString adb = adbPath(); struct Step { QStringList args; }; Step steps[] = { {{"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"}}, {{"-s", deviceId, "pull", "/sdcard/uidump.xml", name + ".xml"}}, {{"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}}, {{"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}} }; for (const auto &step : steps) { QString stdErr; if (!startProcess(adb, step.args, nullptr, &stdErr)) { qWarning() << "ADB command failed:" << step.args.join(' '); qWarning() << "Error:" << stdErr; return false; } } return true; } bool AdbUtils::makeTap(const QString &deviceId, const int x, const int y) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "input", "tap", QString::number(x), QString::number(y) }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb tap:" << process.errorString(); return false; } waitOrKill(process, 10000, "makeTap"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) { const QString adb = adbPath(); const QString imeId = QStringLiteral("com.android.adbkeyboard/.AdbIME"); // Проверяем, установлен ли ADB Keyboard QString installedList; startProcess(adb, {"-s", deviceId, "shell", "pm", "list", "packages", "com.android.adbkeyboard"}, &installedList); bool freshlyInstalled = false; if (!installedList.contains("com.android.adbkeyboard")) { const QString apkPath = QCoreApplication::applicationDirPath() + "/assets/ADBKeyboard.apk"; qDebug() << "[AdbUtils] ADB Keyboard not installed, installing from:" << apkPath; QString installErr; if (!startProcess(adb, {"-s", deviceId, "install", apkPath}, nullptr, &installErr)) { qWarning() << "[AdbUtils] Failed to install ADB Keyboard:" << installErr; return false; } qDebug() << "[AdbUtils] ADB Keyboard installed successfully"; freshlyInstalled = true; } // Пакет мог быть отключён производителем (pm disable-user). // Пытаемся включить; ошибку не считаем фатальной (на некоторых прошивках команда // отвергается, но сам IME при этом включается). { QString pmOut, pmErr; if (!startProcess(adb, {"-s", deviceId, "shell", "pm", "enable", "com.android.adbkeyboard"}, &pmOut, &pmErr)) { qWarning() << "[AdbUtils] pm enable com.android.adbkeyboard failed:" << pmErr.trimmed() << "(продолжаем)"; } } // ime enable с retry: сразу после 'pm install' InputMethodManagerService // может ещё не подхватить новый IME и возвращать "Unknown input method ... cannot be enabled". // Ретраим до появления IME в списке всех зарегистрированных (ime list -a). const int enableAttempts = freshlyInstalled ? 10 : 3; bool imeEnabled = false; QString lastErr; for (int attempt = 0; attempt < enableAttempts; ++attempt) { QString out, err; if (startProcess(adb, {"-s", deviceId, "shell", "ime", "enable", imeId}, &out, &err)) { qDebug() << "[AdbUtils] ime enable:" << out.trimmed(); imeEnabled = true; break; } lastErr = err.trimmed(); qDebug() << "[AdbUtils] ime enable attempt" << attempt + 1 << "/" << enableAttempts << "failed:" << lastErr << "— ждём, пока IMMS перечитает IME"; QThread::msleep(1000); } if (!imeEnabled) { qWarning() << "[AdbUtils] ime enable failed after" << enableAttempts << "attempts. Last error:" << lastErr; return false; } // Верификация: IME должен появиться в списке разрешённых { QString enabledList; startProcess(adb, {"-s", deviceId, "shell", "ime", "list", "-s"}, &enabledList); if (!enabledList.contains(imeId)) { qWarning() << "[AdbUtils] ADB Keyboard отсутствует в 'ime list -s' после 'ime enable'." << "Вывод:" << enabledList.trimmed() << "(прошивка/OEM может блокировать сторонние IME)"; return false; } } // ime set { QString out, err; if (!startProcess(adb, {"-s", deviceId, "shell", "ime", "set", imeId}, &out, &err)) { qWarning() << "[AdbUtils] ime set failed:" << err.trimmed(); return false; } qDebug() << "[AdbUtils] ime set:" << out.trimmed(); } // Верификация: активный IME должен совпасть с нашим { QString defaultIme; startProcess(adb, {"-s", deviceId, "shell", "settings", "get", "secure", "default_input_method"}, &defaultIme); const QString trimmed = defaultIme.trimmed(); if (!trimmed.contains(imeId)) { qWarning() << "[AdbUtils] default_input_method =" << trimmed << "— ожидали" << imeId << "(прошивка откатывает смену IME)"; return false; } qDebug() << "[AdbUtils] Active IME verified:" << trimmed; } return true; } bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) { QProcess process; process.setWorkingDirectory(adbDir()); // Кодируем UTF-8 → base64 и шлём через ADB_INPUT_B64. Это исключает любые проблемы // shell-кавычек на устройстве с пробелами, скобками и не-ASCII символами // (типа "Озон Банк (Ozon)"). ADBKeyboard декодирует base64 и вызывает commitText. const QString b64 = QString::fromLatin1(text.toUtf8().toBase64()); process.start(adbPath(), { "-s", deviceId, "shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64 }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString(); return false; } waitOrKill(process, 10000, "inputAdbIMEText"); qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode() << "text:" << text << "output:" << process.readAllStandardOutput().trimmed() << "error:" << process.readAllStandardError().trimmed(); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool AdbUtils::disableAdbIMEKeyboard(const QString &deviceId) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), {"-s", deviceId, "shell", "ime", "disable", "com.android.adbkeyboard/.AdbIME"}); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb ime disable:" << process.errorString(); return false; } waitOrKill(process, 10000, "disableAdbIMEKeyboard"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2) { return makeSwipe(deviceId, x1, y1, x2, y2, -1); } bool AdbUtils::makeSwipe(const QString &deviceId, const int x1, const int y1, const int x2, const int y2, int durationMs) { QStringList args = { "-s", deviceId, "shell", "input", "swipe", QString::number(x1), QString::number(y1), QString::number(x2), QString::number(y2) }; if (durationMs > 0) args << QString::number(durationMs); QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), args); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb swipe:" << process.errorString(); return false; } waitOrKill(process, 10000, "makeSwipe"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool AdbUtils::inputText(const QString &deviceId, const QString &text) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "input", "text", text }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb input text:" << process.errorString(); return false; } waitOrKill(process, 10000, "inputText"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } bool AdbUtils::hideKeyboard(const QString &deviceId) { // Метод 1: KEYCODE_ESCAPE (111) — работает на большинстве Android-устройств, // но игнорируется на некоторых прошивках (например, TECNO/HiOS). { QProcess p; p.setWorkingDirectory(adbDir()); p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "111"}); if (p.waitForStarted(5000)) p.waitForFinished(3000); } // Метод 2: KEYCODE_BACK (4) — Android посылает BACK сначала в IME; если // IME (клавиатура) видим — он его потребляет и закрывает, не уходя // на предыдущий экран. На TECNO/HiOS это часто единственный рабочий // вариант. Если клавиатура уже скрыта (после метода 1), BACK уйдёт // в Activity — нежелательно. Поэтому сначала проверим через dumpsys // input_method, видна ли всё ещё клавиатура. bool imeVisible = false; { QProcess p; p.setWorkingDirectory(adbDir()); p.start(adbPath(), {"-s", deviceId, "shell", "dumpsys", "input_method"}); if (p.waitForStarted(5000) && p.waitForFinished(5000)) { const QString out = QString::fromUtf8(p.readAllStandardOutput()); imeVisible = out.contains(QStringLiteral("mInputShown=true")) || out.contains(QStringLiteral("mServedInputConnection=")) // fallback heuristic && !out.contains(QStringLiteral("mInputShown=false")); } } if (imeVisible) { qDebug() << "[AdbUtils] hideKeyboard: ESC didn't dismiss IME, trying BACK"; QProcess p; p.setWorkingDirectory(adbDir()); p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "4"}); if (p.waitForStarted(5000)) p.waitForFinished(3000); } return true; } bool AdbUtils::goBack(const QString &deviceId) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "input", "keyevent", "4" }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString(); return false; } waitOrKill(process, 10000, "goBack"); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } QSet AdbUtils::getListApps(const QString &deviceId) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), { "-s", deviceId, "shell", "pm list", "packages", "-3" }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb pm list:" << process.errorString(); return {}; } waitOrKill(process, 15000, "getListApps"); const QString output = process.readAllStandardOutput(); QSet apps; const QStringList lines = output.split("\n"); for (int i = 0; i < lines.size(); ++i) { QString line = lines.at(i).trimmed().replace("package:", ""); if (line.isEmpty()) { continue; } apps.insert(line); } return apps; } QString AdbUtils::getDeviceTimezone(const QString &deviceId) { QString out; if (!startProcess(adbPath(), {"-s", deviceId, "shell", "getprop", "persist.sys.timezone"}, &out)) { return {}; } return out.trimmed(); } QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) { if (deviceId.isEmpty() || packageName.isEmpty()) { return {}; } QString out; if (!startProcess(adbPath(), {"-s", deviceId, "shell", "dumpsys", "package", packageName}, &out)) { return {}; } static const QRegularExpression re(R"(versionName=([^\s]+))"); const QRegularExpressionMatch m = re.match(out); return m.hasMatch() ? m.captured(1).trimmed() : QString(); } // Достаёт имя пакета из строки фокуса вида "... u0 com.bank.app/.MainActivity ...". static QString packageFromFocusLine(const QString &out, const QString &marker) { const qsizetype idx = out.indexOf(marker); if (idx < 0) { return {}; } const qsizetype end = out.indexOf(QChar('\n'), idx); const QString line = out.mid(idx, end < 0 ? -1 : end - idx); static const QRegularExpression rePkg(R"(\bu0\s+([A-Za-z0-9_.]+)/)"); const QRegularExpressionMatch m = rePkg.match(line); return m.hasMatch() ? m.captured(1) : QString(); } QString AdbUtils::getForegroundPackage(const QString &deviceId) { if (deviceId.isEmpty()) { return {}; } // mResumedActivity — реально активная (foreground) активность. QString out; if (startProcess(adbPath(), {"-s", deviceId, "shell", "dumpsys", "activity", "activities"}, &out)) { const QString pkg = packageFromFocusLine(out, "mResumedActivity"); if (!pkg.isEmpty()) { return pkg; } } // fallback: mCurrentFocus из dumpsys window (формат отличается между версиями Android). out.clear(); if (startProcess(adbPath(), {"-s", deviceId, "shell", "dumpsys", "window"}, &out)) { const QString pkg = packageFromFocusLine(out, "mCurrentFocus"); if (!pkg.isEmpty()) { return pkg; } } return {}; } bool AdbUtils::ensurePortraitOrientation(const QString &deviceId) { if (deviceId.isEmpty()) { return false; } const QString adb = adbPath(); QString out; if (!startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "input"}, &out)) { return false; // adb недоступен — пропускаем (best-effort) } // SurfaceOrientation: 0=0°(portrait) 1=90°(landscape) 2=180° 3=270°(landscape) static const QRegularExpression re(R"(SurfaceOrientation:\s*(\d))"); const QRegularExpressionMatch m = re.match(out); const int orient = m.hasMatch() ? m.captured(1).toInt() : 0; if (orient == 1 || orient == 3) { qInfo() << "[AdbUtils] device" << deviceId << "in landscape (orient" << orient << ") → forcing portrait"; // Автоповорот выключаем, иначе сенсор вернёт landscape. startProcess(adb, {"-s", deviceId, "shell", "settings", "put", "system", "accelerometer_rotation", "0"}); startProcess(adb, {"-s", deviceId, "shell", "settings", "put", "system", "user_rotation", "0"}); } return true; } void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) { const QString adb = adbPath(); // Проверяем, включён ли экран (mWakefulness=Awake) QString powerState; startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "power"}, &powerState); const bool isAwake = powerState.contains("mWakefulness=Awake"); if (!isAwake) { // Включаем экран startProcess(adb, {"-s", deviceId, "shell", "input", "keyevent", "KEYCODE_WAKEUP"}); QThread::msleep(500); qDebug() << "[AdbUtils] unlockScreen: woke up device" << deviceId; } // Проверяем, залочен ли экран (mDreamingSleepToken / mShowingLockscreen) QString wmState; startProcess(adb, {"-s", deviceId, "shell", "dumpsys", "window"}, &wmState); const bool isLocked = wmState.contains("mDreamingSleepToken") || wmState.contains("mShowingLockscreen=true") || wmState.contains("StatusBar"); if (isLocked) { // Свайп вверх для разблокировки const int x = screenWidth / 2; makeSwipe(deviceId, x, screenHeight * 3 / 4, x, screenHeight / 4); QThread::msleep(500); qDebug() << "[AdbUtils] unlockScreen: swiped to unlock" << deviceId; } }