Repository: miurahr/aqtinstall

Issue:
Cannot add Qt 6.4.1 Android Arm64_v8a as a Qt Kit into QtCreator
**Description**
When trying to add Qt 6.4.1 Android Arm64_v8a as a Qt Version for Kit into QtCreator, I am getting an error:

The qmake executable /opt/Qt/6.4.1/android_arm64_v8a/bin/qmake could not be added: "/opt/Qt/6.4.1/android_arm64_v8a/bin/qmake" produced no output: /opt/Qt/6.4.1/android_arm64_v8a/bin/qmake: 7: /Users/qt/work/install/bin/qmake: not found

However, via an official installer I can add this Qt version without any issues and I can compile and deploy everything as usual.

**To Reproduce**
1. aqt install-qt -O /opt/Qt linux android 6.4.1 android_arm64_v8a --autodesktop
2. Open QtCreator 8.0.2, add /opt/Qt/6.4.1/android_arm64_v8a/bin/qmake as Qt Version.
3. See the error.

**Expected behavior**
Works the same as for the official installer.

**Desktop:**
 - OS: Ubuntu 22.04
 - `aqt` version: aqtinstall(aqt) v3.0.2 on Python 3.10.6 [CPython GCC 11.3.0]

UPD:
I don't have such problem with Qt 6.4.0 installed via aqtinstall.

Discussion:

Patch:
diff --git a/aqt/updater.py b/aqt/updater.py
index 60a31c0..4ccf3dc 100644
--- a/aqt/updater.py
+++ b/aqt/updater.py
@@ -20,10 +20,12 @@
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 import logging
 import os
+import re
+import stat
 import subprocess
 from logging import getLogger
 from pathlib import Path
-from typing import Dict, Optional
+from typing import Dict, List, Optional, Union
 
 import patch
 
@@ -35,8 +37,8 @@ from aqt.metadata import ArchiveId, MetadataFactory, QtRepoProperty, SimpleSpec,
 dir_for_version = QtRepoProperty.dir_for_version
 
 
-def unpatched_path(os_name: str, final_component: str) -> str:
-    return ("/home/qt/work/install" if os_name == "linux" else "/Users/qt/work/install") + "/" + final_component
+def unpatched_paths() -> List[str]:
+    return ["/home/qt/work/install", "/Users/qt/work/install"]
 
 
 class Updater:
@@ -69,12 +71,16 @@ class Updater:
         file.write_text(data, "UTF-8")
         os.chmod(str(file), st.st_mode)
 
-    def _patch_textfile(self, file: Path, old: str, new: str):
+    def _patch_textfile(self, file: Path, old: Union[str, re.Pattern], new: str, *, is_executable: bool = False):
         st = file.stat()
+        file_mode = st.st_mode | (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH if is_executable else 0)
         data = file.read_text("UTF-8")
-        data = data.replace(old, new)
+        if isinstance(old, re.Pattern):
+            data = old.sub(new, data)
+        else:
+            data = data.replace(old, new)
         file.write_text(data, "UTF-8")
-        os.chmod(str(file), st.st_mode)
+        os.chmod(str(file), file_mode)
 
     def _detect_qmake(self) -> bool:
         """detect Qt configurations from qmake."""
@@ -171,10 +177,10 @@ class Updater:
     def patch_qmake_script(self, base_dir, qt_version: str, os_name: str, desktop_arch_dir: str):
         sep = "\\" if os_name == "windows" else "/"
         patched = sep.join([base_dir, qt_version, desktop_arch_dir, "bin"])
-        unpatched = unpatched_path(os_name, "bin")
         qmake_path = self.prefix / "bin" / ("qmake.bat" if os_name == "windows" else "qmake")
         self.logger.info(f"Patching {qmake_path}")
-        self._patch_textfile(qmake_path, unpatched, patched)
+        for unpatched in unpatched_paths():
+            self._patch_textfile(qmake_path, f"{unpatched}/bin", patched, is_executable=True)
 
     def patch_qtcore(self, target):
         """patch to QtCore"""
@@ -226,14 +232,29 @@ class Updater:
 
     def patch_target_qt_conf(self, base_dir: str, qt_version: str, arch_dir: str, os_name: str, desktop_arch_dir: str):
         target_qt_conf = self.prefix / "bin" / "target_qt.conf"
-        old_targetprefix = f'Prefix={unpatched_path(os_name, "target")}'
         new_hostprefix = f"HostPrefix=../../{desktop_arch_dir}"
         new_targetprefix = "Prefix={}".format(str(Path(base_dir).joinpath(qt_version, arch_dir, "target")))
         new_hostdata = "HostData=../{}".format(arch_dir)
