1
0
forked from BRT/arc
arc/android/adb/AdbUtils.cpp

875 lines
38 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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 {};
}
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;
}
}