diff --git a/ci/makefiles/android.mk b/ci/makefiles/android.mk index 6550daa0ba..17f3ff6e9d 100644 --- a/ci/makefiles/android.mk +++ b/ci/makefiles/android.mk @@ -6,7 +6,7 @@ ANDROID_NDK_VERSION_LEGACY ?= 21e ANDROID_SDK_TOOLS_VERSION ?= 6514223 ANDROID_SDK_BUILD_TOOLS_VERSION ?= 29.0.3 ANDROID_HOME ?= $(HOME)/.android -ANDROID_API_LEVEL ?= 27 +ANDROID_API_LEVEL ?= 30 # per OS dictionary-like UNAME_S := $(shell uname -s) diff --git a/doc/source/buildoptions.rst b/doc/source/buildoptions.rst index d9f97175a2..f58a84b869 100644 --- a/doc/source/buildoptions.rst +++ b/doc/source/buildoptions.rst @@ -88,6 +88,8 @@ options (this list may not be exhaustive): included in AndroidManifest.xml. - ``--service``: A service name and the Python script it should run. See :ref:`arbitrary_scripts_services`. +- ``--worker``: A worker name and the Python script it should run. See + :ref:`workers` for details. - ``--add-source``: Add a source directory to the app's Java code. - ``--no-compile-pyo``: Do not optimise .py files to .pyo. - ``--enable-androidx``: Enable AndroidX support library. @@ -149,6 +151,8 @@ ready. included in AndroidManifest.xml. - ``--service``: A service name and the Python script it should run. See :ref:`arbitrary_scripts_services`. +- ``--worker``: A worker name and the Python script it should run. See + :ref:`workers` for details. - ``add-source``: Add a source directory to the app's Java code. - ``--port``: The port on localhost that the WebView will access. Defaults to 5000. @@ -170,6 +174,8 @@ systems and frameworks. - ``--version``: The version number. - ``--service``: A service name and the Python script it should run. See :ref:`arbitrary_scripts_services`. +- ``--worker``: A worker name and the Python script it should run. See + :ref:`workers` for details. - ``--blacklist``: The path to a file containing blacklisted patterns that will be excluded from the final AAR. Defaults to ``./blacklist.txt``. - ``--whitelist``: The path to a file containing whitelisted patterns diff --git a/doc/source/index.rst b/doc/source/index.rst index 84a02b962e..2c6e1e8d7b 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -35,6 +35,7 @@ Contents recipes bootstraps services + workers troubleshooting docker contribute diff --git a/doc/source/workers.rst b/doc/source/workers.rst new file mode 100644 index 0000000000..c6656b43ec --- /dev/null +++ b/doc/source/workers.rst @@ -0,0 +1,129 @@ +Workers +======= + +python-for-android supports worker tasks using `WorkManager +`_. +``WorkManager`` tasks are the recommended way to perform both one-time +and recurring work with current Android. Starting with Android 12, +worker tasks will also be required to replace foreground services in +some cases. + +Each worker runs tasks in a service declared in the +``AndroidManifest.xml`` file. This is managed by python-for-android with +a service generated from the application package. The worker service is +specified to run in a separate process since python-for-android does not +support running multiple Python interpreters in the same process. + +Worker creation +--------------- + +To create the worker, create a python script with your worker code and +add a ``--worker=myworker:PATH_TO_WORKER_PY`` argument when calling +python-for-android. + +The ``myworker`` name before the colon is in the names of the worker and +worker service classes, via which you will interact with it later. + +The ``PATH_TO_WORKER_PY`` is the relative path to the worker entry point +(like ``workers/myworker.py``) + +You can add multiple ``--worker`` arguments to include multiple workers, +all of which you will later be able to stop and start from your app. + +Running workers +--------------- + +To run the workers (i.e. starting them from within your main app code), +you must use PyJNIus to interact with the Java class python-for-android +creates for each one. First, you need to create a work request using the +``buildInputData`` helper function which configures the work to run in +the appropriate service class:: + + from jnius import autoclass + + worker = autoclass('your.package.domain.package.name.MyworkerWorker') + OneTimeWorkRequestBuilder = autoclass('androidx.work.OneTimeWorkRequest$Builder') + argument = '' + data = worker.buildInputData(argument) + request = OneTimeWorkRequestBuilder(worker._class).setInputData(data).build() + +Here, ``your.package.domain.package.name`` refers to the package +identifier of your APK. The identifier is set by the ``--package`` +argument to python-for-android. The name of the worker is +``MyworkerWorker``, where ``Myworker`` is the identifier that was +previously passed to the ``--worker`` argument, but with the first +letter upper case. You must also pass the ``argument`` parameter even if +(as here) it is an empty string or `None`. If you do pass it, the +service can make use of this argument. + +The argument is made available to your worker via the +'PYTHON_SERVICE_ARGUMENT' environment variable. It is exposed as a +simple string, so if you want to pass in multiple values, we would +recommend using the json module to encode and decode more complex data. +:: + + from os import environ + argument = environ.get('PYTHON_SERVICE_ARGUMENT', '') + +Now the work request needs to be enqueued in the application's +`WorkManager +`_ +instance:: + + mActivity = autoclass('org.kivy.android.PythonActivity').mActivity + WorkManager = autoclass('androidx.work.WorkManager') + work_manager = WorkManager.getInstance(mActivity) + work_manager.enqueue(request) + +Enqueuing a work request is asynchronous and returns an `Operation +`_. To +block until the request has been queued, wait for the state to resolve:: + + operation = work_manager.enqueue(request) + operation.getResult().get() + +Once the work request has been queued, information about the request +such as its current state can be requested from ``WorkManager``:: + + request_id = request.getId() + work_info = work_manager.getWorkInfoById(request_id).get() + state = work_info.getState() + print('Work request state:', state.toString()) + if state.isFinished(): + print('Work request has completed') + +.. note:: + + The app root directory for Python imports will be in the app root + folder even if the worker file is in a subfolder. If the worker is + in the ``worker/`` folder, it must be imported with ``import + worker.module`` rather than ``import module``. + +Worker progress +~~~~~~~~~~~~~~~ + +A worker can send intermediate progress data for the work request that +can be retrieved in the activity. From the worker script, use the +``setProgressAsync`` method from the worker class instance:: + + from jnius import autoclass + + mWorker = autoclass('your.package.domain.package.name.MyworkerWorker').mWorker + DataBuilder = autoclass('androidx.work.Data$Builder') + + data = DataBuilder().putInt('PROGRESS', 50).build() + mWorker.setProgressAsync(data) + +The progress can be retrieved in the activity from the work request +information:: + + request_id = request.getId() + work_info = work_manager.getWorkInfoById(request_id).get() + progress = work_info.getProgress().getInt('PROGRESS', 0) + print('Work request {}% complete'.format(progress)) + +.. note:: + + At present, there is no method to return output data for the work + request. The work is either succeeded or failed based on the exit + status of the worker script. diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index 6885a333df..195ac70fe1 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -481,6 +481,39 @@ def make_package(args): base_service_class=base_service_class, ) + worker_names = [] + for spec in args.workers: + spec = spec.split(':') + name = spec[0] + entrypoint = spec[1] + + worker_names.append(name) + worker_target_path = \ + 'src/main/java/{}/{}Worker.java'.format( + args.package.replace(".", "/"), + name.capitalize() + ) + render( + 'Worker.tmpl.java', + worker_target_path, + name=name, + entrypoint=entrypoint, + args=args, + ) + + worker_service_target_path = \ + 'src/main/java/{}/{}WorkerService.java'.format( + args.package.replace(".", "/"), + name.capitalize() + ) + render( + 'WorkerService.tmpl.java', + worker_service_target_path, + name=name, + entrypoint=entrypoint, + args=args, + ) + # Find the SDK directory and target API with open('project.properties', 'r') as fileh: target = fileh.read().strip() @@ -497,6 +530,15 @@ def make_package(args): sdk_dir = fileh.read().strip() sdk_dir = sdk_dir[8:] + # Specific WorkManager versions require newer SDK versions. + # + # See https://developer.android.com/jetpack/androidx/releases/work + # for details. + if int(android_api) >= 31: + work_manager_version = '2.7.1' + else: + work_manager_version = '2.6.0' + # Try to build with the newest available build tools ignored = {".DS_Store", ".ds_store"} build_tools_versions = [x for x in listdir(join(sdk_dir, 'build-tools')) if x not in ignored] @@ -528,6 +570,7 @@ def make_package(args): "args": args, "service": service, "service_names": service_names, + "worker_names": worker_names, "android_api": android_api, "debug": "debug" in args.build_mode, "native_services": args.native_services @@ -556,6 +599,7 @@ def make_package(args): build_tools_version=build_tools_version, debug_build="debug" in args.build_mode, is_library=(get_bootstrap_name() == 'service_library'), + work_manager_version=work_manager_version, ) # gradle properties @@ -710,6 +754,9 @@ def parse_args_and_make_package(args=None): ap.add_argument('--service', dest='services', action='append', default=[], help='Declare a new service entrypoint: ' 'NAME:PATH_TO_PY[:foreground]') + ap.add_argument('--worker', dest='workers', action='append', default=[], + help='Declare a new worker entrypoint: ' + 'NAME:PATH_TO_PY') ap.add_argument('--native-service', dest='native_services', action='append', default=[], help='Declare a new native service: ' 'package.name.service') @@ -947,6 +994,11 @@ def _read_configuration(): '--launcher (SDL2 bootstrap only)' + 'to have something to launch inside the .apk!') sys.exit(1) + + if args.workers and not args.enable_androidx: + print('WARNING: Enabling androidx for worker support') + args.enable_androidx = True + make_package(args) return args diff --git a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c index d46b1fae0d..4cf2ed10a3 100644 --- a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c +++ b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -52,7 +53,7 @@ PyMODINIT_FUNC initandroidembed(void) { } #endif -int dir_exists(char *filename) { +static int dir_exists(char *filename) { struct stat st; if (stat(filename, &st) == 0) { if (S_ISDIR(st.st_mode)) @@ -61,7 +62,7 @@ int dir_exists(char *filename) { return 0; } -int file_exists(const char *filename) { +static int file_exists(const char *filename) { FILE *file; if ((file = fopen(filename, "r"))) { fclose(file); @@ -70,8 +71,7 @@ int file_exists(const char *filename) { return 0; } -/* int main(int argc, char **argv) { */ -int main(int argc, char *argv[]) { +static int run_python(int argc, char *argv[], bool call_exit) { char *env_argument = NULL; char *env_entrypoint = NULL; @@ -333,30 +333,37 @@ int main(int argc, char *argv[]) { https://github.com/kivy/kivy/pull/6107#issue-246120816 */ - char terminatecmd[256]; - snprintf( - terminatecmd, sizeof(terminatecmd), - "import sys; sys.exit(%d)\n", ret - ); - PyRun_SimpleString(terminatecmd); - - /* This should never actually be reached, but we'll leave the clean-up - * here just to be safe. + if (call_exit) { + char terminatecmd[256]; + snprintf( + terminatecmd, sizeof(terminatecmd), + "import sys; sys.exit(%d)\n", ret + ); + PyRun_SimpleString(terminatecmd); + } + + /* This should never actually be reached with call_exit. */ + if (call_exit) + LOGP("Unexpectedly reached python finalization"); #if PY_MAJOR_VERSION < 3 Py_Finalize(); - LOGP("Unexpectedly reached Py_FinalizeEx(), but was successful."); #else if (Py_FinalizeEx() != 0) // properly check success on Python 3 - LOGP("Unexpectedly reached Py_FinalizeEx(), and got error!"); - else - LOGP("Unexpectedly reached Py_FinalizeEx(), but was successful."); + LOGP("Py_FinalizeEx() returned an error!"); #endif return ret; } -JNIEXPORT void JNICALL Java_org_kivy_android_PythonService_nativeStart( +#ifdef BOOTSTRAP_NAME_SDL2 +int SDL_main(int argc, char *argv[]) { + LOGP("Entering SDL_main"); + return run_python(argc, argv, true); +} +#endif + +static int native_service_start( JNIEnv *env, jobject thiz, jstring j_android_private, @@ -365,7 +372,8 @@ JNIEXPORT void JNICALL Java_org_kivy_android_PythonService_nativeStart( jstring j_python_name, jstring j_python_home, jstring j_python_path, - jstring j_arg) { + jstring j_arg, + bool call_exit) { jboolean iscopy; const char *android_private = (*env)->GetStringUTFChars(env, j_android_private, &iscopy); @@ -394,9 +402,55 @@ JNIEXPORT void JNICALL Java_org_kivy_android_PythonService_nativeStart( char *argv[] = {"."}; /* ANDROID_ARGUMENT points to service subdir, - * so main() will run main.py from this dir + * so run_python() will run main.py from this dir */ - main(1, argv); + return run_python(1, argv, call_exit); +} + +JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( + JNIEnv *env, + jobject thiz, + jstring j_android_private, + jstring j_android_argument, + jstring j_service_entrypoint, + jstring j_python_name, + jstring j_python_home, + jstring j_python_path, + jstring j_arg) { + LOGP("Entering org.kivy.android.PythonService.nativeStart"); + return native_service_start(env, + thiz, + j_android_private, + j_android_argument, + j_service_entrypoint, + j_python_name, + j_python_home, + j_python_path, + j_arg, + true); +} + +JNIEXPORT int JNICALL Java_org_kivy_android_PythonWorker_nativeStart( + JNIEnv *env, + jobject thiz, + jstring j_android_private, + jstring j_android_argument, + jstring j_service_entrypoint, + jstring j_python_name, + jstring j_python_home, + jstring j_python_path, + jstring j_arg) { + LOGP("Entering org.kivy.android.PythonWorker.nativeStart"); + return native_service_start(env, + thiz, + j_android_private, + j_android_argument, + j_service_entrypoint, + j_python_name, + j_python_home, + j_python_path, + j_arg, + false); } #if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY) @@ -419,7 +473,7 @@ void Java_org_kivy_android_PythonActivity_nativeSetenv( } -void Java_org_kivy_android_PythonActivity_nativeInit(JNIEnv* env, jclass cls, jobject obj) +int Java_org_kivy_android_PythonActivity_nativeInit(JNIEnv* env, jclass cls, jobject obj) { /* This nativeInit follows SDL2 */ @@ -435,7 +489,7 @@ void Java_org_kivy_android_PythonActivity_nativeInit(JNIEnv* env, jclass cls, jo argv[1] = NULL; /* status = SDL_main(1, argv); */ - main(1, argv); + return run_python(1, argv, true); /* Do not issue an exit or the whole application will terminate instead of just the SDL thread */ /* exit(status); */ diff --git a/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonService.java b/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonService.java index dd6f307ec7..963e9244a0 100644 --- a/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonService.java +++ b/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonService.java @@ -184,7 +184,7 @@ public void run(){ } // Native part - public static native void nativeStart( + public static native int nativeStart( String androidPrivate, String androidArgument, String serviceEntrypoint, String pythonName, String pythonHome, String pythonPath, diff --git a/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonWorker.java b/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonWorker.java new file mode 100644 index 0000000000..d874bc7526 --- /dev/null +++ b/pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonWorker.java @@ -0,0 +1,130 @@ +package org.kivy.android; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.concurrent.futures.CallbackToFutureAdapter.Completer; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.work.ListenableWorker.Result; +import androidx.work.multiprocess.RemoteListenableWorker; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.io.File; +import java.lang.System; +import java.util.concurrent.Executors; + +import org.kivy.android.PythonUtil; + +public class PythonWorker extends RemoteListenableWorker { + private static final String TAG = "PythonWorker"; + + // WorkRequest data key for python service argument + public static final String ARGUMENT_SERVICE_ARGUMENT = "PYTHON_SERVICE_ARGUMENT"; + + // Python environment variables + private String androidPrivate; + private String androidArgument; + private String pythonName; + private String pythonHome; + private String pythonPath; + private String workerEntrypoint; + + public PythonWorker( + @NonNull Context context, + @NonNull WorkerParameters params) { + super(context, params); + + String appRoot = PythonUtil.getAppRoot(context); + + androidPrivate = appRoot; + androidArgument = appRoot; + pythonHome = appRoot; + pythonPath = appRoot + ":" + appRoot + "/lib"; + + File appRootFile = new File(appRoot); + PythonUtil.unpackAsset(context, "private", appRootFile, false); + PythonUtil.loadLibraries( + appRootFile, + new File(getApplicationContext().getApplicationInfo().nativeLibraryDir) + ); + } + + public void setPythonName(String value) { + pythonName = value; + } + + public void setWorkerEntrypoint(String value) { + workerEntrypoint = value; + } + + @Override + public ListenableFuture startRemoteWork() { + return CallbackToFutureAdapter.getFuture(completer -> { + String dataArg = getInputData().getString(ARGUMENT_SERVICE_ARGUMENT); + final String serviceArg; + if (dataArg != null) { + Log.d(TAG, "Setting python service argument to " + dataArg); + serviceArg = dataArg; + } else { + serviceArg = ""; + } + + // If the work is cancelled, exit the whole process since we + // have no other way to stop the python thread. + // + // FIXME: Unfortunately, exiting here causes the service to + // behave unreliably since all the connections are not + // unbound. Android will immediately restart the service to + // bind the connection again and eventually there are issues + // with the process not exiting to completely clear the + // Python environment. + completer.addCancellationListener(new Runnable() { + @Override + public void run() { + Log.i(TAG, "Exiting remote work service process"); + System.exit(0); + } + }, Executors.newSingleThreadExecutor()); + + // The python thread handling the work needs to be run in a + // separate thread so that future can be returned. Without + // it, any cancellation can't be processed. + final Thread pythonThread = new Thread(new Runnable() { + @Override + public void run() { + int res = nativeStart( + androidPrivate, androidArgument, + workerEntrypoint, pythonName, + pythonHome, pythonPath, + serviceArg + ); + Log.d(TAG, "Finished remote python work: " + res); + + if (res == 0) { + completer.set(Result.success()); + } else { + completer.set(Result.failure()); + } + } + }); + pythonThread.setName("python_worker_thread"); + + Log.i(TAG, "Starting remote python work"); + pythonThread.start(); + + return TAG + " work thread"; + }); + } + + // Native part + public static native int nativeStart( + String androidPrivate, String androidArgument, + String workerEntrypoint, String pythonName, + String pythonHome, String pythonPath, + String pythonServiceArgument + ); +} diff --git a/pythonforandroid/bootstraps/common/build/templates/Worker.tmpl.java b/pythonforandroid/bootstraps/common/build/templates/Worker.tmpl.java new file mode 100644 index 0000000000..7eafb77c0f --- /dev/null +++ b/pythonforandroid/bootstraps/common/build/templates/Worker.tmpl.java @@ -0,0 +1,45 @@ +package {{ args.package }}; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.WorkRequest; +import androidx.work.WorkerParameters; + +import org.kivy.android.PythonWorker; + +public class {{ name|capitalize }}Worker extends PythonWorker { + private static final String TAG = "{{ name|capitalize }}Worker"; + + public static {{ name|capitalize }}Worker mWorker = null; + + public {{ name|capitalize }}Worker ( + @NonNull Context context, + @NonNull WorkerParameters params) { + super(context, params); + setPythonName("{{ name }}"); + setWorkerEntrypoint("{{ entrypoint }}"); + mWorker = this; + } + + public static Data buildInputData (String serviceArgument) { + String dataArgument = serviceArgument == null ? "" : serviceArgument; + Data data = new Data.Builder() + .putString(ARGUMENT_SERVICE_ARGUMENT, dataArgument) + .putString(ARGUMENT_PACKAGE_NAME, "{{ args.package }}") + .putString(ARGUMENT_CLASS_NAME, + {{ name|capitalize }}WorkerService.class.getName()) + .build(); + Log.v(TAG, "Request data: " + data.toString()); + return data; + } + + public static WorkRequest buildWorkRequest ( + WorkRequest.Builder builder, + String serviceArgument) { + Data data = buildInputData(serviceArgument); + return builder.setInputData(data).build(); + } +} diff --git a/pythonforandroid/bootstraps/common/build/templates/WorkerService.tmpl.java b/pythonforandroid/bootstraps/common/build/templates/WorkerService.tmpl.java new file mode 100644 index 0000000000..e82d8ca44d --- /dev/null +++ b/pythonforandroid/bootstraps/common/build/templates/WorkerService.tmpl.java @@ -0,0 +1,41 @@ +package {{ args.package }}; + +import android.content.Context; +import android.util.Log; + +import androidx.work.Configuration; +import androidx.work.multiprocess.RemoteWorkerService; +import androidx.work.WorkManager; + +import java.lang.System; + +public class {{ name|capitalize }}WorkerService extends RemoteWorkerService { + private static final String TAG = "{{ name|capitalize }}WorkerService"; + + @Override + public void onCreate() { + try { + Log.v(TAG, "Initializing WorkManager"); + Context context = getApplicationContext(); + Configuration configuration = new Configuration.Builder() + .setDefaultProcessName(context.getPackageName()) + .build(); + WorkManager.initialize(context, configuration); + } catch (IllegalStateException e) { + } + super.onCreate(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + // The process needs to exit when the service is destroyed since + // p4a doesn't support starting a Python interpreter more than + // once per process. Combined with the stopWithTask="true" + // configuration in the manifest, this should ensure that the + // service process exits when a task completes. + Log.v(TAG, "Exiting service process"); + System.exit(0); + } +} diff --git a/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle b/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle index bb000393a4..72a843b5dd 100644 --- a/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle +++ b/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle @@ -100,6 +100,9 @@ android { main { jniLibs.srcDir 'libs' java { + {% if not args.enable_androidx %} + exclude 'org/kivy/android/PythonWorker.java' + {% endif %} {%- for adir, pattern in args.extra_source_dirs -%} srcDir '{{adir}}' @@ -127,8 +130,14 @@ dependencies { implementation '{{ depend }}' {%- endfor %} {%- endif %} - {% if args.presplash_lottie %} + {%- if args.presplash_lottie %} implementation 'com.airbnb.android:lottie:3.4.0' {%- endif %} + {%- if args.workers %} + implementation 'androidx.annotation:annotation:1.3.0' + implementation 'androidx.concurrent:concurrent-futures:1.1.0' + implementation 'androidx.work:work-runtime:{{ work_manager_version }}' + implementation 'androidx.work:work-multiprocess:{{ work_manager_version }}' + {%- endif %} } diff --git a/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml b/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml index 27b2f19433..d2af3f94f8 100644 --- a/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml +++ b/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml @@ -124,6 +124,12 @@ {% for name in native_services %} {% endfor %} + {% for name in worker_names %} + + {% endfor %} {% if args.billing_pubkey %} {% endfor %} + {% for name in worker_names %} + + {% endfor %} diff --git a/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml b/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml index d19ed32931..eac8cc2c78 100644 --- a/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml +++ b/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml @@ -82,6 +82,12 @@ android:process=":service_{{ name }}" android:exported="true" /> {% endfor %} + {% for name in worker_names %} + + {% endfor %} {% if args.billing_pubkey %} {% endfor %} + {% for name in worker_names %} + + {% endfor %} {% if args.billing_pubkey %} 0: + print(remaining, 'seconds remaining') + + progress = int((100.0 * (duration - remaining)) / duration) + set_progress(progress) + + remaining -= 1 + time.sleep(1) +set_progress(100) + +print('Exiting the test worker') diff --git a/testapps/on_device_unit_tests/test_app/screen_unittests.kv b/testapps/on_device_unit_tests/test_app/screen_unittests.kv index b04d98fd41..d87764d9ab 100644 --- a/testapps/on_device_unit_tests/test_app/screen_unittests.kv +++ b/testapps/on_device_unit_tests/test_app/screen_unittests.kv @@ -42,6 +42,10 @@ text: 'Test Service' font_size: sp(FONT_SIZE_SUBTITLE) on_press: root.parent.current = 'service' + Button: + text: 'Test Worker' + font_size: sp(FONT_SIZE_SUBTITLE) + on_press: root.parent.current = 'worker' Image: keep_ratio: False allow_stretch: True diff --git a/testapps/on_device_unit_tests/test_app/screen_worker.kv b/testapps/on_device_unit_tests/test_app/screen_worker.kv new file mode 100644 index 0000000000..d8c89a27f4 --- /dev/null +++ b/testapps/on_device_unit_tests/test_app/screen_worker.kv @@ -0,0 +1,78 @@ +#:import FONT_SIZE_SUBTITLE constants.FONT_SIZE_SUBTITLE +#:import FONT_SIZE_TEXT constants.FONT_SIZE_TEXT +#:import FONT_SIZE_TITLE constants.FONT_SIZE_TITLE + +#:import set_device_orientation tools.set_device_orientation +#:import Spacer20 widgets.Spacer20 +#:import CircularButton widgets.CircularButton + +#:set green_color (0.3, 0.5, 0, 1) +#:set red_color (1.0, 0, 0, 1) + +: + name: 'worker' + ScrollView: + BoxLayout: + orientation: 'vertical' + size_hint_y: None + height: self.minimum_height + Button: + text: 'Back to unittests' + font_size: sp(FONT_SIZE_SUBTITLE) + size_hint_y: None + height: dp(60) + on_press: root.parent.current = 'unittests' + Image: + keep_ratio: False + allow_stretch: True + source: 'static/coloursinv.png' + size_hint_y: None + height: dp(100) + Label: + text: + '[color=#999999]Test[/color] P4A ' \ + '[color=#999999]service[/color]' + size_hint_y: None + padding: 0, 20 + height: self.texture_size[1] + halign: 'center' + font_size: sp(FONT_SIZE_TITLE) + font_name: 'static/Blanka-Regular.otf' + text_size: root.width, None + markup: True + Spacer20: + Spacer20: + RelativeLayout: + size_hint_y: None + height: dp(100) + CircularButton: + text: 'Start Worker' if not app.work.running else 'Stop Worker' + pos_hint: {'center_x': .5} + background_color: red_color if not app.work.running else green_color + on_press: app.worker_button_pressed() + Spacer20: + Spacer20: + ProgressBar: + pos_hint: {'center_x': .5} + size_hint_x: .5 + size_hint_y: None + value: app.work.progress + Label: + text: 'Work state: ' + app.work.state + size_hint_y: None + height: self.texture_size[1] + halign: 'center' + text_size: root.width, None + font_size: sp(FONT_SIZE_TEXT) + Spacer20: + Spacer20: + Label: + text: + '[color=#ff5900]WARNING:[/color] ' \ + 'this test only works on an Android device' + markup: True + size_hint_y: None + height: self.texture_size[1] + halign: 'center' + text_size: root.width, None + font_size: sp(FONT_SIZE_TEXT) diff --git a/testapps/on_device_unit_tests/test_app/templates/index.html b/testapps/on_device_unit_tests/test_app/templates/index.html index 9fc6e06f56..ccf2df7599 100644 --- a/testapps/on_device_unit_tests/test_app/templates/index.html +++ b/testapps/on_device_unit_tests/test_app/templates/index.html @@ -134,6 +134,75 @@

