#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", ""); } // Логирует длинный вывод построчно с префиксом, иначе 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; } process.waitForFinished(); 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); if (stdOut.contains("Events injected: 1")) { return true; } // Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code, // в отличие от monkey, который при пустом resolve просто молча печатает "No activities found". if (!launcherActivity.isEmpty()) { 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; } 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. Минимальная санитарная проверка: дамп не пустой, достаточного размера // и содержит хотя бы одну ноду с непустым 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(" 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(); } 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 {}; } 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; } }