diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index d7f789aaaf..4eff5d0f61 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -70,6 +70,8 @@ jobs: target: testapps-webview - name: service_library target: testapps-service_library-aar + - name: qt + target: testapps-qt steps: - name: Checkout python-for-android uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 9747c6c43d..b894bed3ea 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,26 @@ testapps-service_library-aar: virtualenv --requirements python3 \ --arch=arm64-v8a --arch=x86 --release +testapps-qt: testapps-qt/debug/apk testapps-qt/release/aab + +# testapps-webview/MODE/ARTIFACT +testapps-qt/%: virtualenv + $(eval MODE := $(word 2, $(subst /, ,$@))) + $(eval ARTIFACT := $(word 3, $(subst /, ,$@))) + @echo Building testapps-qt for $(MODE) mode and $(ARTIFACT) artifact + . $(ACTIVATE) && cd testapps/on_device_unit_tests/ && \ + python setup.py $(ARTIFACT) --$(MODE) --sdk-dir $(ANDROID_SDK_HOME) --ndk-dir $(ANDROID_NDK_HOME) \ + --bootstrap qt \ + --requirements python3,shiboken6,pyside6 \ + --arch=arm64-v8a \ + --local-recipes ./test_qt/recipes \ + --qt-libs Core \ + --load-local-libs plugins_platforms_qtforandroid \ + --add-jar ./test_qt/jar/PySide6/jar/Qt6Android.jar \ + --add-jar ./test_qt/jar/PySide6/jar/Qt6AndroidBindings.jar \ + --permission android.permission.WRITE_EXTERNAL_STORAGE \ + --permission android.permission.INTERNET + testapps/%: virtualenv $(eval $@_APP_ARCH := $(shell basename $*)) . $(ACTIVATE) && cd testapps/on_device_unit_tests/ && \ diff --git a/doc/source/buildoptions.rst b/doc/source/buildoptions.rst index 44bae679c1..782bd67295 100644 --- a/doc/source/buildoptions.rst +++ b/doc/source/buildoptions.rst @@ -204,6 +204,49 @@ systems and frameworks. include multiple jar files, pass this argument multiple times. - ``add-source``: Add a source directory to the app's Java code. +Qt +~~ + +This bootstrap can be used with ``--bootstrap=qt`` or by including the ``PySide6`` or +``shiboken6`` recipe, e.g. ``--requirements=pyside6,shiboken6``. Currently, the only way +to use this bootstrap is through `pyside6-android-deploy `__ +tool shipped with ``PySide6``, as the recipes for ``PySide6`` and ``shiboken6`` are created +dynamically. The tool builds ``PySide6`` and ``shiboken6`` wheels for a specific Android platform +and the recipes simply unpack the built wheels. You can see the recipes `here `__. + +.. note:: + The ``pyside6-android-deploy`` tool and hence the Qt bootstrap does not support multi-architecture + builds currently. + +What are Qt and PySide? +%%%%%%%%%%%%%%%%%%%%%%%% + +`Qt `__ is a popularly used cross-platform C++ framework for developing +GUI applications. `PySide6 `__ refers to the +Python bindings for Qt6, and enables the Python developers access to the Qt6 API. +`Shiboken6 `__ is the binding generator +tool used for generating the Python bindings from C++ code. + +.. note:: The `shiboken6` recipe is for the `Shiboken Python module `__ + which includes a couple of utility functions for inspecting and debugging PySide6 code. + +Build Options +%%%%%%%%%%%%% + +``pyside6-android-deploy`` works by generating a ``buildozer.spec`` file and thereby using +`buildozer `__ to control the build options used by +``python-for-android`` with the Qt bootstrap. Apart from the general build options that works +across all the other bootstraps, the Qt bootstrap introduces the following 3 new build options. + +- ``--qt-libs``: list of Qt libraries(modules) to be loaded. +- ``--load-local-libs``: list of Qt plugin libraries to be loaded. +- ``--init-classes``: list of Java class names to the loaded from the Qt jar files supplied through + the ``--add-jar`` option. + +These build options are automatically populated by the ``pyside6-android-deploy`` tool, but can be +modified by updating the ``buildozer.spec`` file. Apart from the above 3 build options, the tool +also automatically identifies the values to be fed into the cli options ``--permission``, ``--add-jar`` +depending on the PySide6 modules used by the applicaiton. Requirements blacklist (APK size optimization) ---------------------------------------------- diff --git a/doc/source/commands.rst b/doc/source/commands.rst index 5a0884aa5e..dda644e47a 100644 --- a/doc/source/commands.rst +++ b/doc/source/commands.rst @@ -26,7 +26,7 @@ behaviour, though not all commands make use of them. ``--debug`` Print extra debug information about the build, including all compilation output. - + ``--sdk_dir`` The filepath where the Android SDK is installed. This can alternatively be set in several other ways. @@ -34,7 +34,7 @@ behaviour, though not all commands make use of them. ``--android_api`` The Android API level to target; python-for-android will check if the platform tools for this level are installed. - + ``--ndk_dir`` The filepath where the Android NDK is installed. This can alternatively be set in several other ways. @@ -74,12 +74,12 @@ supply those that you need. The architecture to build for. You can specify multiple architectures to build for at the same time. As an example ``p4a ... --arch arm64-v8a --arch armeabi-v7a ...`` will build a distribution for both ``arm64-v8a`` and ``armeabi-v7a``. - + ``--bootstrap BOOTSTRAP`` The Java bootstrap to use for your application. You mostly don't need to worry about this or set it manually, as an appropriate bootstrap will be chosen from your ``--requirements``. Current - choices are ``sdl2`` (used with Kivy and most other apps) or ``webview``. + choices are ``sdl2`` (used with Kivy and most other apps), ``webview`` or ``qt``. .. note:: These options are preliminary. Others will include toggles diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst index 95c4eefc4e..987a00cddb 100644 --- a/doc/source/quickstart.rst +++ b/doc/source/quickstart.rst @@ -26,8 +26,8 @@ Concepts - **bootstrap:** A bootstrap is the app backend that will start your application. The default for graphical applications is SDL2. - You can also use e.g. the webview for web apps, or service_only/service_library for - background services. Different bootstraps have different additional + You can also use e.g. the webview for web apps, or service_only/service_library for + background services, or qt for PySide6 apps. Different bootstraps have different additional build options. *Advanced:* @@ -281,7 +281,7 @@ Recipe management You can see the list of the available recipes with:: p4a recipes - + If you are contributing to p4a and want to test a recipes again, you need to clean the build and rebuild your distribution:: @@ -295,7 +295,6 @@ it (edit the ``__init__.py``):: mkdir -p p4a-recipes/myrecipe touch p4a-recipes/myrecipe/__init__.py - Distribution management ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index eced5886c8..29d16ea9f2 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -83,7 +83,7 @@ def get_bootstrap_name(): if PYTHON is not None and not exists(PYTHON): PYTHON = None -if _bootstrap_name in ('sdl2', 'webview', 'service_only'): +if _bootstrap_name in ('sdl2', 'webview', 'service_only', 'qt'): WHITELIST_PATTERNS.append('pyconfig.h') environment = jinja2.Environment(loader=jinja2.FileSystemLoader( @@ -543,6 +543,7 @@ def make_package(args): } if get_bootstrap_name() == "sdl2": render_args["url_scheme"] = url_scheme + render( 'AndroidManifest.tmpl.xml', manifest_path, @@ -571,7 +572,8 @@ def make_package(args): render( 'gradle.tmpl.properties', 'gradle.properties', - args=args) + args=args, + bootstrap_name=get_bootstrap_name()) # ant build templates render( @@ -601,6 +603,26 @@ def make_package(args): join(res_dir, 'values/strings.xml'), **render_args) + # Library resources from Qt + # These are referred by QtLoader.java in Qt6AndroidBindings.jar + # qt_libs and load_local_libs are loaded at App startup + if get_bootstrap_name() == "qt": + qt_libs = args.qt_libs.split(",") + load_local_libs = args.load_local_libs.split(",") + init_classes = args.init_classes + if init_classes: + init_classes = init_classes.split(",") + init_classes = ":".join(init_classes) + arch = get_dist_info_for("archs")[0] + render( + 'libs.tmpl.xml', + join(res_dir, 'values/libs.xml'), + qt_libs=qt_libs, + load_local_libs=load_local_libs, + init_classes=init_classes, + arch=arch + ) + if exists(join("templates", "custom_rules.tmpl.xml")): render( 'custom_rules.tmpl.xml', @@ -951,6 +973,14 @@ def create_argument_parser(): help='Use that parameter if you need to implement your own PythonServive Java class') ap.add_argument('--activity-class-name', dest='activity_class_name', default=DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS, help='The full java class name of the main activity') + if get_bootstrap_name() == "qt": + ap.add_argument('--qt-libs', dest='qt_libs', required=True, + help='comma separated list of Qt libraries to be loaded') + ap.add_argument('--load-local-libs', dest='load_local_libs', required=True, + help='comma separated list of Qt plugin libraries to be loaded') + ap.add_argument('--init-classes', dest='init_classes', default='', + help='comma separated list of java class names to be loaded from the Qt jar files, ' + 'specified through add_jar cli option') return ap diff --git a/pythonforandroid/bootstraps/common/build/templates/gradle.tmpl.properties b/pythonforandroid/bootstraps/common/build/templates/gradle.tmpl.properties index f99dd5a052..cea16375d2 100644 --- a/pythonforandroid/bootstraps/common/build/templates/gradle.tmpl.properties +++ b/pythonforandroid/bootstraps/common/build/templates/gradle.tmpl.properties @@ -1,3 +1,8 @@ +{% if bootstrap_name == "qt" %} +# For tweaking memory settings. Otherwise, a p4a session with Qt bootstrap and PySide6 recipe +# terminates with a Java out of memory exception +org.gradle.jvmargs=-Xmx2500m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +{% endif %} {% if args.enable_androidx %} android.useAndroidX=true android.enableJetifier=true diff --git a/pythonforandroid/bootstraps/qt/__init__.py b/pythonforandroid/bootstraps/qt/__init__.py new file mode 100644 index 0000000000..9a6e03f064 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/__init__.py @@ -0,0 +1,53 @@ +import sh +from os.path import join +from pythonforandroid.toolchain import ( + Bootstrap, current_directory, info, info_main, shprint) +from pythonforandroid.util import ensure_dir, rmdir + + +class QtBootstrap(Bootstrap): + name = 'qt' + recipe_depends = ['python3', 'genericndkbuild', 'PySide6', 'shiboken6'] + # this is needed because the recipes PySide6 and shiboken6 resides in the PySide Qt repository + # - https://code.qt.io/cgit/pyside/pyside-setup.git/ + # Without this some tests will error because it cannot find the recipes within pythonforandroid + # repository + can_be_chosen_automatically = False + + def assemble_distribution(self): + info_main("# Creating Android project using Qt bootstrap") + + rmdir(self.dist_dir) + info("Copying gradle build") + shprint(sh.cp, '-r', self.build_dir, self.dist_dir) + + with current_directory(self.dist_dir): + with open('local.properties', 'w') as fileh: + fileh.write('sdk.dir={}'.format(self.ctx.sdk_dir)) + + arch = self.ctx.archs[0] + if len(self.ctx.archs) > 1: + raise ValueError("Trying to build for more than one arch. Qt bootstrap cannot handle that yet") + + info(f"Bootstrap running with arch {arch}") + + with current_directory(self.dist_dir): + info("Copying Python distribution") + + self.distribute_libs(arch, [self.ctx.get_libs_dir(arch.arch)]) + self.distribute_aars(arch) + self.distribute_javaclasses(self.ctx.javaclass_dir, + dest_dir=join("src", "main", "java")) + + python_bundle_dir = join(f'_python_bundle__{arch.arch}', '_python_bundle') + ensure_dir(python_bundle_dir) + site_packages_dir = self.ctx.python_recipe.create_python_bundle( + join(self.dist_dir, python_bundle_dir), arch) + + if not self.ctx.with_debug_symbols: + self.strip_libraries(arch) + self.fry_eggs(site_packages_dir) + super().assemble_distribution() + + +bootstrap = QtBootstrap() diff --git a/pythonforandroid/bootstraps/qt/build/.gitignore b/pythonforandroid/bootstraps/qt/build/.gitignore new file mode 100644 index 0000000000..a1fc39c070 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/.gitignore @@ -0,0 +1,14 @@ +.gradle +/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties diff --git a/pythonforandroid/bootstraps/qt/build/blacklist.txt b/pythonforandroid/bootstraps/qt/build/blacklist.txt new file mode 100644 index 0000000000..65f6e4df2e --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/blacklist.txt @@ -0,0 +1,70 @@ +# prevent user to include invalid extensions +*.apk +*.aab +*.apks +*.pxd + +# eggs +*.egg-info + +# unit test +unittest/* + +# python config +config/makesetup + +# unused encodings +lib-dynload/*codec* +encodings/cp*.pyo +encodings/tis* +encodings/shift* +encodings/bz2* +encodings/iso* +encodings/undefined* +encodings/johab* +encodings/p* +encodings/m* +encodings/euc* +encodings/k* +encodings/unicode_internal* +encodings/quo* +encodings/gb* +encodings/big5* +encodings/hp* +encodings/hz* + +# unused python modules +bsddb/* +wsgiref/* +hotshot/* +pydoc_data/* +tty.pyo +anydbm.pyo +nturl2path.pyo +LICENCE.txt +macurl2path.pyo +dummy_threading.pyo +audiodev.pyo +antigravity.pyo +dumbdbm.pyo +sndhdr.pyo +__phello__.foo.pyo +sunaudio.pyo +os2emxpath.pyo +multiprocessing/dummy* + +# unused binaries python modules +lib-dynload/termios.so +lib-dynload/_lsprof.so +lib-dynload/*audioop.so +lib-dynload/_hotshot.so +lib-dynload/_heapq.so +lib-dynload/_json.so +lib-dynload/grp.so +lib-dynload/resource.so +lib-dynload/pyexpat.so +lib-dynload/_ctypes_test.so +lib-dynload/_testcapi.so + +# odd files +plat-linux3/regen diff --git a/pythonforandroid/bootstraps/qt/build/jni/Application.mk b/pythonforandroid/bootstraps/qt/build/jni/Application.mk new file mode 100644 index 0000000000..e3d23e5be1 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/jni/Application.mk @@ -0,0 +1,8 @@ + +# Uncomment this if you're using STL in your project +# See CPLUSPLUS-SUPPORT.html in the NDK documentation for more information +# APP_STL := stlport_static + +# APP_ABI := armeabi armeabi-v7a x86 +APP_ABI := $(ARCH) +APP_PLATFORM := $(NDK_API) diff --git a/pythonforandroid/bootstraps/qt/build/jni/application/src/Android.mk b/pythonforandroid/bootstraps/qt/build/jni/application/src/Android.mk new file mode 100644 index 0000000000..aebe3f623b --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/jni/application/src/Android.mk @@ -0,0 +1,18 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := main_$(PREFERRED_ABI) + +# Add your application source files here... +LOCAL_SRC_FILES := start.c + +LOCAL_CFLAGS += -I$(PYTHON_INCLUDE_ROOT) $(EXTRA_CFLAGS) + +LOCAL_SHARED_LIBRARIES := python_shared + +LOCAL_LDLIBS := -llog $(EXTRA_LDLIBS) + +LOCAL_LDFLAGS += -L$(PYTHON_LINK_ROOT) $(APPLICATION_ADDITIONAL_LDFLAGS) + +include $(BUILD_SHARED_LIBRARY) diff --git a/pythonforandroid/bootstraps/qt/build/jni/application/src/Android_static.mk b/pythonforandroid/bootstraps/qt/build/jni/application/src/Android_static.mk new file mode 100644 index 0000000000..1bb58cb76d --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/jni/application/src/Android_static.mk @@ -0,0 +1,9 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := main_$(PREFERRED_ABI) + +LOCAL_SRC_FILES := start.c + +include $(BUILD_SHARED_LIBRARY) diff --git a/pythonforandroid/bootstraps/qt/build/jni/application/src/bootstrap_name.h b/pythonforandroid/bootstraps/qt/build/jni/application/src/bootstrap_name.h new file mode 100644 index 0000000000..8a4d8aa464 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/jni/application/src/bootstrap_name.h @@ -0,0 +1,4 @@ + +#define BOOTSTRAP_USES_NO_SDL_HEADERS + +const char bootstrap_name[] = "qt"; diff --git a/pythonforandroid/bootstraps/qt/build/src/main/assets/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/src/main/java/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/java/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/src/main/java/org/kivy/android/PythonActivity.java b/pythonforandroid/bootstraps/qt/build/src/main/java/org/kivy/android/PythonActivity.java new file mode 100644 index 0000000000..81cad01616 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/src/main/java/org/kivy/android/PythonActivity.java @@ -0,0 +1,245 @@ +package org.kivy.android; + +import android.os.SystemClock; + +import java.io.File; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ArrayList; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.view.KeyEvent; +import android.util.Log; +import android.widget.Toast; +import android.os.Bundle; +import android.os.PowerManager; +import android.content.Context; +import android.content.pm.PackageManager; + +import org.qtproject.qt.android.bindings.QtActivity; +import org.qtproject.qt.android.QtNative; + +public class PythonActivity extends QtActivity { + + private static final String TAG = "PythonActivity"; + + public static PythonActivity mActivity = null; + + private Bundle mMetaData = null; + private PowerManager.WakeLock mWakeLock = null; + + public String getAppRoot() { + String app_root = getFilesDir().getAbsolutePath() + "/app"; + return app_root; + } + + public String getEntryPoint(String search_dir) { + /* Get the main file (.pyc|.py) depending on if we + * have a compiled version or not. + */ + List entryPoints = new ArrayList(); + entryPoints.add("main.pyc"); // python 3 compiled files + for (String value : entryPoints) { + File mainFile = new File(search_dir + "/" + value); + if (mainFile.exists()) { + return value; + } + } + return "main.py"; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + this.mActivity = this; + Log.v(TAG, "Ready to unpack"); + File app_root_file = new File(getAppRoot()); + PythonUtil.unpackAsset(mActivity, "private", app_root_file, true); + PythonUtil.unpackPyBundle(mActivity, getApplicationInfo().nativeLibraryDir + "/" + "libpybundle", app_root_file, false); + + Log.v("Python", "Device: " + android.os.Build.DEVICE); + Log.v("Python", "Model: " + android.os.Build.MODEL); + + // Set up the Python environment + String app_root_dir = getAppRoot(); + String mFilesDirectory = mActivity.getFilesDir().getAbsolutePath(); + String entry_point = getEntryPoint(app_root_dir); + + Log.v(TAG, "Setting env vars for start.c and Python to use"); + QtNative.setEnvironmentVariable("ANDROID_ENTRYPOINT", entry_point); + QtNative.setEnvironmentVariable("ANDROID_ARGUMENT", app_root_dir); + QtNative.setEnvironmentVariable("ANDROID_APP_PATH", app_root_dir); + QtNative.setEnvironmentVariable("ANDROID_PRIVATE", mFilesDirectory); + QtNative.setEnvironmentVariable("ANDROID_UNPACK", app_root_dir); + QtNative.setEnvironmentVariable("PYTHONHOME", app_root_dir); + QtNative.setEnvironmentVariable("PYTHONPATH", app_root_dir + ":" + app_root_dir + "/lib"); + QtNative.setEnvironmentVariable("PYTHONOPTIMIZE", "2"); + + Log.v(TAG, "About to do super onCreate"); + super.onCreate(savedInstanceState); + Log.v(TAG, "Did super onCreate"); + + this.mActivity = this; + try { + Log.v(TAG, "Access to our meta-data..."); + mActivity.mMetaData = mActivity.getPackageManager().getApplicationInfo( + mActivity.getPackageName(), PackageManager.GET_META_DATA).metaData; + + PowerManager pm = (PowerManager) mActivity.getSystemService(Context.POWER_SERVICE); + if ( mActivity.mMetaData.getInt("wakelock") == 1 ) { + mActivity.mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "Screen On"); + mActivity.mWakeLock.acquire(); + } + } catch (PackageManager.NameNotFoundException e) { + } + } + + @Override + public void onDestroy() { + Log.i("Destroy", "end of app"); + super.onDestroy(); + + // make sure all child threads (python_thread) are stopped + android.os.Process.killProcess(android.os.Process.myPid()); + } + + long lastBackClick = SystemClock.elapsedRealtime(); + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // If it wasn't the Back key or there's no web page history, bubble up to the default + // system behavior (probably exit the activity) + if (SystemClock.elapsedRealtime() - lastBackClick > 2000){ + lastBackClick = SystemClock.elapsedRealtime(); + Toast.makeText(this, "Click again to close the app", + Toast.LENGTH_LONG).show(); + return true; + } + + lastBackClick = SystemClock.elapsedRealtime(); + return super.onKeyDown(keyCode, event); + } + + + //---------------------------------------------------------------------------- + // Listener interface for onNewIntent + // + + public interface NewIntentListener { + void onNewIntent(Intent intent); + } + + private List newIntentListeners = null; + + public void registerNewIntentListener(NewIntentListener listener) { + if ( this.newIntentListeners == null ) + this.newIntentListeners = Collections.synchronizedList(new ArrayList()); + this.newIntentListeners.add(listener); + } + + public void unregisterNewIntentListener(NewIntentListener listener) { + if ( this.newIntentListeners == null ) + return; + this.newIntentListeners.remove(listener); + } + + @Override + protected void onNewIntent(Intent intent) { + if ( this.newIntentListeners == null ) + return; + this.onResume(); + synchronized ( this.newIntentListeners ) { + Iterator iterator = this.newIntentListeners.iterator(); + while ( iterator.hasNext() ) { + (iterator.next()).onNewIntent(intent); + } + } + } + + //---------------------------------------------------------------------------- + // Listener interface for onActivityResult + // + + public interface ActivityResultListener { + void onActivityResult(int requestCode, int resultCode, Intent data); + } + + private List activityResultListeners = null; + + public void registerActivityResultListener(ActivityResultListener listener) { + if ( this.activityResultListeners == null ) + this.activityResultListeners = Collections.synchronizedList(new ArrayList()); + this.activityResultListeners.add(listener); + } + + public void unregisterActivityResultListener(ActivityResultListener listener) { + if ( this.activityResultListeners == null ) + return; + this.activityResultListeners.remove(listener); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent intent) { + if ( this.activityResultListeners == null ) + return; + this.onResume(); + synchronized ( this.activityResultListeners ) { + Iterator iterator = this.activityResultListeners.iterator(); + while ( iterator.hasNext() ) + (iterator.next()).onActivityResult(requestCode, resultCode, intent); + } + } + + public static void start_service( + String serviceTitle, + String serviceDescription, + String pythonServiceArgument + ) { + _do_start_service( + serviceTitle, serviceDescription, pythonServiceArgument, true + ); + } + + public static void start_service_not_as_foreground( + String serviceTitle, + String serviceDescription, + String pythonServiceArgument + ) { + _do_start_service( + serviceTitle, serviceDescription, pythonServiceArgument, false + ); + } + + public static void _do_start_service( + String serviceTitle, + String serviceDescription, + String pythonServiceArgument, + boolean showForegroundNotification + ) { + Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); + String argument = PythonActivity.mActivity.getFilesDir().getAbsolutePath(); + String app_root_dir = PythonActivity.mActivity.getAppRoot(); + String entry_point = PythonActivity.mActivity.getEntryPoint(app_root_dir + "/service"); + serviceIntent.putExtra("androidPrivate", argument); + serviceIntent.putExtra("androidArgument", app_root_dir); + serviceIntent.putExtra("serviceEntrypoint", "service/" + entry_point); + serviceIntent.putExtra("pythonName", "python"); + serviceIntent.putExtra("pythonHome", app_root_dir); + serviceIntent.putExtra("pythonPath", app_root_dir + ":" + app_root_dir + "/lib"); + serviceIntent.putExtra("serviceStartAsForeground", + (showForegroundNotification ? "true" : "false") + ); + serviceIntent.putExtra("serviceTitle", serviceTitle); + serviceIntent.putExtra("serviceDescription", serviceDescription); + serviceIntent.putExtra("pythonServiceArgument", pythonServiceArgument); + PythonActivity.mActivity.startService(serviceIntent); + } + + public static void stop_service() { + Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); + PythonActivity.mActivity.stopService(serviceIntent); + } + +} diff --git a/pythonforandroid/bootstraps/qt/build/src/main/jniLibs/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/jniLibs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/src/main/libs/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/libs/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/src/main/res/drawable/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/res/drawable/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/src/main/res/mipmap/.gitkeep b/pythonforandroid/bootstraps/qt/build/src/main/res/mipmap/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pythonforandroid/bootstraps/qt/build/templates/AndroidManifest.tmpl.xml b/pythonforandroid/bootstraps/qt/build/templates/AndroidManifest.tmpl.xml new file mode 100644 index 0000000000..057794e4ed --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/templates/AndroidManifest.tmpl.xml @@ -0,0 +1,106 @@ + + + + = 9 %} + android:xlargeScreens="true" + {% endif %} + /> + + + + + + {% for perm in args.permissions %} + + {% endfor %} + + {% if args.wakelock %} + + {% endif %} + + {% if args.billing_pubkey %} + + {% endif %} + + {{ args.extra_manifest_xml }} + + + + {% for l in args.android_used_libs %} + + {% endfor %} + + {% for m in args.meta_data %} + {% endfor %} + + + + + + + + + + {%- if args.intent_filters -%} + {{- args.intent_filters -}} + {%- endif -%} + + + + + + + + + {% if service or args.launcher %} + + {% endif %} + {% for name in service_names %} + + {% endfor %} + {% for name in native_services %} + + {% endfor %} + + {% for a in args.add_activity %} + + {% endfor %} + + + diff --git a/pythonforandroid/bootstraps/qt/build/templates/libs.tmpl.xml b/pythonforandroid/bootstraps/qt/build/templates/libs.tmpl.xml new file mode 100644 index 0000000000..d423f4152b --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/templates/libs.tmpl.xml @@ -0,0 +1,27 @@ + + + + {{ arch }};c++_shared + {%- for qt_lib in qt_libs %} + {{ arch }};Qt6{{ qt_lib }}_{{ arch }} + {%- endfor -%} + + + + {%- for load_local_lib in load_local_libs %} + {{ arch }};lib{{ load_local_lib }}_{{ arch }}.so + {%- endfor -%} + {{ arch }};libshiboken6.abi3.so + {{ arch }};libpyside6.abi3.so + {%- for qt_lib in qt_libs %} + {{ arch }};Qt{{ qt_lib }}.abi3.so + {% if qt_lib == "Qml" -%} + {{ arch }};libpyside6qml.abi3.so + {% endif %} + {%- endfor -%} + + + {{ init_classes }} + 1 + 1 + diff --git a/pythonforandroid/bootstraps/qt/build/templates/strings.tmpl.xml b/pythonforandroid/bootstraps/qt/build/templates/strings.tmpl.xml new file mode 100644 index 0000000000..41c20ac663 --- /dev/null +++ b/pythonforandroid/bootstraps/qt/build/templates/strings.tmpl.xml @@ -0,0 +1,6 @@ + + + {{ args.name }} + {{ private_version }} + {{ args.presplash_color }} + diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index dc53fd4337..bbd61e603d 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -449,7 +449,7 @@ def unpack(self, arch): extraction_filename = join( self.ctx.packages_path, self.name, filename) if isfile(extraction_filename): - if extraction_filename.endswith('.zip'): + if extraction_filename.endswith(('.zip', '.whl')): try: sh.unzip(extraction_filename) except (sh.ErrorReturnCode_1, sh.ErrorReturnCode_2): @@ -1171,6 +1171,9 @@ def reduce_object_file_names(self, dirn): parts = file_basename.split('.') if len(parts) <= 2: continue + # PySide6 libraries end with .abi3.so + if parts[1] == "abi3": + continue move(filen, join(file_dirname, parts[0] + '.so')) diff --git a/pythonforandroid/recipes/android/__init__.py b/pythonforandroid/recipes/android/__init__.py index e568ac8d9e..608d9ee738 100644 --- a/pythonforandroid/recipes/android/__init__.py +++ b/pythonforandroid/recipes/android/__init__.py @@ -35,7 +35,7 @@ def prebuild_arch(self, arch): ctx_bootstrap = ctx_bootstrap.decode('utf-8') bootstrap = bootstrap_name = ctx_bootstrap is_sdl2 = (bootstrap_name == "sdl2") - if bootstrap_name in ["sdl2", "webview", "service_only", "service_library"]: + if bootstrap_name in ["sdl2", "webview", "service_only", "service_library", "qt"]: java_ns = u'org.kivy.android' jni_ns = u'org/kivy/android' else: diff --git a/pythonforandroid/recipes/genericndkbuild/__init__.py b/pythonforandroid/recipes/genericndkbuild/__init__.py index 901f208986..8b2a9c26a2 100644 --- a/pythonforandroid/recipes/genericndkbuild/__init__.py +++ b/pythonforandroid/recipes/genericndkbuild/__init__.py @@ -21,6 +21,8 @@ def get_recipe_env(self, arch=None, with_flags_in_cc=True, with_python=True): with_python=with_python, ) env['APP_ALLOW_MISSING_DEPS'] = 'true' + # required for Qt bootstrap + env['PREFERRED_ABI'] = arch.arch return env def build_arch(self, arch): diff --git a/testapps/on_device_unit_tests/test_app/main.py b/testapps/on_device_unit_tests/test_app/main.py index 48bf0dc33d..31422e8939 100644 --- a/testapps/on_device_unit_tests/test_app/main.py +++ b/testapps/on_device_unit_tests/test_app/main.py @@ -13,6 +13,7 @@ - A kivy unittest app (sdl2 bootstrap) - A unittest app (webview bootstrap) - A non-gui unittests app + - A non-gui Qt app (qt bootstrap) If you install/build this app via the `setup.py` file, a file named `app_requirements.txt` will be generated which will contain the requirements diff --git a/testapps/on_device_unit_tests/test_app/tests/test_requirements.py b/testapps/on_device_unit_tests/test_app/tests/test_requirements.py index e4104f8300..451f9fbf0c 100644 --- a/testapps/on_device_unit_tests/test_app/tests/test_requirements.py +++ b/testapps/on_device_unit_tests/test_app/tests/test_requirements.py @@ -251,3 +251,25 @@ def test_run_module(self): import libtorrent as lt print('Imported libtorrent version {}'.format(lt.version)) + + +class Pyside6TestCase(PythonTestMixIn, TestCase): + module_import = 'PySide6' + + def test_run_module(self): + import PySide6 + from PySide6.QtCore import QDateTime + from PySide6 import QtWidgets + + print(f"Imported PySide6 version {PySide6.__version__}") + print(f"Current date and time obtained from PySide6 : {QDateTime.currentDateTime().toString()}") + + +class Shiboken6TestCase(PythonTestMixIn, TestCase): + module_import = 'shiboken6' + + def test_run_module(self): + import shiboken6 + from shiboken6 import Shiboken + + print('Imported shiboken6 version {}'.format(shiboken6.__version__)) diff --git a/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6Android.jar b/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6Android.jar new file mode 100644 index 0000000000..c09f18fa6b Binary files /dev/null and b/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6Android.jar differ diff --git a/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6AndroidBindings.jar b/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6AndroidBindings.jar new file mode 100644 index 0000000000..d656c34d7a Binary files /dev/null and b/testapps/on_device_unit_tests/test_qt/jar/PySide6/jar/Qt6AndroidBindings.jar differ diff --git a/testapps/on_device_unit_tests/test_qt/recipes/PySide6/__init__.py b/testapps/on_device_unit_tests/test_qt/recipes/PySide6/__init__.py new file mode 100644 index 0000000000..6c795746ee --- /dev/null +++ b/testapps/on_device_unit_tests/test_qt/recipes/PySide6/__init__.py @@ -0,0 +1,63 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import shutil +import zipfile +from os.path import join +from pathlib import Path + +from pythonforandroid.logger import info +from pythonforandroid.recipe import PythonRecipe + + +class PySideRecipe(PythonRecipe): + version = '6.6.0a1' + # This will download the aarch64 wheel from the Qt servers. + # This wheel is only for testing purposes. This test will be update when PySide releases + # official PySide6 Android wheels. + url = ("https://download.qt.io/snapshots/ci/pyside/test/Android/aarch64/" + "PySide6-6.6.0a1-6.6.0-cp37-abi3-android_aarch64.whl") + wheel_name = 'PySide6-6.6.0a1-6.6.0-cp37-abi3-android_aarch64.whl' + depends = ["shiboken6"] + call_hostpython_via_targetpython = False + install_in_hostpython = False + + def build_arch(self, arch): + """Unzip the wheel and copy into site-packages of target""" + + self.wheel_path = join(self.ctx.packages_path, self.name, self.wheel_name) + info("Copying libc++_shared.so from SDK to be loaded on startup") + libcpp_path = f"{self.ctx.ndk.sysroot_lib_dir}/{arch.command_prefix}/libc++_shared.so" + shutil.copyfile(libcpp_path, Path(self.ctx.get_libs_dir(arch.arch)) / "libc++_shared.so") + + info(f"Installing {self.name} into site-packages") + with zipfile.ZipFile(self.wheel_path, "r") as zip_ref: + info("Unzip wheels and copy into {}".format(self.ctx.get_python_install_dir(arch.arch))) + zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch)) + + lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/PySide6/Qt/lib") + + info("Copying Qt libraries to be loaded on startup") + shutil.copytree(lib_dir, self.ctx.get_libs_dir(arch.arch), dirs_exist_ok=True) + shutil.copyfile(lib_dir.parent.parent / "libpyside6.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "libpyside6.abi3.so") + + shutil.copyfile(lib_dir.parent.parent / "QtCore.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtCore.abi3.so") + + shutil.copyfile(lib_dir.parent.parent / "QtWidgets.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtWidgets.abi3.so") + + shutil.copyfile(lib_dir.parent.parent / "QtGui.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtGui.abi3.so") + + plugin_path = (lib_dir.parent / "plugins" / "platforms" / + f"libplugins_platforms_qtforandroid_{arch.arch}.so") + + if plugin_path.exists(): + shutil.copyfile(plugin_path, + (Path(self.ctx.get_libs_dir(arch.arch)) / + f"libplugins_platforms_qtforandroid_{arch.arch}.so")) + + +recipe = PySideRecipe() diff --git a/testapps/on_device_unit_tests/test_qt/recipes/shiboken6/__init__.py b/testapps/on_device_unit_tests/test_qt/recipes/shiboken6/__init__.py new file mode 100644 index 0000000000..71feb18d27 --- /dev/null +++ b/testapps/on_device_unit_tests/test_qt/recipes/shiboken6/__init__.py @@ -0,0 +1,39 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import shutil +import zipfile +from os.path import join +from pathlib import Path + +from pythonforandroid.logger import info +from pythonforandroid.recipe import PythonRecipe + + +class ShibokenRecipe(PythonRecipe): + version = '6.6.0a1' + # This will download the aarch64 wheel from the Qt servers. + # This wheel is only for testing purposes. This test will be update when PySide releases + # official shiboken6 Android wheels. + url = ("https://download.qt.io/snapshots/ci/pyside/test/Android/aarch64/" + "shiboken6-6.6.0a1-6.6.0-cp37-abi3-android_aarch64.whl") + wheel_name = 'shiboken6-6.6.0a1-6.6.0-cp37-abi3-android_aarch64.whl' + + call_hostpython_via_targetpython = False + install_in_hostpython = False + + def build_arch(self, arch): + ''' Unzip the wheel and copy into site-packages of target''' + + self.wheel_path = join(self.ctx.packages_path, self.name, self.wheel_name) + info('Installing {} into site-packages'.format(self.name)) + with zipfile.ZipFile(self.wheel_path, 'r') as zip_ref: + info('Unzip wheels and copy into {}'.format(self.ctx.get_python_install_dir(arch.arch))) + zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch)) + + lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/shiboken6") + shutil.copyfile(lib_dir / "libshiboken6.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "libshiboken6.abi3.so") + + +recipe = ShibokenRecipe() diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1e17a9bd18..742ea0ba73 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -144,9 +144,9 @@ def test_all_bootstraps(self): """A test which will initialize a bootstrap and will check if the method :meth:`~pythonforandroid.bootstrap.Bootstrap.all_bootstraps ` returns the expected values, which should be: `empty", `service_only`, - `webview` and `sdl2` + `webview`, `sdl2` and `qt` """ - expected_bootstraps = {"empty", "service_only", "service_library", "webview", "sdl2"} + expected_bootstraps = {"empty", "service_only", "service_library", "webview", "sdl2", "qt"} set_of_bootstraps = Bootstrap.all_bootstraps() self.assertEqual( expected_bootstraps, expected_bootstraps & set_of_bootstraps @@ -344,6 +344,7 @@ def bootstrap_name(self): name of the bootstrap to test""" raise NotImplementedError("Not implemented in GenericBootstrapTest") + @mock.patch("pythonforandroid.bootstraps.qt.open", create=True) @mock.patch("pythonforandroid.bootstraps.service_only.open", create=True) @mock.patch("pythonforandroid.bootstraps.webview.open", create=True) @mock.patch("pythonforandroid.bootstraps.sdl2.open", create=True) @@ -370,6 +371,7 @@ def test_assemble_distribution( mock_open_sdl2_files, mock_open_webview_files, mock_open_service_only_files, + mock_open_qt_files ): """ A test for any overwritten method of @@ -410,6 +412,7 @@ def test_assemble_distribution( "sdl2": mock_open_sdl2_files, "webview": mock_open_webview_files, "service_only": mock_open_service_only_files, + "qt": mock_open_qt_files } expected_open_calls = { "sdl2": [ @@ -418,6 +421,7 @@ def test_assemble_distribution( ], "webview": [mock.call("local.properties", "w")], "service_only": [mock.call("local.properties", "w")], + "qt": [mock.call("local.properties", "w")] } mock_open_bs = mock_open_bootstraps[self.bootstrap_name] # test that the expected calls has been called @@ -659,3 +663,15 @@ def test_assemble_distribution(self, *args): with self.assertRaises(SystemExit) as e: bs.assemble_distribution() self.assertEqual(e.exception.args[0], 1) + + +class TestBootstrapQt(GenericBootstrapTest, unittest.TestCase): + """ + An inherited class of `GenericBootstrapTest` and `unittest.TestCase` which + will be used to perform tests for + :class:`~pythonforandroid.bootstraps.qt.BootstrapQt`. + """ + + @property + def bootstrap_name(self): + return "qt"