Android tests

+
+ + + + +
+ {{ 'Worker started' if worker_running else 'Worker stopped' }} +
+ + +
+ + +

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..a0df5e6076 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 @@ -10,26 +10,27 @@ def test_run_module(self): import numpy as np arr = np.random.random((3, 3)) - det = np.linalg.det(arr) + np.linalg.det(arr) + class ScipyTestCase(PythonTestMixIn, TestCase): module_import = 'scipy' def test_run_module(self): import numpy as np - from scipy.cluster.vq import vq, kmeans, whiten - features = np.array([[ 1.9,2.3], - [ 1.5,2.5], - [ 0.8,0.6], - [ 0.4,1.8], - [ 0.1,0.1], - [ 0.2,1.8], - [ 2.0,0.5], - [ 0.3,1.5], - [ 1.0,1.0]]) + from scipy.cluster.vq import kmeans, whiten + features = np.array([[1.9, 2.3], + [1.5, 2.5], + [0.8, 0.6], + [0.4, 1.8], + [0.1, 0.1], + [0.2, 1.8], + [2.0, 0.5], + [0.3, 1.5], + [1.0, 1.0]]) whitened = whiten(features) - book = np.array((whitened[0],whitened[2])) - print('kmeans', kmeans(whitened,book)) + book = np.array((whitened[0], whitened[2])) + print('kmeans', kmeans(whitened, book)) class OpensslTestCase(PythonTestMixIn, TestCase): @@ -58,7 +59,7 @@ class KivyTestCase(PythonTestMixIn, TestCase): def test_run_module(self): # This import has side effects, if it works then it's an # indication that Kivy is okay - from kivy.core.window import Window + from kivy.core.window import Window # noqa: F401 class PyjniusTestCase(PythonTestMixIn, TestCase): @@ -102,7 +103,6 @@ def test_run_module(self): import os from PIL import ( Image as PilImage, - ImageOps, ImageFont, ImageDraw, ImageFilter, @@ -175,7 +175,7 @@ def test_run_module(self): f = Fernet(key) cryptography_encrypted = f.encrypt( b'A really secret message. Not for prying eyes.') - cryptography_decrypted = f.decrypt(cryptography_encrypted) + f.decrypt(cryptography_encrypted) class PycryptoTestCase(PythonTestMixIn, TestCase): @@ -187,7 +187,7 @@ def test_run_module(self): crypto_hash_message = 'A secret message' hash = SHA256.new() hash.update(crypto_hash_message) - crypto_hash_hexdigest = hash.hexdigest() + hash.hexdigest() class PycryptodomeTestCase(PythonTestMixIn, TestCase): @@ -211,7 +211,7 @@ def test_run_module(self): 'ok' if os.path.exists("rsa_key.bin") else 'fail')) self.assertTrue(os.path.exists("rsa_key.bin")) - print('\t -> Testing Public key:'.format(key.publickey().export_key())) + print('\t -> Testing Public key: {}'.format(key.publickey().export_key())) class ScryptTestCase(PythonTestMixIn, TestCase): @@ -229,7 +229,7 @@ class M2CryptoTestCase(PythonTestMixIn, TestCase): def test_run_module(self): from M2Crypto import SSL - ctx = SSL.Context('sslv23') + SSL.Context('sslv23') class Pysha3TestCase(PythonTestMixIn, TestCase): diff --git a/testapps/on_device_unit_tests/test_app/tools.py b/testapps/on_device_unit_tests/test_app/tools.py index 16dac01022..b9880ec6a4 100644 --- a/testapps/on_device_unit_tests/test_app/tools.py +++ b/testapps/on_device_unit_tests/test_app/tools.py @@ -55,7 +55,7 @@ def raise_error(error): try: from widgets import ErrorPopup except ImportError: - print('raise_error:', error) + print('raise_error:', error) return ErrorPopup(error_text=error).open() @@ -160,3 +160,40 @@ def set_device_orientation(direction): else: activity.setRequestedOrientation( ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) + + +@skip_if_not_running_from_android_device +def get_work_manager(): + """ + Return the application's `WorkManager` instance. + + .. warning:: This function will only be ran if executed from android""" + from jnius import autoclass + + WorkManager = autoclass('androidx.work.WorkManager') + activity = get_android_python_activity() + return WorkManager.getInstance(activity) + + +@skip_if_not_running_from_android_device +def work_info_observer(callback): + """ + Creates on Observer for a WorkInfo instance + + .. warning:: This function will only be ran if executed from android.""" + from jnius import PythonJavaClass, cast, java_method + + class WorkInfoObserver(PythonJavaClass): + __javainterfaces__ = ['androidx/lifecycle/Observer'] + __javacontext__ = 'app' + + def __init__(self, callback, **kwargs): + super().__init__(**kwargs) + self.callback = callback + + @java_method('(Ljava/lang/Object;)V') + def onChanged(self, obj): + workinfo = cast('androidx.work.WorkInfo', obj) + self.callback(workinfo) + + return WorkInfoObserver(callback)