#include "AdbUtils.h" #include #include #include #include #include #include #include #include "dump/TaskDumpRecorder.h" 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; } // 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; } 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 {}; } process.waitForFinished(); QString pid = process.readAllStandardOutput(); return pid.replace("\n", ""); } bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) { 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; } process.waitForFinished(); const QString output = process.readAllStandardOutput(); // Если процесс запустился, там будет такая строка. // Если нет, то: "** No activities found to run, monkey aborted." if (output.contains("Events injected: 1")) { return true; } // Тут надо в логи писать, почему не стартует qDebug() << "Start app output:" << output; 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; } process.waitForFinished(); 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. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")"); const bool hasContent = reNonEmpty.match(xmlOut).hasMatch(); // Дополнительно: ловим "WebView-shell-only" состояние, когда AT отдаёт // только обёртку WebView и нижнюю нав-панель Ozon (FINANCE_TAB*), // но Svelte-контент ещё не отрисован. Признак полезного среза — // хотя бы один Button с непустым text ИЛИ EditText. Нав-табы у Ozon // имеют пустой text (только content-desc), поэтому регекс их не зачтёт. static const QRegularExpression reUsefulUi( R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")" ); const bool hasUsefulUi = reUsefulUi.match(xmlOut).hasMatch(); if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("saveXml(idx, cleanXml); } return cleanXml; } qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries"; return {}; } bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) { const QString adb = adbPath(); struct Step { QStringList args; }; Step steps[] = { {{"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"}}, {{"-s", deviceId, "pull", "/sdcard/screen.png", name + ".png"}}, {{"-s", deviceId, "shell", "rm", "/sdcard/screen.png"}} }; 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; } QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) { QProcess process; process.setWorkingDirectory(adbDir()); process.start(adbPath(), {"-s", deviceId, "exec-out", "screencap", "-p"}); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] captureScreenshotBytes failed to start for" << deviceId << "error:" << process.errorString(); return {}; } if (!process.waitForFinished(10000) || process.exitCode() != 0) { qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId; return {}; } return process.readAllStandardOutput(); } 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; } process.waitForFinished(); 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()); process.start(adbPath(), { "-s", deviceId, "shell", "am broadcast -a ADB_INPUT_TEXT --es msg '" + text + "'" }); if (!process.waitForStarted(5000)) { qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString(); return false; } process.waitForFinished(); qDebug() << "[AdbUtils] inputAdbIMEText result:" << process.exitCode() << "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; } process.waitForFinished(); 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; } process.waitForFinished(); 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; } process.waitForFinished(); 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; } process.waitForFinished(); 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 {}; } process.waitForFinished(); 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(); } 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; } }