848 lines
36 KiB
C++
848 lines
36 KiB
C++
#include "AdbUtils.h"
|
||
#include <QProcess>
|
||
#include <qDebug>
|
||
#include <QString>
|
||
#include <QCoreApplication>
|
||
#include <QFileInfo>
|
||
#include <QRegularExpression>
|
||
#include <QThread>
|
||
|
||
#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;
|
||
}
|
||
|
||
// Ждёт завершения процесса с таймаутом. Раньше здесь стоял бесконечный
|
||
// 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<QPair<QString, QString> > 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<QPair<QString, QString> > 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 {};
|
||
}
|
||
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<QString, QString> 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("<?xml")
|
||
|| xmlOut.size() < 500 || !hasContent) {
|
||
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
|
||
<< "hasContent=" << hasContent
|
||
<< "), retry" << (retry + 1) << "of 3";
|
||
QThread::msleep(1500);
|
||
continue;
|
||
}
|
||
|
||
// 6. Screenshot (if needed)
|
||
if (screenshot) {
|
||
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
|
||
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
|
||
QString("screen_step_%1.png").arg(idx)});
|
||
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
|
||
}
|
||
|
||
// 7. Обрезка к началу XML
|
||
QByteArray xmlBytes = xmlOut.toUtf8();
|
||
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
|
||
xmlBytes = xmlBytes.mid(start);
|
||
}
|
||
const QString cleanXml = QString::fromUtf8(xmlBytes);
|
||
if (auto *rec = TaskDumpRecorder::current()) {
|
||
rec->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("<?xml"); start != -1) {
|
||
xmlBytes = xmlBytes.mid(start);
|
||
}
|
||
return QString::fromUtf8(xmlBytes);
|
||
}
|
||
|
||
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;
|
||
}
|
||
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<QString> 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<QString> 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;
|
||
}
|
||
}
|