-        self._patch_textfile(target_qt_conf, old_targetprefix, new_targetprefix)
+        new_host_lib_execs = "./bin" if os_name == "windows" else "./libexec"
+        old_host_lib_execs = re.compile(r"^HostLibraryExecutables=[^\n]*$", flags=re.MULTILINE)
+
+        self._patch_textfile(target_qt_conf, old_host_lib_execs, f"HostLibraryExecutables={new_host_lib_execs}")
+        for unpatched in unpatched_paths():
+            self._patch_textfile(target_qt_conf, f"Prefix={unpatched}/target", new_targetprefix)
         self._patch_textfile(target_qt_conf, "HostPrefix=../../", new_hostprefix)
         self._patch_textfile(target_qt_conf, "HostData=target", new_hostdata)
 
+    def patch_qdevice_file(self, base_dir: str, qt_version: str, arch_dir: str, os_name: str):
+        """Qt 6.4.1+ specific, but it should not hurt anything if `mkspecs/qdevice.pri` does not exist"""
+
+        qdevice = Path(base_dir) / qt_version / arch_dir / "mkspecs/qdevice.pri"
+        if not qdevice.exists():
+            return
+
+        old_line = re.compile(r"^DEFAULT_ANDROID_NDK_HOST =[^\n]*$", flags=re.MULTILINE)
+        new_line = f"DEFAULT_ANDROID_NDK_HOST = {'darwin' if os_name == 'mac' else os_name}-x86_64"
+        self._patch_textfile(qdevice, old_line, new_line)
+
     @classmethod
     def update(cls, target: TargetConfig, base_path: Path, installed_desktop_arch_dir: Optional[str]):
         """
@@ -290,6 +311,7 @@ class Updater:
 
                 updater.patch_qmake_script(base_dir, version_dir, target.os_name, desktop_arch_dir)
                 updater.patch_target_qt_conf(base_dir, version_dir, arch_dir, target.os_name, desktop_arch_dir)
+                updater.patch_qdevice_file(base_dir, version_dir, arch_dir, target.os_name)
         except IOError as e:
             raise UpdaterError(f"Updater caused an IO error: {e}") from e
 
diff --git a/ci/generate_azure_pipelines_matrices.py b/ci/generate_azure_pipelines_matrices.py
index 53d9dd1..25b75ad 100644
--- a/ci/generate_azure_pipelines_matrices.py
+++ b/ci/generate_azure_pipelines_matrices.py
@@ -296,17 +296,24 @@ mac_build_jobs.extend(
     [
         BuildJob("install-qt", "6.4.0", "mac", "ios", "ios", "ios", module="qtsensors", is_autodesktop=True),
         BuildJob("install-qt", "6.2.4", "mac", "ios", "ios", "ios", module="qtsensors", is_autodesktop=False),
+        BuildJob("install-qt", "6.4.1", "mac", "android", "android_armv7", "android_armv7", is_autodesktop=True),
         BuildJob("install-qt", "6.1.0", "mac", "android", "android_armv7", "android_armv7", is_autodesktop=True),
     ]
 )
 linux_build_jobs.extend(
-    [BuildJob("install-qt", "6.1.0", "linux", "android", "android_armv7", "android_armv7", is_autodesktop=True)]
+    [
+        BuildJob("install-qt", "6.1.0", "linux", "android", "android_armv7", "android_armv7", is_autodesktop=True),
+        BuildJob("install-qt", "6.4.1", "linux", "android", "android_arm64_v8a", "android_arm64_v8a", is_autodesktop=True),
+    ]
 )
 
 # Qt 6.3.0 for Windows-Android has win64_mingw available, but not win64_mingw81.
 # This will test that the path to mingw is not hardcoded.
 windows_build_jobs.extend(
-    [BuildJob("install-qt", "6.3.0", "windows", "android", "android_armv7", "android_armv7", is_autodesktop=True)]
+    [
+        BuildJob("install-qt", "6.3.0", "windows", "android", "android_armv7", "android_armv7", is_autodesktop=True),
+        BuildJob("install-qt", "6.4.1", "windows", "android", "android_x86_64", "android_x86_64", is_autodesktop=True),
+    ]
 )
 
 # Test binary patch of qmake


Test patch:
diff --git a/tests/test_install.py b/tests/test_install.py
index bd31df4..79945b4 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -635,6 +635,81 @@ def tool_archive(host: str, tool_name: str, variant: str, date: datetime = datet
                 r"INFO    : Time elapsed: .* second"
             ),
         ),
+        (
+            "install-qt linux android 6.4.1 android_arm64_v8a".split(),
+            "linux",
+            "android",
+            "6.4.1",
+            {"std": "android_arm64_v8a"},
+            {"std": "android_arm64_v8a"},
+            {"std": "linux_x64/android/qt6_641_arm64_v8a/Updates.xml"},
+            {
+                "std": [
+                    MockArchive(
+                        filename_7z="qtbase-MacOS-MacOS_12-Clang-Android-Android_ANY-ARM64.7z",
+                        update_xml_name="qt.qt6.641.android_arm64_v8a",
+                        contents=(
+                            # Qt 6 non-desktop should patch qconfig.pri, qmake script and target_qt.conf
+                            PatchedFile(
+                                filename="mkspecs/qconfig.pri",
+                                unpatched_content="... blah blah blah ...\n"
+                                "QT_EDITION = Not OpenSource\n"
+                                "QT_LICHECK = Not Empty\n"
+                                "... blah blah blah ...\n",
+                                patched_content="... blah blah blah ...\n"
+                                "QT_EDITION = OpenSource\n"
+                                "QT_LICHECK =\n"
+                                "... blah blah blah ...\n",
+                            ),
+                            PatchedFile(
+                                filename="mkspecs/qdevice.pri",
+                                unpatched_content="blah blah blah...\n"
+                                "DEFAULT_ANDROID_NDK_HOST = mac-x86_64\n"
+                                "blah blah blah...\n",
+                                patched_content="blah blah blah...\n"
+                                "DEFAULT_ANDROID_NDK_HOST = linux-x86_64\n"
+                                "blah blah blah...\n",
+                            ),
+                            PatchedFile(
+                                filename="bin/target_qt.conf",
+                                unpatched_content="Prefix=/Users/qt/work/install/target\n"
+                                "HostPrefix=../../\n"
+                                "HostData=target\n"
+                                "HostLibraryExecutables=./bin\n"
+                                "HostLibraryExecutables=./libexec\n",
+                                patched_content="Prefix={base_dir}{sep}6.4.1{sep}android_arm64_v8a{sep}target\n"
+                                "HostPrefix=../../gcc_64\n"
+                                "HostData=../android_arm64_v8a\n"
+                                "HostLibraryExecutables=./libexec\n"
+                                "HostLibraryExecutables=./libexec\n",
+                            ),
+                            PatchedFile(
+                                filename="bin/qmake",
+                                unpatched_content="... blah blah blah ...\n"
+                                "/home/qt/work/install/bin\n"
+                                "/Users/qt/work/install/bin\n"
+                                "... blah blah blah ...\n",
+                                patched_content="... blah blah blah ...\n"
+                                "{base_dir}/6.4.1/gcc_64/bin\n"
+                                "{base_dir}/6.4.1/gcc_64/bin\n"
+                                "... blah blah blah ...\n",
+                            ),
+                        ),
+                    ),
+                ]
+            },
+            re.compile(
+                r"^INFO    : aqtinstall\(aqt\) v.* on Python 3.*\n"
+                r"WARNING : You are installing the android version of Qt, which requires that the desktop version of "
+                r"Qt is also installed. You can install it with the following command:\n"
+                r"          `aqt install-qt linux desktop 6\.4\.1 gcc_64`\n"
+                r"INFO    : Downloading qtbase\.\.\.\n"
+                r"Finished installation of qtbase-MacOS-MacOS_12-Clang-Android-Android_ANY-ARM64\.7z in .*\n"
+                r"INFO    : Patching .*6\.4\.1[/\\]android_arm64_v8a[/\\]bin[/\\]qmake\n"
+                r"INFO    : Finished installation\n"
+                r"INFO    : Time elapsed: .* second"
+            ),
+        ),
         (
             "install-qt linux android 6.3.0 android_arm64_v8a".split(),
             "linux",
@@ -939,6 +1014,8 @@ def test_install(
                 for patched_file in archive.contents:
                     file_path = installed_path / patched_file.filename
                     assert file_path.is_file()
+                    if file_path.name == "qmake":
+                        assert os.access(file_path, os.X_OK), "qmake file must be executable"
 
                     expect_content = patched_file.expected_content(base_dir=output_dir, sep=os.sep)
                     actual_content = file_path.read_text(encoding="utf_8")