Revision control

Copy as Markdown

// Copyright (C) 2019 BogDan Vatra <bogdan@kde.org>
// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
#include <QtCore/QCommandLineParser>
#include <QtCore/QCoreApplication>
#include <QtCore/QDeadlineTimer>
#include <QtCore/qdebug.h>
#include <QtCore/QDir>
#include <QtCore/QHash>
#include <QtCore/QLockFile>
#include <QtCore/QProcess>
#include <QtCore/QProcessEnvironment>
#include <QtCore/QRegularExpression>
#include <QtCore/QSet>
#include <QtCore/QStandardPaths>
#include <QtCore/QThread>
#include <QtCore/QXmlStreamReader>
#include <QtCore/QFileInfo>
#include <QtCore/QTemporaryFile>
#include <atomic>
#include <chrono>
#include <csignal>
#include <QtCore/qxpfunctional.h>
#include <optional>
#if defined(Q_OS_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
using namespace Qt::StringLiterals;
// QTest-based test processes may exit with up to 127 for normal test failures
static constexpr int HIGHEST_QTEST_EXITCODE = 127;
// Something went wrong in androidtestrunner, in general
static constexpr int EXIT_ERROR = 254;
// More specific exit codes for failures in androidtestrunner:
static constexpr int EXIT_NOEXITCODE = 253; // Failed to transfer exit code from device
static constexpr int EXIT_ANR = 252; // Android ANR error (Application Not Responding)
static constexpr int EXIT_NORESULTS = 251; // Failed to transfer result files from device
static constexpr int EXIT_DEVICE_GONE = 250; // Device disconnected mid-test
struct Options
{
bool verbose = false;
bool skipAddInstallRoot = false;
int timeoutSecs = 600; // 10 minutes
int resultsPullRetries = 3;
QString buildPath;
QString manifestPath;
QString adbCommand{"adb"_L1};
QString bundletoolPath;
QString serial;
QString makeCommand;
QString package;
QString activity;
QStringList permissions;
QStringList testArgsList;
QString stdoutFileName;
QHash<QString, QString> outFiles;
QStringList amStarttestArgs;
QString packagePath;
QString ndkStackPath;
QList<QStringList> preTestRunAdbCommands;
bool showLogcatOutput = false;
std::optional<QProcess> stdoutLogger;
};
static Options g_options;
struct TestInfo
{
int sdkVersion = -1;
int pid = -1;
QString userId;
std::atomic<bool> isPackageInstalled { false };
std::atomic<bool> isTestRunnerInterrupted { false };
std::atomic<bool> deviceGone { false };
std::atomic<qint64> stdoutLoggerPid { 0 };
};
// sigHandler reads/writes these from arbitrary signal contexts.
static_assert(std::atomic<bool>::is_always_lock_free);
static_assert(std::atomic<qint64>::is_always_lock_free);
static TestInfo g_testInfo;
// QTest-based processes return 0 if all tests PASSed, or the number of FAILs up to 127.
// Other exitcodes signify abnormal termination and are system-dependent.
static bool isTestExitCodeNormal(const int ec)
{
return (ec >= 0 && ec <= HIGHEST_QTEST_EXITCODE);
}
static QByteArray execCommand(const QString &program, const QStringList &args, bool verbose = false,
std::chrono::milliseconds timeout = std::chrono::milliseconds(-1))
{
const auto command = program + " "_L1 + args.join(u' ');
if (verbose && g_options.verbose)
fprintf(stdout,"Execute %s.\n", command.toUtf8().constData());
QProcess process;
process.start(program, args);
if (!process.waitForStarted()) {
qCritical("Cannot execute command %s.", qPrintable(command));
return QByteArray();
}
const bool finished = timeout.count() < 0
? process.waitForFinished()
: process.waitForFinished(static_cast<int>(timeout.count()));
if (!finished) {
qCritical("Execution of command %s timed out.", qPrintable(command));
process.kill();
process.waitForFinished();
// Beyond the kill's own "killed by signal", stderr may hint at the hang.
const QByteArray stdErr = process.readAllStandardError();
if (!stdErr.isEmpty())
qWarning().noquote() << stdErr.trimmed();
return QByteArray();
}
const auto stdOut = process.readAllStandardOutput();
const auto stdErr = process.readAllStandardError();
if (process.exitCode() != 0) {
// Surface why a notable (verbose) command failed; quiet polls stay quiet.
if (verbose && !stdOut.isEmpty())
qWarning().noquote() << stdOut.trimmed();
if (!stdErr.isEmpty())
qWarning().noquote() << stdErr.trimmed();
return QByteArray();
}
if (verbose && g_options.verbose)
fprintf(stdout, "%s\n", stdOut.constData());
// Non-null even when empty so a silent success is distinct from failure.
return stdOut.isNull() ? QByteArray("") : stdOut;
}
static QStringList adbArgsWithSerial(const QStringList &args)
{
if (g_options.serial.isEmpty())
return args;
return QStringList{ "-s"_L1, g_options.serial } + args;
}
static QByteArray execAdbCommand(const QStringList &args, bool verbose = true)
{
return execCommand(g_options.adbCommand, adbArgsWithSerial(args), verbose);
}
static QByteArray execBundletoolCommand(const QStringList &args, bool verbose = true)
{
QString java("java"_L1);
QStringList argsFull = QStringList() << "-jar"_L1 << g_options.bundletoolPath << args;
return execCommand(java, argsFull, verbose);
}
static bool setPackagePath(const QString &path)
{
if (!g_options.packagePath.isEmpty()) {
qCritical("Only one of --apk or --aab may be set, and only once.");
return false;
}
g_options.packagePath = path;
return true;
}
static bool collectPackagePaths(const QStringList &apkValues, const QStringList &aabValues)
{
if (apkValues.size() > 1) {
qCritical("--apk specified %lld times; only one APK path is supported.",
static_cast<long long>(apkValues.size()));
return false;
}
if (aabValues.size() > 1) {
qCritical("--aab specified %lld times; only one AAB path is supported.",
static_cast<long long>(aabValues.size()));
return false;
}
if (!apkValues.isEmpty() && !setPackagePath(apkValues.first()))
return false;
if (!aabValues.isEmpty() && !setPackagePath(aabValues.first()))
return false;
return true;
}
static QByteArray execCommand(const QString &command, bool verbose = true,
std::chrono::milliseconds timeout = std::chrono::milliseconds(-1))
{
auto args = QProcess::splitCommand(command);
const auto program = args.takeFirst();
return execCommand(program, args, verbose, timeout);
}
// Split argv into our and test options, then re-join as "<ours> -- <test args>".
static QStringList splitOwnAndTestArgs(const QStringList &args,
const QSet<QString> &knownOpts,
const QSet<QString> &valueOpts)
{
QStringList ownArgs;
QStringList testArgs;
ownArgs.reserve(args.size() + 1);
for (int i = 0; i < args.size(); ++i) {
const QString &arg = args.at(i);
if (i == 0) {
ownArgs << arg;
continue;
}
if (arg == "--"_L1) {
// Caller already split things explicitly, so forward the rest as-is.
for (int j = i + 1; j < args.size(); ++j)
testArgs << args.at(j);
break;
}
if (arg.startsWith("--"_L1) && arg.size() > 2) {
QString name = arg.mid(2);
const qsizetype eqIdx = name.indexOf(u'=');
if (eqIdx != -1)
name.truncate(eqIdx);
if (!knownOpts.contains(name)) {
qCritical("Unknown option '--%s'. Use '--' to forward test arguments.",
qPrintable(name));
return {};
}
ownArgs << arg;
if (eqIdx == -1 && valueOpts.contains(name)) {
if (i + 1 >= args.size()) {
qCritical("Option --%s requires a value.", qPrintable(name));
return {};
}
ownArgs << args.at(++i);
}
continue;
}
// Keep scanning, because CMake appends our flags after the test's flags.
testArgs << arg;
}
if (!testArgs.isEmpty()) {
ownArgs << "--"_L1;
ownArgs += testArgs;
}
return ownArgs;
}
static QString ndkStackPath()
{
#ifdef Q_OS_WIN
const QStringList candidates = { "ndk-stack.cmd"_L1, "ndk-stack.bat"_L1, "ndk-stack"_L1 };
#else
const QStringList candidates = { "ndk-stack"_L1 };
#endif
for (const char *var : { "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME" }) {
const QString ndkPath = qEnvironmentVariable(var);
if (ndkPath.isEmpty())
continue;
for (const QString &name : candidates) {
const QString path = ndkPath + QDir::separator() + name;
if (QFile::exists(path))
return path;
}
}
return {};
}
static bool parseOptions()
{
QCommandLineParser parser;
parser.setApplicationDescription(
"Runs a Qt for Android test on an emulator or a device. Specify a "
"device via --serial, ANDROID_SERIAL or ANDROID_DEVICE_SERIAL.\n"
"\n"
"Exit codes:\n"
" 0-127 QTest exit code (number of failed test functions)\n"
" 250 EXIT_DEVICE_GONE device disconnected\n"
" 251 EXIT_NORESULTS result files could not be pulled\n"
" 252 EXIT_ANR Android Not Responding\n"
" 253 EXIT_NOEXITCODE exit code could not be read from device\n"
" 254 EXIT_ERROR generic runner failure"_L1);
QCommandLineOption pathOpt(
"path"_L1, "Path where the Android Gradle package is built."_L1, "path"_L1);
QCommandLineOption makeOpt(
"make"_L1, "make command to build the APK."_L1, "command"_L1);
QCommandLineOption apkOpt(
"apk"_L1, "Test APK path. Built via --make if absent."_L1, "path"_L1);
QCommandLineOption aabOpt(
"aab"_L1, "Test AAB path. Built via --make if absent."_L1, "path"_L1);
QCommandLineOption bundletoolOpt(
"bundletool"_L1, "Path to Android bundletool jar."_L1, "path"_L1);
QCommandLineOption manifestOpt(
"manifest"_L1, "Custom AndroidManifest.xml path."_L1, "path"_L1);
QCommandLineOption adbOpt(
"adb"_L1, "Path to adb. Falls back to $PATH."_L1, "command"_L1, "adb"_L1);
QCommandLineOption serialOpt(
"serial"_L1,
"Android device serial. Overrides ANDROID_SERIAL / ANDROID_DEVICE_SERIAL."_L1,
"serial"_L1);
QCommandLineOption activityOpt(
"activity"_L1, "Activity to run. Defaults to the first in the manifest."_L1, "name"_L1);
QCommandLineOption timeoutOpt(
"timeout"_L1, "Test timeout in seconds (default 600)."_L1, "seconds"_L1, "600"_L1);
QCommandLineOption preTestAdbOpt("pre-test-adb-command"_L1,
"adb command to run after install, before test."_L1, "command"_L1);
QCommandLineOption skipInstallRootOpt(
"skip-install-root"_L1,
"Don't append INSTALL_ROOT=... to --make. Only honored for make-family "
"tools (make, gmake, nmake, mingw32-make, jom)."_L1);
QCommandLineOption ndkStackOpt(
"ndk-stack"_L1, "Path to ndk-stack (default: ndk-stack under $ANDROID_NDK_ROOT "
"or $ANDROID_NDK_HOME)."_L1, "path"_L1);
QCommandLineOption showLogcatOpt(
"show-logcat"_L1, "Print logcat output (+ system_server on ANR)."_L1);
QCommandLineOption verboseOpt(
"verbose"_L1, "Print extra information."_L1);
QCommandLineOption helpOpt("help"_L1, "Show this help message."_L1);
const QList<QCommandLineOption> ourOptions = {
pathOpt, makeOpt, apkOpt, aabOpt,
bundletoolOpt, manifestOpt,
adbOpt, serialOpt,
activityOpt, timeoutOpt, preTestAdbOpt,
skipInstallRootOpt,
ndkStackOpt, showLogcatOpt, verboseOpt,
helpOpt,
};
parser.addOptions(ourOptions);
parser.addPositionalArgument("testargs"_L1, "Arguments forwarded to the test."_L1,
"[-- TESTARGS]"_L1);
QSet<QString> knownOpts;
QSet<QString> valueOpts;
for (const QCommandLineOption &opt : ourOptions) {
const bool takesValue = !opt.valueName().isEmpty();
for (const QString &name : opt.names()) {
knownOpts.insert(name);
if (takesValue)
valueOpts.insert(name);
}
}
const QStringList processedArgs =
splitOwnAndTestArgs(QCoreApplication::arguments(), knownOpts, valueOpts);
if (processedArgs.isEmpty())
return false;
if (!parser.parse(processedArgs)) {
qCritical("%s", qPrintable(parser.errorText()));
return false;
}
if (parser.isSet(helpOpt))
parser.showHelp(0);
g_options.buildPath = parser.value(pathOpt);
g_options.makeCommand = parser.value(makeOpt);
if (!collectPackagePaths(parser.values(apkOpt), parser.values(aabOpt)))
return false;
g_options.adbCommand = parser.value(adbOpt);
g_options.activity = parser.value(activityOpt);
g_options.serial = parser.value(serialOpt);
bool timeoutOk = false;
g_options.timeoutSecs = parser.value(timeoutOpt).toInt(&timeoutOk);
if (!timeoutOk || g_options.timeoutSecs <= 0) {
qCritical("--timeout must be a positive integer (got '%s').",
qPrintable(parser.value(timeoutOpt)));
return false;
}
g_options.skipAddInstallRoot = parser.isSet(skipInstallRootOpt);
g_options.showLogcatOutput = parser.isSet(showLogcatOpt);
g_options.ndkStackPath = parser.value(ndkStackOpt);
g_options.verbose = parser.isSet(verboseOpt);
g_options.manifestPath = parser.value(manifestOpt);
g_options.bundletoolPath = parser.value(bundletoolOpt);
for (const QString &cmd : parser.values(preTestAdbOpt))
g_options.preTestRunAdbCommands += QProcess::splitCommand(cmd);
g_options.testArgsList = parser.positionalArguments();
static const QStringList makeNames = {
"make"_L1, "gmake"_L1, "nmake"_L1, "mingw32-make"_L1, "jom"_L1,
};
const QStringList makeParts = QProcess::splitCommand(g_options.makeCommand);
const QString makeBaseName = QFileInfo(makeParts.value(0)).baseName();
const bool makeIsGnuMakeFamily = makeNames.contains(makeBaseName, Qt::CaseInsensitive);
if (!g_options.skipAddInstallRoot && makeIsGnuMakeFamily) {
g_options.makeCommand = "%1 INSTALL_ROOT=%2 install"_L1
.arg(g_options.makeCommand, QDir::toNativeSeparators(g_options.buildPath));
}
if (g_options.buildPath.isEmpty() || g_options.packagePath.isEmpty()) {
qCritical("--path and --apk (or --aab) are required.");
fputs(qPrintable(parser.helpText()), stderr);
return false;
}
if (!g_options.packagePath.endsWith(".apk"_L1)
&& !g_options.packagePath.endsWith(".aab"_L1)) {
qCritical("Package path '%s' must end with .apk or .aab.",
qPrintable(g_options.packagePath));
return false;
}
if (g_options.packagePath.endsWith(".aab"_L1)) {
if (g_options.bundletoolPath.isEmpty()) {
qCritical("--aab requires --bundletool to locate the bundletool jar.");
return false;
}
if (!QFile::exists(g_options.bundletoolPath)) {
qCritical("--bundletool path '%s' does not exist.",
qPrintable(g_options.bundletoolPath));
return false;
}
}
if (g_options.serial.isEmpty())
g_options.serial = qEnvironmentVariable("ANDROID_SERIAL");
if (g_options.serial.isEmpty())
g_options.serial = qEnvironmentVariable("ANDROID_DEVICE_SERIAL");
if (g_options.ndkStackPath.isEmpty())
g_options.ndkStackPath = ndkStackPath();
return true;
}
static QString gradleInitScriptPath()
{
static QString path;
if (!path.isEmpty())
return path;
QTemporaryFile initScript;
initScript.setAutoRemove(false);
if (!initScript.open())
return {};
initScript.write(
"gradle.projectsEvaluated {\n"
" def prop = gradle.rootProject.findProperty(\"property\")\n"
" def target = gradle.rootProject.findProject(':app') ?: gradle.rootProject\n"
" for (part in prop.tokenize('.')) {\n"
" target = target.\"${part}\"\n"
" }\n"
" println target\n"
"}\n"
"rootProject { tasks.register(\"printProjectProperty\") }\n");
initScript.close();
path = initScript.fileName();
qAddPostRoutine([] { QFile::remove(path); });
return path;
}
// Query a Gradle project's property using dot-separated path notation e.g. "android.namespace".
static QString getGradleProjectProperty(const QString &androidBuildDir, const QString &property)
{
#ifdef Q_OS_WIN
QString gradlew = androidBuildDir + "/gradlew.bat"_L1;
#else
QString gradlew = androidBuildDir + "/gradlew"_L1;
#endif
if (!QFile::exists(gradlew))
return {};
const QString scriptPath = gradleInitScriptPath();
if (scriptPath.isEmpty())
return {};
QProcess process;
process.setWorkingDirectory(androidBuildDir);
process.start(gradlew, { "-q"_L1, "--init-script"_L1, scriptPath,
"-Pproperty="_L1 + property, "printProjectProperty"_L1 });
if (!process.waitForFinished()) {
qWarning("gradlew '%s' timed out.", qPrintable(property));
process.kill();
process.waitForFinished();
return {};
}
if (process.exitCode() != 0) {
qWarning("gradlew '%s' exited with %d:\n%s", qPrintable(property),
process.exitCode(), process.readAllStandardError().constData());
return {};
}
return QString::fromUtf8(process.readAllStandardOutput()).trimmed();
}
static bool processAndroidManifest()
{
if (!g_options.manifestPath.isEmpty()) {
if (!QFile::exists(g_options.manifestPath)) {
qCritical("--manifest path '%s' does not exist.",
qPrintable(g_options.manifestPath));
return false;
}
} else {
const QStringList candidates = {
g_options.buildPath + "/AndroidManifest.xml"_L1,
g_options.buildPath + "/app/AndroidManifest.xml"_L1
};
for (const QString &candidate : candidates) {
if (QFile::exists(candidate)) {
g_options.manifestPath = candidate;
break;
}
}
}
if (g_options.manifestPath.isEmpty()) {
qCritical("Unable to find AndroidManifest.xml at '%s'.", qPrintable(g_options.buildPath));
return false;
}
QFile androidManifestXml(g_options.manifestPath);
if (!androidManifestXml.open(QIODevice::ReadOnly)) {
qCritical("Unable to read android manifest '%s'", qPrintable(g_options.manifestPath));
return false;
}
QXmlStreamReader reader(&androidManifestXml);
while (!reader.atEnd()) {
reader.readNext();
if (!reader.isStartElement())
continue;
if (reader.name() == "activity"_L1 && g_options.activity.isEmpty())
g_options.activity = reader.attributes().value("android:name"_L1).toString();
else if (reader.name() == "uses-permission"_L1)
g_options.permissions.append(reader.attributes().value("android:name"_L1).toString());
}
return true;
}
static QStringList queryDangerousPermissions()
{
const QStringList args({ "shell"_L1, "dumpsys"_L1, "package"_L1, "permissions"_L1 });
const QByteArray output = execAdbCommand(args, false);
if (output.isNull()) {
qWarning("Failed to query permissions via dumpsys");
return {};
}
/*
* Permissions section from this command look like:
*
* Permission [android.permission.INTERNET] (c8cafdc):
* sourcePackage=android
* uid=1000 gids=[3003] type=0 prot=normal|instant
* perm=PermissionInfo{5f5bfbb android.permission.INTERNET}
* flags=0x0
*/
const static QRegularExpression regex("^\\s*Permission\\s+\\[([^\\]]+)\\]\\s+\\([^)]+\\):"_L1);
QStringList dangerousPermissions;
QString currentPerm;
const QStringList lines = QString::fromUtf8(output).split(u'\n');
for (const QString &line : lines) {
QRegularExpressionMatch match = regex.match(line);
if (match.hasMatch()) {
currentPerm = match.captured(1);
continue;
}
if (currentPerm.isEmpty())
continue;
int protIndex = line.indexOf("prot="_L1);
if (protIndex == -1)
continue;
QString protectionTypes = line.mid(protIndex + 5).trimmed();
if (protectionTypes.contains("dangerous"_L1, Qt::CaseInsensitive))
dangerousPermissions.append(currentPerm);
currentPerm.clear();
}
return dangerousPermissions;
}
static QString deviceOutputFileName(const QString &format, const QString &hostPath)
{
return hostPath.isEmpty() ? "stdout.%1"_L1.arg(format) : QFileInfo(hostPath).fileName();
}
static void setOutputFile(QString file, QString format)
{
if (format.isEmpty())
format = "txt"_L1;
if (file == u'-')
file.clear();
if (file.isEmpty()) {
if (!g_options.outFiles.contains(format))
g_options.outFiles.insert(format, QString());
g_options.stdoutFileName = deviceOutputFileName(format, g_options.outFiles.value(format));
} else {
g_options.outFiles[format] = file;
}
}
static bool parseTestArgs()
{
QRegularExpression oldFormats{"^-(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1};
QRegularExpression newLoggingFormat{"^(.*),(txt|csv|xunitxml|junitxml|xml|lightxml|teamcity|tap)$"_L1};
QString file;
QString logType;
QStringList unhandledArgs;
for (int i = 0; i < g_options.testArgsList.size(); ++i) {
const QString &arg = g_options.testArgsList[i].trimmed();
if (arg == "--"_L1)
continue;
if (arg == "-o"_L1 || arg == "--output"_L1) {
if (i >= g_options.testArgsList.size() - 1)
return false; // missing file argument
const auto &filePath = g_options.testArgsList[++i];
const auto match = newLoggingFormat.match(filePath);
if (!match.hasMatch()) {
file = filePath;
} else {
const auto capturedTexts = match.capturedTexts();
setOutputFile(capturedTexts.at(1), capturedTexts.at(2));
}
} else {
auto match = oldFormats.match(arg);
if (match.hasMatch()) {
logType = match.capturedTexts().at(1);
} else {
// Use triple literal quotes so that QProcess::splitCommand() in androidjnimain.cpp
// keeps quotes characters inside the string.
QString quotedArg = QString(arg).replace("\""_L1, "\\\"\\\"\\\""_L1);
// Add escaped double quote character so that args with spaces are treated as one.
unhandledArgs << " \\\"%1\\\""_L1.arg(quotedArg);
}
}
}
if (g_options.outFiles.isEmpty() || !file.isEmpty() || !logType.isEmpty())
setOutputFile(file, logType);
QString testAppArgs;
for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.constEnd(); ++it)
testAppArgs += "-o %1,%2 "_L1.arg(deviceOutputFileName(it.key(), it.value()), it.key());
testAppArgs += unhandledArgs.join(u' ').trimmed();
testAppArgs = "\"%1\""_L1.arg(testAppArgs.trimmed());
const QString activityName = "%1/%2"_L1.arg(g_options.package).arg(g_options.activity);
// Pass over any qt or testlib env vars if set
QStringList testEnvVarArgs;
const QStringList envVarsList = QProcessEnvironment::systemEnvironment().toStringList();
for (const QString &var : envVarsList) {
if (!var.startsWith("QTEST_"_L1) && !var.startsWith("QT_"_L1))
continue;
const qsizetype index = var.indexOf(u'=');
if (index < 0)
continue;
const QString key = var.left(index);
QString escapedValue = var.mid(index + 1);
escapedValue.replace("'"_L1, "'\\''"_L1);
const QString value = "'%1'"_L1.arg(escapedValue);
testEnvVarArgs << "-e"_L1 << ("extraenvvars_"_L1 + key) << value;
}
g_options.amStarttestArgs = { "shell"_L1, "am"_L1, "start"_L1, "-W"_L1,
"-n"_L1, activityName,
"-e"_L1, "applicationArguments"_L1, testAppArgs };
g_options.amStarttestArgs.append(testEnvVarArgs);
return true;
}
static int getPid(const QString &package)
{
const QByteArray output = execAdbCommand({ "shell"_L1, "pidof"_L1, "-s"_L1, package }, false);
if (output.isNull())
return -1;
bool ok = false;
// Android 9's toybox ignores -s and lists every match; a fork()ing test has two.
const int pid = output.simplified().split(' ').constFirst().toInt(&ok);
return ok && pid > 0 ? pid : -1;
}
static QString runCommandAsUserArgs(const QString &cmd)
{
return "run-as %1 --user %2 %3"_L1.arg(g_options.package, g_testInfo.userId, cmd);
}
// Returns nullopt when `adb devices` itself failed, so callers don't confuse
// a transient daemon hiccup with "device disconnected".
static std::optional<QStringList> runningDevices()
{
const QByteArray output = execAdbCommand({ "devices"_L1 }, false);
if (output.isNull())
return std::nullopt;
QStringList devices;
for (const QByteArray &line : output.split(u'\n')) {
if (line.contains("\tdevice"_L1))
devices.append(QString::fromUtf8(line.split(u'\t').first()));
}
return devices;
}
static bool deviceDisconnected()
{
const auto devices = runningDevices();
return devices && !devices->contains(g_options.serial);
}
static bool isRunning() {
if (g_testInfo.deviceGone.load())
return false;
if (getPid(g_options.package) > 0)
return true;
// No pid means the test exited, but adb failing looks the same; check the
// device list to tell them apart and flag a disconnect when warranted.
if (!g_options.serial.isEmpty() && deviceDisconnected())
g_testInfo.deviceGone.store(true);
return false;
}
static bool pollUntil(qxp::function_ref<bool() const> predicate,
QDeadlineTimer deadline,
std::chrono::nanoseconds interval)
{
do {
if (predicate())
return true;
QThread::sleep(interval);
} while (!deadline.hasExpired() && !g_testInfo.isTestRunnerInterrupted.load());
if (g_testInfo.isTestRunnerInterrupted.load())
return false;
// Predicate may have flipped during the final sleep; check once more.
return predicate();
}
static void waitForStarted()
{
using namespace std::chrono_literals;
// Grab the pid for logcat filtering if pidof catches it, but don't block
// on a short-lived process that waitForFinished tracks by presence anyway
pollUntil([]() {
const int pid = getPid(g_options.package);
if (pid > 0)
g_testInfo.pid = pid;
return pid > 0 || !isRunning();
}, QDeadlineTimer(10s), 100ms);
}
static bool waitForLoggingStarted()
{
using namespace std::chrono_literals;
if (g_options.stdoutFileName.isEmpty())
return false;
const QString existsCmd = "head -c 0 files/%1 2>/dev/null"_L1.arg(g_options.stdoutFileName);
const QStringList adbExistsCmd = { "shell"_L1, runCommandAsUserArgs(existsCmd) };
auto fileExists = [&]() { return !execAdbCommand(adbExistsCmd, false).isNull(); };
// Wait for the output file, but stop early if the test exits first
pollUntil([&]() { return fileExists() || !isRunning(); }, QDeadlineTimer(5s), 25ms);
return fileExists();
}
static bool setupStdoutLogger()
{
// Empty stdoutFileName means file-only output; nothing to stream live.
if (g_options.stdoutFileName.isEmpty())
return true;
const QString tailPipeCmd = "tail -n +1 -f 'files/%1'"_L1.arg(g_options.stdoutFileName);
const QStringList adbTailCmd = { "shell"_L1, runCommandAsUserArgs(tailPipeCmd) };
g_options.stdoutLogger.emplace();
g_options.stdoutLogger->setProcessChannelMode(QProcess::ForwardedOutputChannel);
g_options.stdoutLogger->start(g_options.adbCommand, adbArgsWithSerial(adbTailCmd));
g_testInfo.stdoutLoggerPid.store(g_options.stdoutLogger->processId());
if (!g_options.stdoutLogger->waitForStarted()) {
g_testInfo.stdoutLoggerPid.store(0);
qCritical() << "Error: failed to run adb command to fetch stdout test results.";
g_options.stdoutLogger = std::nullopt;
return false;
}
return true;
}
static bool stopStdoutLogger()
{
if (!g_options.stdoutLogger.has_value())
return true;
if (g_options.stdoutLogger->state() == QProcess::NotRunning) {
// sigHandler already SIGTERM'd the logger; that's expected.
if (g_testInfo.isTestRunnerInterrupted.load())
return true;
qCritical() << "The stdout logger process was terminated unexpectedly, "
"It might have been terminated by an external process";
return false;
}
g_options.stdoutLogger->terminate();
g_testInfo.stdoutLoggerPid.store(0);
if (!g_options.stdoutLogger->waitForFinished(5000)) {
g_options.stdoutLogger->kill();
g_options.stdoutLogger->waitForFinished();
qCritical() << "Error: adb test results tail command timed out.";
return false;
}
return true;
}
static void waitForFinished()
{
using namespace std::chrono_literals;
const bool finished = pollUntil([]() { return !isRunning(); },
QDeadlineTimer(g_options.timeoutSecs * 1s), 100ms);
if (!finished && !g_testInfo.isTestRunnerInterrupted.load())
qWarning() << "Timed out while waiting for the test to finish";
}
static void obtainSdkVersion()
{
// Best-effort: SDK version gates userId() for multi-user, legacyDate formatting,
// and ApplicationExitInfo queries. Falling back to defaults is safe.
const QStringList versionArgs = { "shell"_L1, "getprop"_L1, "ro.build.version.sdk"_L1 };
const QByteArray output = execAdbCommand(versionArgs, false);
if (output.isNull()) {
qWarning() << "Unable to query the SDK version: adb getprop ro.build.version.sdk failed.";
return;
}
bool ok = false;
int sdkVersion = output.toInt(&ok);
if (ok)
g_testInfo.sdkVersion = sdkVersion;
else
qWarning("Unable to parse SDK version from adb getprop output: '%s'.", output.constData());
}
static QString userId()
{
// adb get-current-user command is available starting from API level 26.
QByteArray userId;
if (g_testInfo.sdkVersion >= 26) {
const QStringList userIdArgs = {"shell"_L1, "cmd"_L1, "activity"_L1, "get-current-user"_L1};
userId = execAdbCommand(userIdArgs, false);
if (userId.isNull())
qCritical() << "Error: failed to retrieve the user ID";
}
if (userId.isEmpty())
userId = "0";
return QString::fromUtf8(userId.simplified());
}
static QByteArray adbReadAppFile(const QString &fileName, int retries,
std::chrono::milliseconds backoff)
{
const QString catCmd = "cat files/%1 2> /dev/null"_L1.arg(fileName);
const QStringList args = { "shell"_L1, runCommandAsUserArgs(catCmd) };
while (retries > 0) {
const QByteArray output = execAdbCommand(args, false);
if (!output.isEmpty())
return output;
if (--retries)
QThread::msleep(backoff.count());
}
return QByteArray();
}
static bool pullResults()
{
using namespace std::chrono_literals;
for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.constEnd(); ++it) {
const QString filePath = it.value();
if (filePath.isEmpty())
continue; // stdout-streamed format so nothing to pull
const QString fileName = QFileInfo(filePath).fileName();
const QByteArray output = adbReadAppFile(fileName, g_options.resultsPullRetries, 200ms);
if (output.isNull()) {
if (deviceDisconnected())
g_testInfo.deviceGone.store(true);
qCritical() << "Error: failed to retrieve test result file %1 (missing or empty)."_L1
.arg(fileName);
return false;
}
QFile out{filePath};
if (!out.open(QIODevice::WriteOnly)) {
qCritical() << "Error: failed to open %1 to write results to host."_L1.arg(filePath);
return false;
}
if (out.write(output) != output.size()) {
qCritical() << "Error: short write of results to %1: %2"_L1
.arg(filePath).arg(out.errorString());
return false;
}
}
return true;
}
static QString getAbiLibsPath()
{
QString libsPath = "%1/libs/"_L1.arg(g_options.buildPath);
if (!QDir(libsPath).exists())
libsPath = "%1/app/libs/"_L1.arg(g_options.buildPath);
const QStringList abiArgs = { "shell"_L1, "getprop"_L1, "ro.product.cpu.abi"_L1 };
QByteArray abi = execAdbCommand(abiArgs, false).trimmed();
if (abi.isEmpty()) {
const QStringList subDirs = QDir(libsPath).entryList(QDir::Dirs | QDir::NoDotAndDotDot);
if (!subDirs.isEmpty())
abi = subDirs.first().toUtf8();
}
if (abi.isEmpty())
return {};
return libsPath + QString::fromUtf8(abi);
}
static void printLogcatCrash(const QByteArray &logcat)
{
// No crash report, do nothing
if (logcat.isEmpty())
return;
QByteArray crashLogcat(logcat);
if (g_options.ndkStackPath.isEmpty()) {
qWarning() << "Warning: ndk-stack path not provided and couldn't be deduced "
"using the ANDROID_NDK_ROOT or ANDROID_NDK_HOME environment "
"variables.";
} else if (const QString libsPath = getAbiLibsPath(); libsPath.isEmpty()) {
qWarning() << "Warning: could not determine the device ABI, "
"skipping ndk-stack and printing the raw dump.";
} else {
QProcess ndkStackProc;
ndkStackProc.start(g_options.ndkStackPath, { "-sym"_L1, libsPath });
if (ndkStackProc.waitForStarted()) {
ndkStackProc.write(crashLogcat);
ndkStackProc.closeWriteChannel();
// Drain ndk-stack to completion so its output isn't truncated.
if (ndkStackProc.waitForFinished()) {
// Keep the raw dump if ndk-stack produced no output.
const QByteArray ndkOutput = ndkStackProc.readAllStandardOutput();
if (!ndkOutput.trimmed().isEmpty())
crashLogcat = ndkOutput;
} else {
qCritical() << "Error: ndk-stack command timed out.";
ndkStackProc.kill();
ndkStackProc.waitForFinished();
}
} else {
qCritical() << "Error: failed to run ndk-stack command.";
}
}
if (crashLogcat.startsWith("********** Crash dump")) {
qDebug().noquote() << crashLogcat.trimmed();
} else {
qDebug() << "[androidtestrunner] ********** BEGIN crash dump **********";
qDebug().noquote() << crashLogcat.trimmed();
qDebug() << "[androidtestrunner] ********** END crash dump **********";
}
}
// Shortened from debuggerd's full banner so emulator/oem builds that wrap
// or truncate the line still slice cleanly.
static constexpr auto crashBannerMarker = "*** *** *** *** *** *** *** ***";
static QByteArray fetchLogcat(const QString &timeStamp, bool waitForDiagnostics)
{
using namespace std::chrono_literals;
// Read all three default buffers explicitly: crashes land in crash,
// ANR notices in system, and the test's QtTestLib/Qt output in main.
QStringList logcatArgs = { "shell"_L1, "logcat"_L1,
"-b"_L1, "main,system,crash"_L1,
"-v"_L1, "brief"_L1 };
// Without a timestamp the time arg is useless; cap by line count instead.
if (!timeStamp.isEmpty())
logcatArgs << "-t"_L1 << "'%1'"_L1.arg(timeStamp);
else
logcatArgs << "-t"_L1 << "5000"_L1;
const bool useColor = qEnvironmentVariable("QTEST_ENVIRONMENT") != "ci"_L1;
if (useColor)
logcatArgs << "-v"_L1 << "color"_L1;
QByteArray logcat = execAdbCommand(logcatArgs, false);
if (logcat.isNull())
qWarning() << "Warning: failed to fetch logcat of the test";
if (!waitForDiagnostics)
return logcat;
const QByteArray anrMarker = "ANR in " + g_options.package.toUtf8();
if (logcat.contains(crashBannerMarker) || logcat.contains(anrMarker))
return logcat;
// Debuggerd banner and ANR notice land seconds after death; poll for them.
QByteArray polled;
constexpr auto timeout = 15s;
const bool found = pollUntil([&]() {
polled = execAdbCommand(logcatArgs, false);
return !polled.isNull()
&& (polled.contains(crashBannerMarker) || polled.contains(anrMarker));
}, QDeadlineTimer(timeout), 250ms);
if (!polled.isEmpty())
logcat = std::move(polled);
if (!found && !g_testInfo.isTestRunnerInterrupted.load()) {
qWarning().noquote() << QString::fromLatin1("[androidtestrunner] No crash banner or "
"ANR marker found in logcat within %1s; output below may be incomplete.")
.arg(timeout.count());
}
return logcat;
}
static QByteArray takeCrashDump(QByteArray *logcat)
{
const qsizetype idx = logcat->indexOf(crashBannerMarker);
if (idx == -1)
return {};
QByteArray dump = logcat->mid(idx);
*logcat = logcat->left(idx);
return dump;
}
static QByteArray filterTestLogcat(const QByteArray &logcat, int testPid, int systemServerPid)
{
// No pid to anchor on: return the logcat unfiltered.
if (testPid <= 0)
return logcat;
static const QRegularExpression logcatRegEx{
"(?:^\\x1B\\[[0-9;]*m)?" // color
"(\\w)/" // message type 1. capture
".*?" // source (non-greedy so the pid capture group binds first)
"(\\(\\s*\\d*\\)):" // pid 2. capture
"\\s*"
".*" // message
"(?:\\x1B\\[[0-9;]*m)?" // color
"[\\n\\r]*$"_L1
};
QByteArrayList kept;
for (const QByteArray &line : logcat.split(u'\n')) {
QRegularExpressionMatch match = logcatRegEx.match(QString::fromUtf8(line));
if (!match.hasMatch()) {
// Unparseable line; keep it rather than drop silently.
kept.append(line);
continue;
}
const QString msgType = match.captured(1);
const QString pidStr = match.captured(2);
const int capturedPid = pidStr.mid(1, pidStr.size() - 2).trimmed().toInt();
const bool isFatal = msgType == u'F';
const bool isOurTest = capturedPid == testPid;
const bool isAnrSource = systemServerPid > 0 && capturedPid == systemServerPid;
if (isOurTest || isFatal || isAnrSource)
kept.append(line);
}
return kept.join('\n');
}
static void analyseLogcat(const QString &timeStamp, int *exitCode)
{
const bool wasAbnormal = !isTestExitCodeNormal(*exitCode)
&& !g_testInfo.isTestRunnerInterrupted.load();
QByteArray logcat = fetchLogcat(timeStamp, wasAbnormal);
if (logcat.isEmpty()) {
if (g_options.showLogcatOutput)
qWarning() << "The retrieved logcat is empty";
return;
}
const QByteArray crashDump = takeCrashDump(&logcat);
const bool anrOccurred = logcat.contains(
"ANR in %1"_L1.arg(g_options.package).toUtf8());
if (anrOccurred) {
// ANR may fire after a clean exit code. Surface it via the exit code,
// but preserve other abnormal codes.
if (isTestExitCodeNormal(*exitCode) || *exitCode == EXIT_NOEXITCODE)
*exitCode = EXIT_ANR;
qCritical("[androidtestrunner] An ANR has occurred while running the test '%s';"
" consult logcat for additional logs from the system_server process",
qPrintable(g_options.package));
}
const int systemServerPid = anrOccurred ? getPid("system_server"_L1) : -1;
const QByteArray filtered = filterTestLogcat(logcat, g_testInfo.pid, systemServerPid);
// Print whenever the caller asked (--show-logcat) or the test exited
// abnormally; the crash buffer is only meaningful on abnormal exit.
const bool testCrashed = !isTestExitCodeNormal(*exitCode)
&& !g_testInfo.isTestRunnerInterrupted.load();
if (g_options.showLogcatOutput || testCrashed) {
qDebug() << "[androidtestrunner] ********** BEGIN logcat dump **********";
qDebug().noquote() << filtered.trimmed();
qDebug() << "[androidtestrunner] ********** END logcat dump **********";
}
if (testCrashed && !crashDump.isEmpty())
printLogcatCrash(crashDump);
}
static QString getCurrentTimeString()
{
const bool legacyDate = g_testInfo.sdkVersion > 0 && g_testInfo.sdkVersion <= 23;
const QString timeFormat = legacyDate ?
"%m-%d %H:%M:%S.000"_L1 : "%Y-%m-%d %H:%M:%S.%3N"_L1;
QStringList dateArgs = { "shell"_L1, "date"_L1, "+'%1'"_L1.arg(timeFormat) };
const QByteArray output = execAdbCommand(dateArgs, false);
if (output.isNull()) {
qWarning() << "[androidtestrunner] ERROR in command: adb shell date";
return {};
}
return QString::fromUtf8(output.simplified());
}
static int testExitCode()
{
using namespace std::chrono_literals;
const QByteArray exitCodeOutput =
adbReadAppFile(u"qtest_last_exit_code"_s, g_options.resultsPullRetries, 200ms);
if (exitCodeOutput.isNull()) {
if (deviceDisconnected())
g_testInfo.deviceGone.store(true);
qCritical() << "[androidtestrunner] ERROR in command: adb shell cat"
" files/qtest_last_exit_code";
return EXIT_NOEXITCODE;
}
qDebug() << "[androidtestrunner] Test exitcode: " << exitCodeOutput;
bool ok;
int exitCode = exitCodeOutput.toInt(&ok);
return ok ? exitCode : EXIT_NOEXITCODE;
}
static bool uninstallTestPackage()
{
return !execAdbCommand({ "uninstall"_L1, g_options.package }).isNull();
}
void sigHandler(int signal)
{
#if !defined(Q_OS_WIN32)
// Reap the adb-tail subprocess so a second SIGINT doesn't orphan it.
const qint64 loggerPid = g_testInfo.stdoutLoggerPid.exchange(0);
if (loggerPid > 0)
::kill(static_cast<pid_t>(loggerPid), SIGTERM);
#endif
std::signal(signal, SIG_DFL);
if (!g_testInfo.isPackageInstalled.load())
_exit(EXIT_ERROR);
g_testInfo.isTestRunnerInterrupted.store(true);
}
int main(int argc, char *argv[])
{
using namespace std::chrono_literals;
std::signal(SIGINT, sigHandler);
std::signal(SIGTERM, sigHandler);
QCoreApplication a(argc, argv);
if (!parseOptions())
return EXIT_ERROR;
if (g_options.makeCommand.isEmpty()) {
qCritical() << "It is required to provide a make command with the \"--make\" parameter "
"to generate the apk.";
return EXIT_ERROR;
}
if (execCommand(g_options.makeCommand, true, g_options.timeoutSecs * 1s).isNull()) {
qCritical("The APK build command \"%s\" failed.", qPrintable(g_options.makeCommand));
return EXIT_ERROR;
}
if (!QFile::exists(g_options.packagePath)) {
qCritical("No package \"%s\" found after running the make command. "
"Check the provided path and the make command.",
qPrintable(g_options.packagePath));
return EXIT_ERROR;
}
const std::optional<QStringList> devices = runningDevices();
if (!devices) {
qCritical("Failed to query connected devices via 'adb devices'.");
return EXIT_ERROR;
} else if (devices->isEmpty()) {
qCritical("No connected devices or running emulators can be found.");
return EXIT_ERROR;
} else if (!g_options.serial.isEmpty() && !devices->contains(g_options.serial)) {
qCritical("No connected device or running emulator with serial '%s' can be found.",
qPrintable(g_options.serial));
return EXIT_ERROR;
} else if (g_options.serial.isEmpty() && devices->size() == 1) {
g_options.serial = devices->first();
} else if (g_options.serial.isEmpty()) {
qCritical("Multiple devices connected, set ANDROID_SERIAL or ANDROID_DEVICE_SERIAL.");
return EXIT_ERROR;
}
obtainSdkVersion();
g_testInfo.userId = userId();
if (!processAndroidManifest())
return EXIT_ERROR;
const QString ns = getGradleProjectProperty(g_options.buildPath, "android.namespace"_L1);
if (!ns.isEmpty())
g_options.package = ns;
if (g_options.package.isEmpty()) {
qCritical("Unable to get package name for '%s'", qPrintable(g_options.packagePath));
return EXIT_ERROR;
}
// parseTestArgs depends on g_options.package
if (!parseTestArgs())
return EXIT_ERROR;
// Per-user prefix so a shared TempLocation doesn't collide across users.
const QString user = qEnvironmentVariable("USER",
qEnvironmentVariable("USERNAME", u"default"_s));
const QString lockName = u"androidtestrunner-%1-%2.lock"_s.arg(user, g_options.serial);
const QDir tempDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation));
QLockFile testRunnerLock(tempDir.absoluteFilePath(lockName));
testRunnerLock.setStaleLockTime(0);
if (!testRunnerLock.lock()) {
qCritical("Failed to acquire test runner lock for '%s'.", qPrintable(g_options.serial));
return EXIT_ERROR;
}
if (g_options.packagePath.endsWith(".apk"_L1)) {
const QStringList installArgs = { "install"_L1, "-r"_L1, g_options.packagePath };
if (execAdbCommand(installArgs).isNull())
return EXIT_ERROR;
} else if (g_options.packagePath.endsWith(".aab"_L1)) {
QFileInfo aab(g_options.packagePath);
const auto apksFilePath = aab.absoluteDir().absoluteFilePath(aab.baseName() + ".apks"_L1);
QStringList installApksArgs = { "install-apks"_L1, "--apks"_L1, apksFilePath };
if (!g_options.serial.isEmpty())
installApksArgs << "--device-id"_L1 << g_options.serial;
if (execBundletoolCommand({ "build-apks"_L1, "--bundle"_L1, g_options.packagePath,
"--output"_L1, apksFilePath, "--local-testing"_L1,
"--overwrite"_L1 }).isNull()
|| execBundletoolCommand(installApksArgs).isNull())
return EXIT_ERROR;
}
g_testInfo.isPackageInstalled.store(true);
const QStringList dangerousPermissions = queryDangerousPermissions();
for (const auto &permission : g_options.permissions) {
if (!dangerousPermissions.contains(permission))
continue;
if (execAdbCommand({ "shell"_L1, "pm"_L1, "grant"_L1, "--user"_L1, g_testInfo.userId,
g_options.package, permission }).isNull()) {
qWarning("Unable to grant '%s' to '%s'. Probably the Android version mismatch.",
qPrintable(permission), qPrintable(g_options.package));
}
}
// Call additional adb command if set after installation and before starting the test
for (const auto &command : g_options.preTestRunAdbCommands) {
if (execAdbCommand(command).isNull()) {
qCritical("The pre test ADB command \"%s\" failed.",
qUtf8Printable(command.join(u' ')));
return EXIT_ERROR;
}
}
// Pre test start
const QString formattedStartTime = getCurrentTimeString();
// Start the test
if (execAdbCommand(g_options.amStarttestArgs).isNull()) {
// am start -W never returns when the app crashes before main; treat the
// failure as a launch-time crash and surface it from logcat.
int exitCode = EXIT_NOEXITCODE;
analyseLogcat(formattedStartTime, &exitCode);
return exitCode;
}
waitForStarted();
if (waitForLoggingStarted() && !setupStdoutLogger())
qWarning("Continuing without live stdout streaming; result files are still pulled.");
waitForFinished();
if (g_testInfo.deviceGone.load()) {
qCritical("[androidtestrunner] Device '%s' became unreachable during the test, "
"result transfer and uninstall skipped.", qPrintable(g_options.serial));
stopStdoutLogger();
return EXIT_DEVICE_GONE;
}
// Post test run
if (!stopStdoutLogger())
return EXIT_ERROR;
int exitCode = testExitCode();
if (g_options.showLogcatOutput || !isTestExitCodeNormal(exitCode))
analyseLogcat(formattedStartTime, &exitCode);
const bool pullRes = pullResults();
if (g_testInfo.deviceGone.load()) {
qCritical("[androidtestrunner] Device '%s' became unreachable during cleanup, "
"uninstall skipped.", qPrintable(g_options.serial));
return EXIT_DEVICE_GONE;
}
if (!pullRes && isTestExitCodeNormal(exitCode))
exitCode = EXIT_NORESULTS;
if (!uninstallTestPackage()) {
qWarning("Failed to uninstall test package '%s'. The test exit code is preserved.",
qPrintable(g_options.package));
}
if (g_testInfo.isTestRunnerInterrupted.load()) {
qCritical() << "The androidtestrunner was interrupted and the test was cleaned up.";
return EXIT_ERROR;
}
return exitCode;
}