| 1 | /**************************************************************************** |
| 2 | ** |
| 3 | ** Copyright (C) 2019 BogDan Vatra <bogdan@kde.org> |
| 4 | ** Copyright (C) 2016 The Qt Company Ltd. |
| 5 | ** Contact: https://www.qt.io/licensing/ |
| 6 | ** |
| 7 | ** This file is part of the tools applications of the Qt Toolkit. |
| 8 | ** |
| 9 | ** $QT_BEGIN_LICENSE:GPL-EXCEPT$ |
| 10 | ** Commercial License Usage |
| 11 | ** Licensees holding valid commercial Qt licenses may use this file in |
| 12 | ** accordance with the commercial license agreement provided with the |
| 13 | ** Software or, alternatively, in accordance with the terms contained in |
| 14 | ** a written agreement between you and The Qt Company. For licensing terms |
| 15 | ** and conditions see https://www.qt.io/terms-conditions. For further |
| 16 | ** information use the contact form at https://www.qt.io/contact-us. |
| 17 | ** |
| 18 | ** GNU General Public License Usage |
| 19 | ** Alternatively, this file may be used under the terms of the GNU |
| 20 | ** General Public License version 3 as published by the Free Software |
| 21 | ** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT |
| 22 | ** included in the packaging of this file. Please review the following |
| 23 | ** information to ensure the GNU General Public License requirements will |
| 24 | ** be met: https://www.gnu.org/licenses/gpl-3.0.html. |
| 25 | ** |
| 26 | ** $QT_END_LICENSE$ |
| 27 | ** |
| 28 | ****************************************************************************/ |
| 29 | |
| 30 | #include <QCoreApplication> |
| 31 | #include <QDir> |
| 32 | #include <QHash> |
| 33 | #include <QRegularExpression> |
| 34 | #include <QSystemSemaphore> |
| 35 | #include <QXmlStreamReader> |
| 36 | |
| 37 | #include <algorithm> |
| 38 | #include <chrono> |
| 39 | #include <functional> |
| 40 | #include <thread> |
| 41 | |
| 42 | #ifdef Q_CC_MSVC |
| 43 | #define popen _popen |
| 44 | #define QT_POPEN_READ "rb" |
| 45 | #define pclose _pclose |
| 46 | #else |
| 47 | #define QT_POPEN_READ "r" |
| 48 | #endif |
| 49 | |
| 50 | struct Options |
| 51 | { |
| 52 | bool helpRequested = false; |
| 53 | bool verbose = false; |
| 54 | bool skipAddInstallRoot = false; |
| 55 | std::chrono::seconds timeout{300}; // 5minutes |
| 56 | QString buildPath; |
| 57 | QString adbCommand{QStringLiteral("adb" )}; |
| 58 | QString makeCommand; |
| 59 | QString package; |
| 60 | QString activity; |
| 61 | QStringList testArgsList; |
| 62 | QHash<QString, QString> outFiles; |
| 63 | QString testArgs; |
| 64 | QString apkPath; |
| 65 | QHash<QString, std::function<bool(const QByteArray &)>> checkFiles = { |
| 66 | {QStringLiteral("txt" ), [](const QByteArray &data) -> bool { |
| 67 | return data.indexOf("\nFAIL! : " ) < 0; |
| 68 | }}, |
| 69 | {QStringLiteral("csv" ), [](const QByteArray &/*data*/) -> bool { |
| 70 | // It seems csv is broken |
| 71 | return true; |
| 72 | }}, |
| 73 | {QStringLiteral("xml" ), [](const QByteArray &data) -> bool { |
| 74 | QXmlStreamReader reader{data}; |
| 75 | while (!reader.atEnd()) { |
| 76 | reader.readNext(); |
| 77 | if (reader.isStartElement() && reader.name() == QStringLiteral("Incident" ) && |
| 78 | reader.attributes().value(QStringLiteral("type" )).toString() == QStringLiteral("fail" )) { |
| 79 | return false; |
| 80 | } |
| 81 | } |
| 82 | return true; |
| 83 | }}, |
| 84 | {QStringLiteral("lightxml" ), [](const QByteArray &data) -> bool { |
| 85 | return data.indexOf("\n<Incident type=\"fail\" " ) < 0; |
| 86 | }}, |
| 87 | {QStringLiteral("xunitxml" ), [](const QByteArray &data) -> bool { |
| 88 | QXmlStreamReader reader{data}; |
| 89 | while (!reader.atEnd()) { |
| 90 | reader.readNext(); |
| 91 | if (reader.isStartElement() && reader.name() == QStringLiteral("testcase" ) && |
| 92 | reader.attributes().value(QStringLiteral("result" )).toString() == QStringLiteral("fail" )) { |
| 93 | return false; |
| 94 | } |
| 95 | } |
| 96 | return true; |
| 97 | }}, |
| 98 | {QStringLiteral("teamcity" ), [](const QByteArray &data) -> bool { |
| 99 | return data.indexOf("' message='Failure! |[Loc: " ) < 0; |
| 100 | }}, |
| 101 | {QStringLiteral("tap" ), [](const QByteArray &data) -> bool { |
| 102 | return data.indexOf("\nnot ok " ) < 0; |
| 103 | }}, |
| 104 | }; |
| 105 | }; |
| 106 | |
| 107 | static Options g_options; |
| 108 | |
| 109 | static bool execCommand(const QString &command, QByteArray *output = nullptr, bool verbose = false) |
| 110 | { |
| 111 | if (verbose) |
| 112 | fprintf(stdout, "Execute %s.\n" , command.toUtf8().constData()); |
| 113 | FILE *process = popen(command.toUtf8().constData(), QT_POPEN_READ); |
| 114 | |
| 115 | if (!process) { |
| 116 | fprintf(stderr, "Cannot execute command %s.\n" , qPrintable(command)); |
| 117 | return false; |
| 118 | } |
| 119 | char buffer[512]; |
| 120 | while (fgets(buffer, sizeof(buffer), process)) { |
| 121 | if (output) |
| 122 | output->append(buffer); |
| 123 | if (verbose) |
| 124 | fprintf(stdout, "%s" , buffer); |
| 125 | } |
| 126 | return pclose(process) == 0; |
| 127 | } |
| 128 | |
| 129 | // Copy-pasted from qmake/library/ioutil.cpp |
| 130 | inline static bool hasSpecialChars(const QString &arg, const uchar (&iqm)[16]) |
| 131 | { |
| 132 | for (int x = arg.length() - 1; x >= 0; --x) { |
| 133 | ushort c = arg.unicode()[x].unicode(); |
| 134 | if ((c < sizeof(iqm) * 8) && (iqm[c / 8] & (1 << (c & 7)))) |
| 135 | return true; |
| 136 | } |
| 137 | return false; |
| 138 | } |
| 139 | |
| 140 | static QString shellQuoteUnix(const QString &arg) |
| 141 | { |
| 142 | // Chars that should be quoted (TM). This includes: |
| 143 | static const uchar iqm[] = { |
| 144 | 0xff, 0xff, 0xff, 0xff, 0xdf, 0x07, 0x00, 0xd8, |
| 145 | 0x00, 0x00, 0x00, 0x38, 0x01, 0x00, 0x00, 0x78 |
| 146 | }; // 0-32 \'"$`<>|;&(){}*?#!~[] |
| 147 | |
| 148 | if (!arg.length()) |
| 149 | return QStringLiteral("\"\"" ); |
| 150 | |
| 151 | QString ret(arg); |
| 152 | if (hasSpecialChars(ret, iqm)) { |
| 153 | ret.replace(QLatin1Char('\''), QStringLiteral("'\\''" )); |
| 154 | ret.prepend(QLatin1Char('\'')); |
| 155 | ret.append(QLatin1Char('\'')); |
| 156 | } |
| 157 | return ret; |
| 158 | } |
| 159 | |
| 160 | static QString shellQuoteWin(const QString &arg) |
| 161 | { |
| 162 | // Chars that should be quoted (TM). This includes: |
| 163 | // - control chars & space |
| 164 | // - the shell meta chars "&()<>^| |
| 165 | // - the potential separators ,;= |
| 166 | static const uchar iqm[] = { |
| 167 | 0xff, 0xff, 0xff, 0xff, 0x45, 0x13, 0x00, 0x78, |
| 168 | 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10 |
| 169 | }; |
| 170 | |
| 171 | if (!arg.length()) |
| 172 | return QStringLiteral("\"\"" ); |
| 173 | |
| 174 | QString ret(arg); |
| 175 | if (hasSpecialChars(ret, iqm)) { |
| 176 | // Quotes are escaped and their preceding backslashes are doubled. |
| 177 | // It's impossible to escape anything inside a quoted string on cmd |
| 178 | // level, so the outer quoting must be "suspended". |
| 179 | ret.replace(QRegularExpression(QStringLiteral("(\\\\*)\"" )), QStringLiteral("\"\\1\\1\\^\"\"" )); |
| 180 | // The argument must not end with a \ since this would be interpreted |
| 181 | // as escaping the quote -- rather put the \ behind the quote: e.g. |
| 182 | // rather use "foo"\ than "foo\" |
| 183 | int i = ret.length(); |
| 184 | while (i > 0 && ret.at(i - 1) == QLatin1Char('\\')) |
| 185 | --i; |
| 186 | ret.insert(i, QLatin1Char('"')); |
| 187 | ret.prepend(QLatin1Char('"')); |
| 188 | } |
| 189 | return ret; |
| 190 | } |
| 191 | |
| 192 | static QString shellQuote(const QString &arg) |
| 193 | { |
| 194 | if (QDir::separator() == QLatin1Char('\\')) |
| 195 | return shellQuoteWin(arg); |
| 196 | else |
| 197 | return shellQuoteUnix(arg); |
| 198 | } |
| 199 | |
| 200 | static bool parseOptions() |
| 201 | { |
| 202 | QStringList arguments = QCoreApplication::arguments(); |
| 203 | int i = 1; |
| 204 | for (; i < arguments.size(); ++i) { |
| 205 | const QString &argument = arguments.at(i); |
| 206 | if (argument.compare(QStringLiteral("--adb" ), Qt::CaseInsensitive) == 0) { |
| 207 | if (i + 1 == arguments.size()) |
| 208 | g_options.helpRequested = true; |
| 209 | else |
| 210 | g_options.adbCommand = arguments.at(++i); |
| 211 | } else if (argument.compare(QStringLiteral("--path" ), Qt::CaseInsensitive) == 0) { |
| 212 | if (i + 1 == arguments.size()) |
| 213 | g_options.helpRequested = true; |
| 214 | else |
| 215 | g_options.buildPath = arguments.at(++i); |
| 216 | } else if (argument.compare(QStringLiteral("--make" ), Qt::CaseInsensitive) == 0) { |
| 217 | if (i + 1 == arguments.size()) |
| 218 | g_options.helpRequested = true; |
| 219 | else |
| 220 | g_options.makeCommand = arguments.at(++i); |
| 221 | } else if (argument.compare(QStringLiteral("--apk" ), Qt::CaseInsensitive) == 0) { |
| 222 | if (i + 1 == arguments.size()) |
| 223 | g_options.helpRequested = true; |
| 224 | else |
| 225 | g_options.apkPath = arguments.at(++i); |
| 226 | } else if (argument.compare(QStringLiteral("--activity" ), Qt::CaseInsensitive) == 0) { |
| 227 | if (i + 1 == arguments.size()) |
| 228 | g_options.helpRequested = true; |
| 229 | else |
| 230 | g_options.activity = arguments.at(++i); |
| 231 | } else if (argument.compare(QStringLiteral("--skip-install-root" ), Qt::CaseInsensitive) == 0) { |
| 232 | g_options.skipAddInstallRoot = true; |
| 233 | } else if (argument.compare(QStringLiteral("--timeout" ), Qt::CaseInsensitive) == 0) { |
| 234 | if (i + 1 == arguments.size()) |
| 235 | g_options.helpRequested = true; |
| 236 | else |
| 237 | g_options.timeout = std::chrono::seconds{arguments.at(++i).toInt()}; |
| 238 | } else if (argument.compare(QStringLiteral("--help" ), Qt::CaseInsensitive) == 0) { |
| 239 | g_options.helpRequested = true; |
| 240 | } else if (argument.compare(QStringLiteral("--verbose" ), Qt::CaseInsensitive) == 0) { |
| 241 | g_options.verbose = true; |
| 242 | } else if (argument.compare(QStringLiteral("--" ), Qt::CaseInsensitive) == 0) { |
| 243 | ++i; |
| 244 | break; |
| 245 | } else { |
| 246 | g_options.testArgsList << arguments.at(i); |
| 247 | } |
| 248 | } |
| 249 | for (;i < arguments.size(); ++i) |
| 250 | g_options.testArgsList << arguments.at(i); |
| 251 | |
| 252 | if (g_options.helpRequested || g_options.buildPath.isEmpty() || g_options.apkPath.isEmpty()) |
| 253 | return false; |
| 254 | |
| 255 | QString serial = qEnvironmentVariable("ANDROID_DEVICE_SERIAL" ); |
| 256 | if (!serial.isEmpty()) |
| 257 | g_options.adbCommand += QStringLiteral(" -s %1" ).arg(serial); |
| 258 | return true; |
| 259 | } |
| 260 | |
| 261 | static void printHelp() |
| 262 | {// "012345678901234567890123456789012345678901234567890123456789012345678901" |
| 263 | fprintf(stderr, "Syntax: %s <options> -- [TESTARGS] \n" |
| 264 | "\n" |
| 265 | " Creates an Android package in a temp directory <destination> and\n" |
| 266 | " runs it on the default emulator/device or on the one specified by\n" |
| 267 | " \"ANDROID_DEVICE_SERIAL\" environment variable.\n\n" |
| 268 | " Mandatory arguments:\n" |
| 269 | " --path <path>: The path where androiddeployqt builds the android package.\n" |
| 270 | " --apk <apk path>: The test apk path. The apk has to exist already, if it\n" |
| 271 | " does not exist the make command must be provided for building the apk.\n\n" |
| 272 | " Optional arguments:\n" |
| 273 | " --make <make cmd>: make command, needed to install the qt library.\n" |
| 274 | " For Qt 5.14+ this can be \"make apk\".\n" |
| 275 | " --adb <adb cmd>: The Android ADB command. If missing the one from\n" |
| 276 | " $PATH will be used.\n" |
| 277 | " --activity <acitvity>: The Activity to run. If missing the first\n" |
| 278 | " activity from AndroidManifest.qml file will be used.\n" |
| 279 | " --timeout <seconds>: Timeout to run the test.\n" |
| 280 | " Default is 5 minutes.\n" |
| 281 | " --skip-install-root: Do not append INSTALL_ROOT=... to the make command.\n" |
| 282 | " -- arguments that will be passed to the test application.\n" |
| 283 | " --verbose: Prints out information during processing.\n" |
| 284 | " --help: Displays this information.\n\n" , |
| 285 | qPrintable(QCoreApplication::arguments().at(0)) |
| 286 | ); |
| 287 | } |
| 288 | |
| 289 | static QString packageNameFromAndroidManifest(const QString &androidManifestPath) |
| 290 | { |
| 291 | QFile androidManifestXml(androidManifestPath); |
| 292 | if (androidManifestXml.open(QIODevice::ReadOnly)) { |
| 293 | QXmlStreamReader reader(&androidManifestXml); |
| 294 | while (!reader.atEnd()) { |
| 295 | reader.readNext(); |
| 296 | if (reader.isStartElement() && reader.name() == QStringLiteral("manifest" )) |
| 297 | return reader.attributes().value(QStringLiteral("package" )).toString(); |
| 298 | } |
| 299 | } |
| 300 | return {}; |
| 301 | } |
| 302 | |
| 303 | static QString activityFromAndroidManifest(const QString &androidManifestPath) |
| 304 | { |
| 305 | QFile androidManifestXml(androidManifestPath); |
| 306 | if (androidManifestXml.open(QIODevice::ReadOnly)) { |
| 307 | QXmlStreamReader reader(&androidManifestXml); |
| 308 | while (!reader.atEnd()) { |
| 309 | reader.readNext(); |
| 310 | if (reader.isStartElement() && reader.name() == QStringLiteral("activity" )) |
| 311 | return reader.attributes().value(QStringLiteral("android:name" )).toString(); |
| 312 | } |
| 313 | } |
| 314 | return {}; |
| 315 | } |
| 316 | |
| 317 | static void setOutputFile(QString file, QString format) |
| 318 | { |
| 319 | if (file.isEmpty()) |
| 320 | file = QStringLiteral("-" ); |
| 321 | if (format.isEmpty()) |
| 322 | format = QStringLiteral("txt" ); |
| 323 | |
| 324 | g_options.outFiles[format] = file; |
| 325 | } |
| 326 | |
| 327 | static bool parseTestArgs() |
| 328 | { |
| 329 | QRegularExpression oldFormats{QStringLiteral("^-(txt|csv|xunitxml|xml|lightxml|teamcity|tap)$" )}; |
| 330 | QRegularExpression newLoggingFormat{QStringLiteral("^(.*),(txt|csv|xunitxml|xml|lightxml|teamcity|tap)$" )}; |
| 331 | |
| 332 | QString file; |
| 333 | QString logType; |
| 334 | QString unhandledArgs; |
| 335 | for (int i = 0; i < g_options.testArgsList.size(); ++i) { |
| 336 | const QString &arg = g_options.testArgsList[i].trimmed(); |
| 337 | if (arg == QStringLiteral("--" )) |
| 338 | continue; |
| 339 | if (arg == QStringLiteral("-o" )) { |
| 340 | if (i >= g_options.testArgsList.size() - 1) |
| 341 | return false; // missing file argument |
| 342 | |
| 343 | const auto &filePath = g_options.testArgsList[++i]; |
| 344 | const auto match = newLoggingFormat.match(filePath); |
| 345 | if (!match.hasMatch()) { |
| 346 | file = filePath; |
| 347 | } else { |
| 348 | const auto capturedTexts = match.capturedTexts(); |
| 349 | setOutputFile(capturedTexts.at(1), capturedTexts.at(2)); |
| 350 | } |
| 351 | } else { |
| 352 | auto match = oldFormats.match(arg); |
| 353 | if (match.hasMatch()) { |
| 354 | logType = match.capturedTexts().at(1); |
| 355 | } else { |
| 356 | unhandledArgs += QStringLiteral(" %1" ).arg(arg); |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | if (g_options.outFiles.isEmpty() || !file.isEmpty() || !logType.isEmpty()) |
| 361 | setOutputFile(file, logType); |
| 362 | |
| 363 | for (const auto &format : g_options.outFiles.keys()) |
| 364 | g_options.testArgs += QStringLiteral(" -o output.%1,%1" ).arg(format); |
| 365 | |
| 366 | g_options.testArgs += unhandledArgs; |
| 367 | g_options.testArgs = QStringLiteral("shell am start -e applicationArguments \\\"%1\\\" -n %2/%3" ).arg(shellQuote(g_options.testArgs.trimmed()), |
| 368 | g_options.package, |
| 369 | g_options.activity); |
| 370 | return true; |
| 371 | } |
| 372 | |
| 373 | static bool isRunning() { |
| 374 | QByteArray output; |
| 375 | if (!execCommand(QStringLiteral("%1 shell \"ps | grep ' %2'\"" ).arg(g_options.adbCommand, |
| 376 | shellQuote(g_options.package)), &output)) { |
| 377 | |
| 378 | return false; |
| 379 | } |
| 380 | return output.indexOf(" " + g_options.package.toUtf8()) > -1; |
| 381 | } |
| 382 | |
| 383 | static bool waitToFinish() |
| 384 | { |
| 385 | using clock = std::chrono::system_clock; |
| 386 | auto start = clock::now(); |
| 387 | // wait to start |
| 388 | while (!isRunning()) { |
| 389 | std::this_thread::sleep_for(std::chrono::milliseconds(100)); |
| 390 | if ((clock::now() - start) > std::chrono::seconds{10}) |
| 391 | return false; |
| 392 | } |
| 393 | |
| 394 | // Wait to finish |
| 395 | while (isRunning()) { |
| 396 | std::this_thread::sleep_for(std::chrono::milliseconds(250)); |
| 397 | if ((clock::now() - start) > g_options.timeout) |
| 398 | return false; |
| 399 | } |
| 400 | return true; |
| 401 | } |
| 402 | |
| 403 | |
| 404 | static bool pullFiles() |
| 405 | { |
| 406 | bool ret = true; |
| 407 | for (auto it = g_options.outFiles.constBegin(); it != g_options.outFiles.end(); ++it) { |
| 408 | QByteArray output; |
| 409 | if (!execCommand(QStringLiteral("%1 shell run-as %2 cat files/output.%3" ) |
| 410 | .arg(g_options.adbCommand, g_options.package, it.key()), &output)) { |
| 411 | return false; |
| 412 | } |
| 413 | auto checkerIt = g_options.checkFiles.find(it.key()); |
| 414 | ret = ret && checkerIt != g_options.checkFiles.end() && checkerIt.value()(output); |
| 415 | if (it.value() == QStringLiteral("-" )){ |
| 416 | fprintf(stdout, "%s" , output.constData()); |
| 417 | fflush(stdout); |
| 418 | } else { |
| 419 | QFile out{it.value()}; |
| 420 | if (!out.open(QIODevice::WriteOnly)) |
| 421 | return false; |
| 422 | out.write(output); |
| 423 | } |
| 424 | } |
| 425 | return ret; |
| 426 | } |
| 427 | |
| 428 | struct RunnerLocker |
| 429 | { |
| 430 | RunnerLocker() |
| 431 | { |
| 432 | runner.acquire(); |
| 433 | } |
| 434 | ~RunnerLocker() |
| 435 | { |
| 436 | runner.release(); |
| 437 | } |
| 438 | QSystemSemaphore runner{QStringLiteral("androidtestrunner" ), 1, QSystemSemaphore::Open}; |
| 439 | }; |
| 440 | |
| 441 | int main(int argc, char *argv[]) |
| 442 | { |
| 443 | QCoreApplication a(argc, argv); |
| 444 | if (!parseOptions()) { |
| 445 | printHelp(); |
| 446 | return 1; |
| 447 | } |
| 448 | |
| 449 | if (!QFile::exists(g_options.apkPath)) { |
| 450 | if (g_options.makeCommand.isEmpty()) { |
| 451 | fprintf(stderr, |
| 452 | "No apk found at \"%s\". Provide a make command with the \"--make\" parameter " |
| 453 | "to generate it first.\n" , |
| 454 | qPrintable(g_options.apkPath)); |
| 455 | return 1; |
| 456 | } |
| 457 | if (!execCommand(g_options.makeCommand, nullptr, true)) { |
| 458 | if (!g_options.skipAddInstallRoot) { |
| 459 | // we need to run make INSTALL_ROOT=path install to install the application file(s) first |
| 460 | if (!execCommand(QStringLiteral("%1 INSTALL_ROOT=%2 install" ) |
| 461 | .arg(g_options.makeCommand, QDir::toNativeSeparators(g_options.buildPath)), nullptr, g_options.verbose)) { |
| 462 | return 1; |
| 463 | } |
| 464 | } else { |
| 465 | if (!execCommand(QStringLiteral("%1" ) |
| 466 | .arg(g_options.makeCommand), nullptr, g_options.verbose)) { |
| 467 | return 1; |
| 468 | } |
| 469 | } |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | if (!QFile::exists(g_options.apkPath)) { |
| 474 | fprintf(stderr, |
| 475 | "No apk \"%s\" found after running the make command. Check the provided path and " |
| 476 | "the make command.\n" , |
| 477 | qPrintable(g_options.apkPath)); |
| 478 | return 1; |
| 479 | } |
| 480 | |
| 481 | RunnerLocker lock; // do not install or run packages while another test is running |
| 482 | if (!execCommand(QStringLiteral("%1 install -r %2" ) |
| 483 | .arg(g_options.adbCommand, g_options.apkPath), nullptr, g_options.verbose)) { |
| 484 | return 1; |
| 485 | } |
| 486 | |
| 487 | QString manifest = g_options.buildPath + QStringLiteral("/AndroidManifest.xml" ); |
| 488 | g_options.package = packageNameFromAndroidManifest(manifest); |
| 489 | if (g_options.activity.isEmpty()) |
| 490 | g_options.activity = activityFromAndroidManifest(manifest); |
| 491 | |
| 492 | // parseTestArgs depends on g_options.package |
| 493 | if (!parseTestArgs()) |
| 494 | return 1; |
| 495 | |
| 496 | // start the tests |
| 497 | bool res = execCommand(QStringLiteral("%1 %2" ).arg(g_options.adbCommand, g_options.testArgs), |
| 498 | nullptr, g_options.verbose) && waitToFinish(); |
| 499 | if (res) |
| 500 | res &= pullFiles(); |
| 501 | res &= execCommand(QStringLiteral("%1 uninstall %2" ).arg(g_options.adbCommand, g_options.package), |
| 502 | nullptr, g_options.verbose); |
| 503 | fflush(stdout); |
| 504 | return res ? 0 : 1; |
| 505 | } |
| 506 | |