diff --git a/cmake/QtExecutableHelpers.cmake b/cmake/QtExecutableHelpers.cmake index 9428f94ef67..3914fa08744 100644 --- a/cmake/QtExecutableHelpers.cmake +++ b/cmake/QtExecutableHelpers.cmake @@ -31,6 +31,12 @@ function(qt_internal_add_executable name) _qt_internal_create_executable(${name}) qt_internal_mark_as_internal_target(${name}) + + set_target_properties(${name} PROPERTIES + _qt_is_test_executable ${arg_QT_TEST} + _qt_is_manual_test ${arg_QT_MANUAL_TEST} + ) + if(ANDROID) _qt_internal_android_executable_finalizer(${name}) endif() diff --git a/cmake/QtPlatformAndroid.cmake b/cmake/QtPlatformAndroid.cmake index 9de6edbb719..c8ddadbbda8 100644 --- a/cmake/QtPlatformAndroid.cmake +++ b/cmake/QtPlatformAndroid.cmake @@ -187,7 +187,7 @@ define_property(TARGET ) # Returns test execution arguments for Android targets -function(qt_internal_android_test_arguments target timeout out_test_runner out_test_arguments) +function(qt_internal_android_test_runner_arguments target out_test_runner out_test_arguments) set(${out_test_runner} "${QT_HOST_PATH}/${QT${PROJECT_VERSION_MAJOR}_HOST_INFO_BINDIR}/androidtestrunner" PARENT_SCOPE) set(deployment_tool "${QT_HOST_PATH}/${QT${PROJECT_VERSION_MAJOR}_HOST_INFO_BINDIR}/androiddeployqt") @@ -196,21 +196,14 @@ function(qt_internal_android_test_arguments target timeout out_test_runner out_t message(FATAL_ERROR "Target ${target} is not a valid android executable target\n") endif() - set(target_binary_dir "$") - if(QT_USE_TARGET_ANDROID_BUILD_DIR) - set(apk_dir "${target_binary_dir}/android-build-${target}") - else() - set(apk_dir "${target_binary_dir}/android-build") - endif() + qt_internal_android_get_target_android_build_dir(${target} android_build_dir) set(${out_test_arguments} - "--path" "${apk_dir}" + "--path" "${android_build_dir}" "--adb" "${ANDROID_SDK_ROOT}/platform-tools/adb" "--skip-install-root" "--make" "\"${CMAKE_COMMAND}\" --build ${CMAKE_BINARY_DIR} --target ${target}_make_apk" - "--apk" "${apk_dir}/${target}.apk" + "--apk" "${android_build_dir}/${target}.apk" "--ndk-stack" "${ANDROID_NDK_ROOT}/ndk-stack" - "--timeout" "${timeout}" - "--verbose" PARENT_SCOPE ) endfunction() diff --git a/cmake/QtTargetHelpers.cmake b/cmake/QtTargetHelpers.cmake index 683b3c1af03..44964c39ec0 100644 --- a/cmake/QtTargetHelpers.cmake +++ b/cmake/QtTargetHelpers.cmake @@ -444,6 +444,8 @@ macro(qt_internal_setup_default_target_function_options) DELAY_RC DELAY_TARGET_INFO QT_APP + QT_TEST + QT_MANUAL_TEST NO_UNITY_BUILD ${__qt_internal_sbom_optional_args} ) diff --git a/cmake/QtTestHelpers.cmake b/cmake/QtTestHelpers.cmake index 42cbb2180bd..289c2690c94 100644 --- a/cmake/QtTestHelpers.cmake +++ b/cmake/QtTestHelpers.cmake @@ -250,11 +250,19 @@ function(qt_internal_add_test_to_batch batch_name name) # Lazy-init the test batch if(NOT TARGET ${target}) + if(${arg_MANUAL}) + set(is_manual "QT_MANUAL_TEST") + else() + set(is_manual "") + endif() + qt_internal_add_executable(${target} ${exceptions_text} ${gui_text} ${version_arg} NO_INSTALL + QT_TEST + ${is_manual} OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/build_dir" SOURCES "${QT_CMAKE_DIR}/qbatchedtestrunner.in.cpp" DEFINES QTEST_BATCH_TESTS ${deprecation_define} @@ -273,8 +281,6 @@ function(qt_internal_add_test_to_batch batch_name name) set_property(TARGET ${target} PROPERTY _qt_has_gui ${arg_GUI}) set_property(TARGET ${target} PROPERTY _qt_has_lowdpi ${arg_LOWDPI}) set_property(TARGET ${target} PROPERTY _qt_version ${version_arg}) - set_property(TARGET ${target} PROPERTY _qt_is_test_executable TRUE) - set_property(TARGET ${target} PROPERTY _qt_is_manual_test ${arg_MANUAL}) else() # Check whether the args match with the batch. Some differences between # flags cannot be reconciled - one should not combine these tests into @@ -512,11 +518,19 @@ function(qt_internal_add_test name) qt_internal_prepare_test_target_flags(version_arg exceptions_text gui_text ${ARGN}) + if(${arg_MANUAL}) + set(is_manual "QT_MANUAL_TEST") + else() + set(is_manual "") + endif() + qt_internal_add_executable("${name}" ${exceptions_text} ${gui_text} ${version_arg} NO_INSTALL + QT_TEST + ${is_manual} OUTPUT_DIRECTORY "${arg_OUTPUT_DIRECTORY}" SOURCES "${arg_SOURCES}" INCLUDE_DIRECTORIES @@ -579,8 +593,6 @@ function(qt_internal_add_test name) qt_internal_extend_target("${name}" CONDITION ANDROID LIBRARIES ${QT_CMAKE_EXPORT_NAMESPACE}::Gui ) - set_target_properties(${name} PROPERTIES _qt_is_test_executable TRUE) - set_target_properties(${name} PROPERTIES _qt_is_manual_test ${arg_MANUAL}) set(blacklist_file "${CMAKE_CURRENT_SOURCE_DIR}/BLACKLIST") if(EXISTS ${blacklist_file}) @@ -653,8 +665,8 @@ function(qt_internal_add_test name) "This is fine if OpenSSL was built statically.") endif() endif() - qt_internal_android_test_arguments( - "${name}" "${android_timeout}" test_executable extra_test_args) + qt_internal_android_test_runner_arguments("${name}" test_executable extra_test_args) + list(APPEND extra_test_args "--timeout" "${android_timeout}" "--verbose") set(test_working_dir "${CMAKE_CURRENT_BINARY_DIR}") elseif(QNX) set(test_working_dir "") diff --git a/cmake/QtWrapperScriptHelpers.cmake b/cmake/QtWrapperScriptHelpers.cmake index bcfc358762b..ecdc043c49c 100644 --- a/cmake/QtWrapperScriptHelpers.cmake +++ b/cmake/QtWrapperScriptHelpers.cmake @@ -235,6 +235,10 @@ export CMAKE_GENERATOR=Xcode qt_internal_create_qt_configure_part_wrapper_script("STANDALONE_TESTS") qt_internal_create_qt_configure_part_wrapper_script("STANDALONE_EXAMPLES") + + if(NOT CMAKE_CROSSCOMPILING) + qt_internal_create_qt_android_runner_wrapper_script() + endif() endfunction() function(qt_internal_create_qt_configure_part_wrapper_script component) @@ -376,3 +380,9 @@ function(qt_internal_create_qt_configure_redo_script) set_property(GLOBAL PROPERTY _qt_configure_redo_script_created TRUE) endfunction() + +function(qt_internal_create_qt_android_runner_wrapper_script) + qt_path_join(android_runner_destination "${QT_INSTALL_DIR}" "${INSTALL_LIBEXECDIR}") + qt_path_join(android_runner "${CMAKE_CURRENT_SOURCE_DIR}" "libexec" "qt-android-runner.py") + qt_copy_or_install(PROGRAMS "${android_runner}" DESTINATION "${android_runner_destination}") +endfunction() diff --git a/libexec/qt-android-runner.py b/libexec/qt-android-runner.py new file mode 100644 index 00000000000..17a5cefcfbb --- /dev/null +++ b/libexec/qt-android-runner.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# Copyright (C) 2024 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +import os +import subprocess +import sys +import base64 +import time +import signal +import argparse + +from datetime import datetime + +def status(msg): + print(f"\n-- {msg}") + +def error(msg): + print(f"Error: {msg}", file=sys.stderr) + +def die(msg): + error(msg) + sys.exit(1) + +# Define and parse arguments +parser = argparse.ArgumentParser(description="Qt for Android app runner.", + epilog=f''' +This is a helper script to run Qt for Android apps directly from the terminal. +It supports starting apps with parameters and forwards environment variables to +the device. It prints live logcat messages as the app is running. The script exits +once the app has exited on the device and terminates the app on the device if the +script is terminated. + +If an APK path is provided, it will first be installed to the device only if the +--install parameter is passed. + +Use --serial parameter or adb's ANDROID_SERIAL environment variable to specify an +Android target serial number (obtained from "adb devices" command) on which to run +the app or test. +''', formatter_class=argparse.RawTextHelpFormatter) + +parser.add_argument('-a', '--adb', metavar='path', type=str, help='Path to adb executable.') +parser.add_argument('-b', '--build-path', metavar='path', type=str, + help='Path to the Android build directory.') +parser.add_argument('-i', '--install', action='store_true', help='Install the APK.') +parser.add_argument('-s', '--serial', type=str, metavar='serial', + help='Android device serial (override $ANDROID_SERIAL).') +parser.add_argument('-p', '--apk', type=str, metavar='path', help='Path to the APK file.') + + +args, remaining_args = parser.parse_known_args() + +# Validate required arguments +if not args.build_path: + die("App build path is not provided") + +adb = args.adb +if not adb: + adb = 'adb' + null_dev = subprocess.DEVNULL + if subprocess.call(['command', '-v', adb], stdout=null_dev, stderr=null_dev) != 0: + die("adb tool path is not provided and is not found in PATH") + +try: + devices = [] + output = subprocess.check_output(f"{adb} devices", shell=True).decode().strip() + for line in output.splitlines(): + if '\tdevice' in line: + serial = line.split('\t')[0] + devices.append(serial) + if not devices: + die(f"No devices are connected.") + + if args.serial and not args.serial in devices: + die(f"No connected devices with the specified serial number.") +except Exception as e: + die(f"Failed to check for running devices, received error: {e}") + +if args.serial: + adb = f"{adb} -s {args.serial}" + +if args.build_path is None: + die("App build path is not provided") + +if args.apk and args.install: + status(f"Installing the app APK {args.apk}") + try: + subprocess.run(f"{adb} install \"{args.apk}\"", check=True, shell=True) + except Exception as e: + error(f"Failed to install the APK, received error: {e}") + + +def get_package_name(build_path): + try: + manifest_file = os.path.join(args.build_path, "AndroidManifest.xml") + if os.path.isfile(manifest_file): + with open(manifest_file) as f: + for line in f: + if 'package="' in line: + return line.split('package="')[1].split('"')[0] + + gradle_file = os.path.join(args.build_path, "build.gradle") + if os.path.isfile(gradle_file): + with open(gradle_file) as f: + for line in f: + if line.strip().startswith("namespace"): + return line.split('=')[1].strip().strip('"') + + properties_file = os.path.join(args.build_path, "gradle.properties") + if os.path.isfile(properties_file): + with open(properties_file) as f: + for line in f: + if line.startswith("androidPackageName="): + return line.split('=')[1].strip() + except Exception as e: + error(f"Failed to retrieve the app's package name, received error: {e}") + + return None + +# Get app details +package_name = get_package_name(args.build_path) +if not package_name: + die("Failed to retrieve the package name of the app") + +activity_name = "org.qtproject.qt.android.bindings.QtActivity" +start_cmd = f"{adb} shell am start -n {package_name}/{activity_name}" + +# Get environment variables +env_vars = " ".join(f"{key}={value}" for key, value in os.environ.items()) +encoded_env_vars = base64.b64encode(env_vars.encode()).decode() +start_cmd += f" -e extraenvvars \"{encoded_env_vars}\"" + +# Get app arguments +if remaining_args: + start_cmd += f" -e applicationArguments \"{' '.join(remaining_args)}\"" + +# Get formatted time from device +start_timestamp = "" +try: + start_timestamp = subprocess.check_output(f"{adb} shell \"date +'%Y-%m-%d %H:%M:%S.%3N'\"", + shell=True).decode().strip() +except Exception as e: + die(f"Failed to get formatted time from the device, received error: {e}") + +try: + subprocess.run(start_cmd, check=True, shell=True) +except Exception as e: + die(f"Failed to start the app {package_name}, received error: {e}") + +# Wait for the app to start and retrieve its pid +start_timeout = 5 +time_limit = time.time() + start_timeout +pid = None +while pid is None: + if time.time() > time_limit: + die(f"Couldn't retrieve the app's PID within {start_timeout} seconds") + time.sleep(0.5) + try: + pidof_output = subprocess.check_output(f"{adb} shell pidof {package_name}", shell=True) + pid = pidof_output.decode().strip().split()[0] + except subprocess.CalledProcessError: + continue + +# Add a signal handler to stop the app if the script is terminated +interrupted = False +def terminate_app(signum, frame): + global interrupted + interrupted = True + +signal.signal(signal.SIGINT, terminate_app) + +# Show app's logs +try: + format_arg = "-v brief -v color" + time_arg = f"-T '{start_timestamp}'" + # escape char and color followed with fatal tag + fatal_regex = f"-e $'^\x1b\\[[0-9]*mF/'" + pid_regex = f"-e '([ ]*{pid}):'" + logcat_cmd = f"{adb} shell \"logcat {time_arg} {format_arg} | grep {pid_regex} {fatal_regex}\"" + logcat_process = subprocess.Popen(logcat_cmd, shell=True) +except Exception as e: + die(f"Failed to get logcat for the app {package_name}, received error: {e}") + +# Monitor the app's pid +try: + while not interrupted: + time.sleep(1) + try: + pidof_output = subprocess.check_output(f"{adb} shell pidof {package_name}", shell=True) + pid = pidof_output.decode().strip() + if not pid: + status(f"The app \"{package_name}\" has exited") + break + except subprocess.CalledProcessError: + status(f"The app \"{package_name}\" has exited") + break +finally: + logcat_process.terminate() + +if interrupted: + try: + subprocess.Popen(f"{adb} shell am force-stop {package_name}", shell=True) + status(f"The app \"{package_name}\" with {pid} has been terminated") + except Exception as e: + error(f"Failed to terminate the app {package_name}, received error: {e}") diff --git a/src/corelib/Qt6AndroidMacros.cmake b/src/corelib/Qt6AndroidMacros.cmake index b718ff4f2f4..3fccd18d261 100644 --- a/src/corelib/Qt6AndroidMacros.cmake +++ b/src/corelib/Qt6AndroidMacros.cmake @@ -1672,6 +1672,79 @@ function(_qt_internal_android_executable_finalizer target) _qt_internal_configure_android_multiabi_target("${target}") qt6_android_generate_deployment_settings("${target}") qt6_android_add_apk_target("${target}") + _qt_internal_android_create_runner_wrapper("${target}") +endfunction() + +# Generates an Android app runner script for target +function(_qt_internal_android_create_runner_wrapper target) + get_target_property(is_test ${target} _qt_is_test_executable) + get_target_property(is_manual_test ${target} _qt_is_manual_test) + if(is_test AND NOT is_manual_test) + qt_internal_android_test_runner_arguments("${target}" tool_path arguments) + else() + qt_internal_android_app_runner_arguments("${target}" tool_path arguments) + endif() + + set(args_splitter "") + if(CMAKE_HOST_WIN32) + set(args_splitter "^") + else() + set(args_splitter "\\") + endif() + + list(PREPEND arguments "${tool_path}") + set(formatted_command "") + # format args in pairs and or single args over multiple lines with indentation + foreach(item IN LISTS arguments) + if(formatted_command STREQUAL "") + set(formatted_command "${item}") + elseif(item MATCHES "^--.*") + set(formatted_command "${formatted_command} ${args_splitter}\n ${item}") + else() + set(formatted_command "${formatted_command} \"${item}\"") + endif() + endforeach() + + get_target_property(target_binary_dir ${target} BINARY_DIR) + + if(CMAKE_HOST_WIN32) + set(script_content "${formatted_command} ${args_splitter}\n %*\n") + set(wrapper_path "${target_binary_dir}/${target}.bat") + else() + set(script_content "#!/bin/sh\n\n${formatted_command} ${args_splitter}\n $@\n") + set(wrapper_path "${target_binary_dir}/${target}") + endif() + + set(template_file "${__qt_core_macros_module_base_dir}/Qt6CoreConfigureFileTemplate.in") + set(qt_core_configure_file_contents "${script_content}") + configure_file("${template_file}" "${wrapper_path}") + + if(CMAKE_HOST_UNIX) + execute_process(COMMAND chmod +x ${wrapper_path}) + endif() +endfunction() + +# Get the android runner script path and its arguments for a target +function(qt_internal_android_app_runner_arguments target out_runner_path out_arguments) + set(runner_dir "${QT_HOST_PATH}/${QT6_HOST_INFO_LIBEXECDIR}") + set(${out_runner_path} "${runner_dir}/qt-android-runner.py" PARENT_SCOPE) + + qt_internal_android_get_target_android_build_dir(${target} android_build_dir) + set(${out_arguments} + "--adb" "${ANDROID_SDK_ROOT}/platform-tools/adb" + "--build-path" "${android_build_dir}" + "--apk" "${android_build_dir}/${target}.apk" + PARENT_SCOPE + ) +endfunction() + +function(qt_internal_android_get_target_android_build_dir target out_build_dir) + get_target_property(target_binary_dir ${target} BINARY_DIR) + if(QT_USE_TARGET_ANDROID_BUILD_DIR) + set(${out_build_dir} "${target_binary_dir}/android-build-${target}" PARENT_SCOPE) + else() + set(${out_build_dir} "${target_binary_dir}/android-build" PARENT_SCOPE) + endif() endfunction() function(_qt_internal_expose_android_package_source_dir_to_ide target)