From 9e1967676c841d86187924fb87e48e0fd7a11fed Mon Sep 17 00:00:00 2001 From: Kevin Ollivier Date: Fri, 17 Apr 2020 17:03:08 -0700 Subject: [PATCH 01/28] Make mWebView accessible from Python code --- .../build/src/main/java/org/kivy/android/PythonActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java index 2f0afdc6f4..46ad27369d 100644 --- a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java +++ b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java @@ -54,7 +54,7 @@ public class PythonActivity extends Activity { public static boolean mBrokenLibraries; protected static ViewGroup mLayout; - protected static WebView mWebView; + public static WebView mWebView; protected static Thread mPythonThread; From dcc4370f46159b062ca6f7b4cbb722265dfc3b3d Mon Sep 17 00:00:00 2001 From: Evstifeev Roman Date: Mon, 15 Jul 2019 11:05:45 +0300 Subject: [PATCH 02/28] Add optional android fileprovider. --- pythonforandroid/bootstraps/common/build/build.py | 5 +++++ .../common/build/src/main/res/xml/.gitkeep | 1 + .../common/build/templates/build.tmpl.gradle | 5 ++++- .../sdl2/build/templates/AndroidManifest.tmpl.xml | 12 ++++++++++++ pythonforandroid/toolchain.py | 2 +- 5 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 pythonforandroid/bootstraps/common/build/src/main/res/xml/.gitkeep diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index 3aaf51d009..ce3a3a276c 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -560,6 +560,9 @@ def make_package(args): remove('AndroidManifest.xml') shutil.copy(manifest_path, 'AndroidManifest.xml') + if args.fileprovider_paths: + shutil.copy(args.fileprovider_paths, join(res_dir, "xml/file_paths.xml")) + # gradle build templates render( 'build.tmpl.gradle', @@ -888,6 +891,8 @@ def create_argument_parser(): ap.add_argument('--depend', dest='depends', action='append', help=('Add a external dependency ' '(eg: com.android.support:appcompat-v7:19.0.1)')) + ap.add_argument('--fileprovider-paths', dest='fileprovider_paths', + help=('Add fileprovider paths xml file')) # The --sdk option has been removed, it is ignored in favour of # --android-api handled by toolchain.py ap.add_argument('--sdk', dest='sdk_version', default=-1, diff --git a/pythonforandroid/bootstraps/common/build/src/main/res/xml/.gitkeep b/pythonforandroid/bootstraps/common/build/src/main/res/xml/.gitkeep new file mode 100644 index 0000000000..8d1c8b69c3 --- /dev/null +++ b/pythonforandroid/bootstraps/common/build/src/main/res/xml/.gitkeep @@ -0,0 +1 @@ + diff --git a/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle b/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle index bb000393a4..3330e40891 100644 --- a/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle +++ b/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle @@ -37,6 +37,7 @@ android { versionCode {{ args.numeric_version }} versionName '{{ args.version }}' manifestPlaceholders = {{ args.manifest_placeholders}} + multiDexEnabled true } @@ -84,7 +85,7 @@ android { } compileOptions { - {% if args.enable_androidx %} + {% if args.enable_androidx %} sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 {% else %} @@ -130,5 +131,7 @@ dependencies { {% if args.presplash_lottie %} implementation 'com.airbnb.android:lottie:3.4.0' {%- endif %} + implementation 'com.android.support:support-v4:26.1.0' + implementation 'com.android.support:multidex:1.0.3' } diff --git a/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml b/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml index 3353c0a0d5..4483622492 100644 --- a/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml +++ b/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml @@ -139,6 +139,18 @@ {% for a in args.add_activity %} {% endfor %} + + {% if args.fileprovider_paths %} + + + + {% endif %} diff --git a/pythonforandroid/toolchain.py b/pythonforandroid/toolchain.py index 85404a2359..121f5b3c2f 100644 --- a/pythonforandroid/toolchain.py +++ b/pythonforandroid/toolchain.py @@ -994,7 +994,7 @@ def _fix_args(args): fix_args = ('--dir', '--private', '--add-jar', '--add-source', '--whitelist', '--blacklist', '--presplash', '--icon', - '--icon-bg', '--icon-fg') + '--icon-bg', '--icon-fg', '--fileprovider-paths') unknown_args = args.unknown_args for asset in args.assets: From 5fe5d193930c1fe013d9f3eac3f459d07eff65fa Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 12 Sep 2021 17:43:48 -0700 Subject: [PATCH 03/28] Remove use of webview loader to defer url loading to Python code. --- .../bootstraps/common/build/build.py | 5 -- .../java/org/kivy/android/PythonActivity.java | 10 ---- .../build/templates/WebViewLoader.tmpl.java | 56 ------------------- 3 files changed, 71 deletions(-) delete mode 100644 pythonforandroid/bootstraps/webview/build/templates/WebViewLoader.tmpl.java diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index ce3a3a276c..d3a1cda03e 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -616,11 +616,6 @@ def make_package(args): 'custom_rules.xml', args=args) - if get_bootstrap_name() == "webview": - render('WebViewLoader.tmpl.java', - 'src/main/java/org/kivy/android/WebViewLoader.java', - args=args) - if args.sign: render('build.properties', 'build.properties') else: diff --git a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java index 46ad27369d..448af01135 100644 --- a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java +++ b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java @@ -216,9 +216,6 @@ public void onPageFinished(WebView view, String url) { final Thread pythonThread = new Thread(new PythonMain(), "PythonThread"); PythonActivity.mPythonThread = pythonThread; pythonThread.start(); - - final Thread wvThread = new Thread(new WebViewLoaderMain(), "WvThread"); - wvThread.start(); } } @@ -563,10 +560,3 @@ public void run() { PythonActivity.nativeInit(new String[0]); } } - -class WebViewLoaderMain implements Runnable { - @Override - public void run() { - WebViewLoader.testConnection(); - } -} diff --git a/pythonforandroid/bootstraps/webview/build/templates/WebViewLoader.tmpl.java b/pythonforandroid/bootstraps/webview/build/templates/WebViewLoader.tmpl.java deleted file mode 100644 index 5482da8477..0000000000 --- a/pythonforandroid/bootstraps/webview/build/templates/WebViewLoader.tmpl.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.kivy.android; - -import android.util.Log; - -import java.io.IOException; -import java.net.Socket; -import java.net.InetSocketAddress; - -import android.os.SystemClock; - -import android.os.Handler; - -import org.kivy.android.PythonActivity; - -public class WebViewLoader { - private static final String TAG = "WebViewLoader"; - - public static void testConnection() { - - while (true) { - if (WebViewLoader.pingHost("localhost", {{ args.port }}, 100)) { - Log.v(TAG, "Successfully pinged localhost:{{ args.port }}"); - Handler mainHandler = new Handler(PythonActivity.mActivity.getMainLooper()); - Runnable myRunnable = new Runnable() { - @Override - public void run() { - PythonActivity.mActivity.loadUrl("http://127.0.0.1:{{ args.port }}/"); - Log.v(TAG, "Loaded webserver in webview"); - } - }; - mainHandler.post(myRunnable); - break; - - } else { - Log.v(TAG, "Could not ping localhost:{{ args.port }}"); - try { - Thread.sleep(100); - } catch(InterruptedException e) { - Log.v(TAG, "InterruptedException occurred when sleeping"); - } - } - } - } - - public static boolean pingHost(String host, int port, int timeout) { - Socket socket = new Socket(); - try { - socket.connect(new InetSocketAddress(host, port), timeout); - socket.close(); - return true; - } catch (IOException e) { - try {socket.close();} catch (IOException f) {return false;} - return false; // Either timeout or unreachable or failed DNS lookup. - } - } -} From 22caae9a163287cac4746bcf69adc622883b071d Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 12 Sep 2021 18:06:33 -0700 Subject: [PATCH 04/28] Set additional settings on the webview. --- .../build/src/main/java/org/kivy/android/PythonActivity.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java index 448af01135..3e935b849b 100644 --- a/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java +++ b/pythonforandroid/bootstraps/webview/build/src/main/java/org/kivy/android/PythonActivity.java @@ -161,6 +161,9 @@ public void onClick(DialogInterface dialog,int id) { mWebView.getSettings().setJavaScriptEnabled(true); mWebView.getSettings().setDomStorageEnabled(true); mWebView.loadUrl("file:///android_asset/_load.html"); + mWebView.getSettings().setAllowFileAccessFromFileURLs(true); + mWebView.getSettings().setAllowUniversalAccessFromFileURLs(true); + mWebView.getSettings().setMediaPlaybackRequiresUserGesture(false); mWebView.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); mWebView.setWebViewClient(new WebViewClient() { From 0262feb0ca8338ceb1ae802723d96a13a7261d93 Mon Sep 17 00:00:00 2001 From: Philipp Auersperg Date: Wed, 18 May 2022 10:49:55 -0600 Subject: [PATCH 05/28] Return main status through JNI entry points This is currently unused, but without passing back the status the Java classes have no way of knowing whether the native code succeeded or not. --- .../bootstraps/common/build/jni/application/src/start.c | 8 ++++---- .../src/main/java/org/kivy/android/PythonService.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c index ce93ca27fd..99ddb036e3 100644 --- a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c +++ b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c @@ -350,7 +350,7 @@ int main(int argc, char *argv[]) { return ret; } -JNIEXPORT void JNICALL Java_org_kivy_android_PythonService_nativeStart( +JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( JNIEnv *env, jobject thiz, jstring j_android_private, @@ -390,7 +390,7 @@ JNIEXPORT void JNICALL Java_org_kivy_android_PythonService_nativeStart( /* ANDROID_ARGUMENT points to service subdir, * so main() will run main.py from this dir */ - main(1, argv); + return main(1, argv); } #if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY) @@ -413,7 +413,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 */ @@ -429,7 +429,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 main(1, argv); /* 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 76d3b2e77b..34df9634e6 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 @@ -202,7 +202,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, From fd2cce29ef7cf9a696f6b11797eae8c04ada8c35 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Wed, 18 May 2022 11:22:12 -0600 Subject: [PATCH 06/28] Rename main function to run_python This code is compiled into a shared library and not an executable where the C `main` entry point is used. The only entry points actually used are the symbols called by the JNI. Rename this "main" function to `run_python` to avoid confusion. This also allows changing the interface without breaking the standard C `main` interface. This could be used to pass in the Python entry point without using environment variables, for example. The exception is in the sdl2 bootstrap where `PythonActivity` extends `SDLActivity`. `SDLActivity` calls `SDL_main` as the native entry point. This was working before because the SDL headers define `main` as `SDL_main`. Instead of relying on that, just implement `SDL_main` as a wrapper around `run_python` when needed. --- .../common/build/jni/application/src/start.c | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c index 99ddb036e3..2646d24140 100644 --- a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c +++ b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c @@ -52,7 +52,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 +61,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 +70,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[]) { char *env_argument = NULL; char *env_entrypoint = NULL; @@ -350,6 +349,13 @@ int main(int argc, char *argv[]) { return ret; } +#ifdef BOOTSTRAP_NAME_SDL2 +int SDL_main(int argc, char *argv[]) { + LOGP("Entering SDL_main"); + return run_python(argc, argv); +} +#endif + JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( JNIEnv *env, jobject thiz, @@ -388,9 +394,9 @@ JNIEXPORT int 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 */ - return main(1, argv); + return run_python(1, argv); } #if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY) @@ -429,7 +435,7 @@ int Java_org_kivy_android_PythonActivity_nativeInit(JNIEnv* env, jclass cls, job argv[1] = NULL; /* status = SDL_main(1, argv); */ - return main(1, argv); + return run_python(1, argv); /* Do not issue an exit or the whole application will terminate instead of just the SDL thread */ /* exit(status); */ From 80db4a0c584542dfe9218a00b4249d0bfbe96aec Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Wed, 18 May 2022 10:36:48 -0600 Subject: [PATCH 07/28] Factor out JNI service entry point This will be reused for WorkManager task entry points in a later commit. --- .../common/build/jni/application/src/start.c | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c index 2646d24140..2b58fc404f 100644 --- a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c +++ b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c @@ -356,7 +356,7 @@ int SDL_main(int argc, char *argv[]) { } #endif -JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( +static int native_service_start( JNIEnv *env, jobject thiz, jstring j_android_private, @@ -399,6 +399,28 @@ JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( return run_python(1, argv); } +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); +} + #if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY) // Webview and service_only uses some more functions: From 04f91a583f467a38c726e6f06fb3869bd9ce4278 Mon Sep 17 00:00:00 2001 From: Robert Niederreiter Date: Wed, 18 May 2022 13:52:00 -0600 Subject: [PATCH 08/28] Allow skipping sys.exit() Adding python code to call `sys.exit` was added to workaround issues with restarting apps. However, that actually exits the entire application. In some scenarios that might be wrong, so provide a conditional to skip calling `sys.exit`. --- .../common/build/jni/application/src/start.c | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c index 2b58fc404f..616aa4365e 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 @@ -70,7 +71,7 @@ static int file_exists(const char *filename) { return 0; } -static int run_python(int argc, char *argv[]) { +static int run_python(int argc, char *argv[], bool call_exit) { char *env_argument = NULL; char *env_entrypoint = NULL; @@ -326,24 +327,24 @@ static int run_python(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; @@ -352,7 +353,7 @@ static int run_python(int argc, char *argv[]) { #ifdef BOOTSTRAP_NAME_SDL2 int SDL_main(int argc, char *argv[]) { LOGP("Entering SDL_main"); - return run_python(argc, argv); + return run_python(argc, argv, true); } #endif @@ -365,7 +366,8 @@ static int native_service_start( 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); @@ -396,7 +398,7 @@ static int native_service_start( /* ANDROID_ARGUMENT points to service subdir, * so run_python() will run main.py from this dir */ - return run_python(1, argv); + return run_python(1, argv, call_exit); } JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( @@ -418,7 +420,8 @@ JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( j_python_name, j_python_home, j_python_path, - j_arg); + j_arg, + true); } #if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY) @@ -457,7 +460,7 @@ int Java_org_kivy_android_PythonActivity_nativeInit(JNIEnv* env, jclass cls, job argv[1] = NULL; /* status = SDL_main(1, argv); */ - return run_python(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); */ From a89c7c8b420c29c25f240d5c2b59705029641d25 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Tue, 2 Aug 2022 16:27:37 -0600 Subject: [PATCH 09/28] Fix flake8 warnings in on device unit tests These are cosmetic but they make it easier to use flake8 to find issues when hacking on the app. --- testapps/on_device_unit_tests/setup.py | 8 ++-- .../test_app/tests/test_requirements.py | 38 +++++++++---------- .../on_device_unit_tests/test_app/tools.py | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/testapps/on_device_unit_tests/setup.py b/testapps/on_device_unit_tests/setup.py index 2efeff0cc0..7a4ac119e6 100644 --- a/testapps/on_device_unit_tests/setup.py +++ b/testapps/on_device_unit_tests/setup.py @@ -46,7 +46,7 @@ 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'armeabi-v7a', - 'bootstrap' : 'sdl2', + 'bootstrap': 'sdl2', 'permissions': ['INTERNET', 'VIBRATE'], 'orientation': ['portrait', 'landscape'], 'service': 'P4a_test_service:app_service.py', @@ -60,19 +60,19 @@ 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'armeabi-v7a', - 'bootstrap' : 'sdl2', + 'bootstrap': 'sdl2', 'permissions': ['INTERNET', 'VIBRATE'], 'orientation': ['portrait', 'landscape'], 'service': 'P4a_test_service:app_service.py', }, 'aar': { - 'requirements' : 'python3', + 'requirements': 'python3', 'android-api': 27, 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'arm64-v8a', - 'bootstrap' : 'service_library', + 'bootstrap': 'service_library', 'permissions': ['INTERNET', 'VIBRATE'], 'service': 'P4a_test_service:app_service.py', } 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 398919d243..1878a28298 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() From eff5ac634b72fce43fa3285bb708410364577d3a Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Tue, 2 Aug 2022 10:10:45 -0600 Subject: [PATCH 10/28] Handle default on-device test app options correctly Now that the on-device test app is configured to build an AAB and AAR in addition to an APK, looking at the default options for the for the APK might be wrong. Try to figure out the target before looking at the options. --- testapps/on_device_unit_tests/setup.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/testapps/on_device_unit_tests/setup.py b/testapps/on_device_unit_tests/setup.py index 7a4ac119e6..78698a6bb8 100644 --- a/testapps/on_device_unit_tests/setup.py +++ b/testapps/on_device_unit_tests/setup.py @@ -78,8 +78,16 @@ } } +# Try to figure out the build target so we know the default options that +# will be used. +target = 'apk' +for arg in sys.argv: + if arg in options: + target = arg + break + # check if we overwrote the default test_app requirements via `cli` -requirements = options['apk']['requirements'].rsplit(',') +requirements = options[target]['requirements'].rsplit(',') for n, arg in enumerate(sys.argv): if arg == '--requirements': print('found requirements') @@ -89,7 +97,7 @@ # remove `orientation` in case that we don't detect a kivy or flask app, # since the `service_only` bootstrap does not support such argument if not ({'kivy', 'flask'} & set(requirements)): - options['apk'].pop('orientation') + options[target].pop('orientation', None) # write a file to let the test_app know which requirements we want to test # Note: later, when running the app, we will guess if we have the right test. From c67679fe1c955ad3a080b5f8f22b2c71866cdb57 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Tue, 31 May 2022 15:16:33 -0600 Subject: [PATCH 11/28] Factor out Flask app class on on-device test app This should make it easier to track state than using globals. --- .../test_app/app_flask.py | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/testapps/on_device_unit_tests/test_app/app_flask.py b/testapps/on_device_unit_tests/test_app/app_flask.py index 27ae7d4ad4..326bd1224b 100644 --- a/testapps/on_device_unit_tests/test_app/app_flask.py +++ b/testapps/on_device_unit_tests/test_app/app_flask.py @@ -11,6 +11,7 @@ from flask import ( Flask, + current_app, render_template, request, Markup @@ -26,12 +27,37 @@ get_android_python_activity, set_device_orientation, setup_lifecycle_callbacks, + skip_if_not_running_from_android_device, ) -app = Flask(__name__) -setup_lifecycle_callbacks() -service_running = False +class App(Flask): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + setup_lifecycle_callbacks() + self.service_running = False + + @property + @skip_if_not_running_from_android_device + def service(self): + from jnius import autoclass + + return autoclass('org.test.unit_tests_app.ServiceP4a_test_service') + + @skip_if_not_running_from_android_device + def service_start(self): + activity = get_android_python_activity() + self.service.start(activity, 'Some argument') + self.service_running = True + + @skip_if_not_running_from_android_device + def service_stop(self): + activity = get_android_python_activity() + self.service.stop(activity) + self.service_running = False + + +app = App(__name__) TESTS_TO_PERFORM = dict() NON_ANDROID_DEVICE_MSG = 'Not running from Android device' @@ -53,34 +79,12 @@ def get_html_for_tested_modules(tested_modules, failed_tests): return Markup(modules_text) -def get_test_service(): - from jnius import autoclass - - return autoclass('org.test.unit_tests_app.ServiceP4a_test_service') - - -def start_service(): - global service_running - activity = get_android_python_activity() - test_service = get_test_service() - test_service.start(activity, 'Some argument') - service_running = True - - -def stop_service(): - global service_running - activity = get_android_python_activity() - test_service = get_test_service() - test_service.stop(activity) - service_running = False - - @app.route('/') def index(): return render_template( 'index.html', platform='Android' if RUNNING_ON_ANDROID else 'Desktop', - service_running=service_running, + service_running=current_app.service_running, ) @@ -179,7 +183,7 @@ def service(): action = args['action'] if action == 'start': - start_service() + current_app.service_start() else: - stop_service() + current_app.service_stop() return ('', 204) From c4e39a2eeac2b1c4024d990949bb3d705667c890 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Tue, 31 May 2022 16:07:19 -0600 Subject: [PATCH 12/28] Add periodic status refresh for webview test app --- .../test_app/app_flask.py | 11 ++++++++++ .../test_app/templates/index.html | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/testapps/on_device_unit_tests/test_app/app_flask.py b/testapps/on_device_unit_tests/test_app/app_flask.py index 326bd1224b..acbff92bba 100644 --- a/testapps/on_device_unit_tests/test_app/app_flask.py +++ b/testapps/on_device_unit_tests/test_app/app_flask.py @@ -12,6 +12,7 @@ from flask import ( Flask, current_app, + jsonify, render_template, request, Markup @@ -37,6 +38,11 @@ def __init__(self, *args, **kwargs): setup_lifecycle_callbacks() self.service_running = False + def get_status(self): + return jsonify({ + 'service_running': self.service_running, + }) + @property @skip_if_not_running_from_android_device def service(self): @@ -88,6 +94,11 @@ def index(): ) +@app.route('/status') +def status(): + return current_app.get_status() + + @app.route('/unittests') def unittests(): import unittest 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..92a08c4f9e 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,27 @@

Android tests

+ +

From 4105cc320fd4293705a70c0490453109f74ea207 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Fri, 20 May 2022 16:40:20 -0600 Subject: [PATCH 13/28] Support WorkManager tasks The androidx WorkManager component allows simple handling of tasks in Android apps. Furthermore, in Android 12, WorkManager tasks are the preferred way to run foreground services when the app is in the background. Since the python interpreter integration only allows running one python interpreter per process, this uses a `RemoteListenableWorker` to run the tasks on a service in a separate process. Unfortunately, that's only available since the work-multiprocess 2.6.0 release, which requires SDK 30. Co-authored-by: Robert Niederreiter Co-authored-by: Philipp Auersperg --- .../bootstraps/common/build/build.py | 52 +++++++ .../common/build/jni/application/src/start.c | 23 ++++ .../java/org/kivy/android/PythonWorker.java | 130 ++++++++++++++++++ .../common/build/templates/Worker.tmpl.java | 45 ++++++ .../build/templates/WorkerService.tmpl.java | 41 ++++++ .../common/build/templates/build.tmpl.gradle | 11 +- .../build/templates/AndroidManifest.tmpl.xml | 6 + .../build/templates/AndroidManifest.tmpl.xml | 6 + .../build/templates/AndroidManifest.tmpl.xml | 6 + .../build/templates/AndroidManifest.tmpl.xml | 6 + 10 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 pythonforandroid/bootstraps/common/build/src/main/java/org/kivy/android/PythonWorker.java create mode 100644 pythonforandroid/bootstraps/common/build/templates/Worker.tmpl.java create mode 100644 pythonforandroid/bootstraps/common/build/templates/WorkerService.tmpl.java diff --git a/pythonforandroid/bootstraps/common/build/build.py b/pythonforandroid/bootstraps/common/build/build.py index d3a1cda03e..f1faa9ea08 100644 --- a/pythonforandroid/bootstraps/common/build/build.py +++ b/pythonforandroid/bootstraps/common/build/build.py @@ -494,6 +494,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() @@ -512,6 +545,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] @@ -543,6 +585,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 @@ -574,6 +617,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 @@ -801,6 +845,9 @@ def create_argument_parser(): 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') @@ -1052,6 +1099,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 616aa4365e..39b72b01d2 100644 --- a/pythonforandroid/bootstraps/common/build/jni/application/src/start.c +++ b/pythonforandroid/bootstraps/common/build/jni/application/src/start.c @@ -424,6 +424,29 @@ JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart( 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) // Webview and service_only uses some more functions: 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 3330e40891..0591d77481 100644 --- a/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle +++ b/pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle @@ -101,6 +101,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}}' @@ -128,10 +131,16 @@ dependencies { implementation '{{ depend }}' {%- endfor %} {%- endif %} - {% if args.presplash_lottie %} + {%- if args.presplash_lottie %} implementation 'com.airbnb.android:lottie:3.4.0' {%- endif %} implementation 'com.android.support:support-v4:26.1.0' implementation 'com.android.support:multidex:1.0.3' + {%- 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 4483622492..93c41d07df 100644 --- a/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml +++ b/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml @@ -122,6 +122,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 ab410330f2..de9944f22f 100644 --- a/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml +++ b/pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml @@ -78,6 +78,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 %} Date: Wed, 25 May 2022 11:23:00 -0600 Subject: [PATCH 14/28] Include worker test in on-device test app Exercise the `--worker` option in the on-device test app. This requires raising the SDK version to 30. --- ci/makefiles/android.mk | 2 +- testapps/on_device_unit_tests/setup.py | 9 +- .../test_app/app_flask.py | 76 ++++++++++++++++ .../on_device_unit_tests/test_app/app_kivy.py | 91 +++++++++++++++++++ .../test_app/app_worker.py | 43 +++++++++ .../test_app/screen_unittests.kv | 4 + .../test_app/screen_worker.kv | 78 ++++++++++++++++ .../test_app/templates/index.html | 50 +++++++++- .../on_device_unit_tests/test_app/tools.py | 37 ++++++++ 9 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 testapps/on_device_unit_tests/test_app/app_worker.py create mode 100644 testapps/on_device_unit_tests/test_app/screen_worker.kv diff --git a/ci/makefiles/android.mk b/ci/makefiles/android.mk index 2041a6ce76..d33fdc47dc 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/testapps/on_device_unit_tests/setup.py b/testapps/on_device_unit_tests/setup.py index 78698a6bb8..ced413ed90 100644 --- a/testapps/on_device_unit_tests/setup.py +++ b/testapps/on_device_unit_tests/setup.py @@ -42,7 +42,7 @@ 'requirements': 'sqlite3,libffi,openssl,pyjnius,kivy,python3,requests,urllib3,' 'chardet,idna', - 'android-api': 27, + 'android-api': 30, 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'armeabi-v7a', @@ -50,13 +50,14 @@ 'permissions': ['INTERNET', 'VIBRATE'], 'orientation': ['portrait', 'landscape'], 'service': 'P4a_test_service:app_service.py', + 'worker': 'P4a_test_worker:app_worker.py', }, 'aab': { 'requirements': 'sqlite3,libffi,openssl,pyjnius,kivy,python3,requests,urllib3,' 'chardet,idna', - 'android-api': 27, + 'android-api': 30, 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'armeabi-v7a', @@ -64,17 +65,19 @@ 'permissions': ['INTERNET', 'VIBRATE'], 'orientation': ['portrait', 'landscape'], 'service': 'P4a_test_service:app_service.py', + 'worker': 'P4a_test_worker:app_worker.py', }, 'aar': { 'requirements': 'python3', - 'android-api': 27, + 'android-api': 30, 'ndk-api': 21, 'dist-name': 'bdist_unit_tests_app', 'arch': 'arm64-v8a', 'bootstrap': 'service_library', 'permissions': ['INTERNET', 'VIBRATE'], 'service': 'P4a_test_service:app_service.py', + 'worker': 'P4a_test_worker:app_worker.py', } } diff --git a/testapps/on_device_unit_tests/test_app/app_flask.py b/testapps/on_device_unit_tests/test_app/app_flask.py index acbff92bba..0a9c0f6397 100644 --- a/testapps/on_device_unit_tests/test_app/app_flask.py +++ b/testapps/on_device_unit_tests/test_app/app_flask.py @@ -26,6 +26,7 @@ get_failed_unittests_from, vibrate_with_pyjnius, get_android_python_activity, + get_work_manager, set_device_orientation, setup_lifecycle_callbacks, skip_if_not_running_from_android_device, @@ -37,10 +38,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) setup_lifecycle_callbacks() self.service_running = False + self.work_request = None def get_status(self): return jsonify({ 'service_running': self.service_running, + 'worker_running': self.worker_running, }) @property @@ -62,6 +65,58 @@ def service_stop(self): self.service.stop(activity) self.service_running = False + @property + @skip_if_not_running_from_android_device + def P4a_test_workerWorker(self): + from jnius import autoclass + + return autoclass('org.test.unit_tests_app.P4a_test_workerWorker') + + @skip_if_not_running_from_android_device + def worker_start(self): + from jnius import autoclass + + if self.worker_running: + return + + OneTimeWorkRequestBuilder = autoclass('androidx.work.OneTimeWorkRequest$Builder') + data = self.P4a_test_workerWorker.buildInputData('10') + self.work_request = ( + OneTimeWorkRequestBuilder(self.P4a_test_workerWorker._class) + .setInputData(data) + .build() + ) + work_manager = get_work_manager() + op = work_manager.enqueue(self.work_request) + op.getResult().get() + + @skip_if_not_running_from_android_device + def worker_stop(self): + if self.worker_running: + work_manager = get_work_manager() + op = work_manager.cancelWorkById(self.work_request.getId()) + op.getResult().get() + + @property + @skip_if_not_running_from_android_device + def work_info(self): + if self.work_request is None: + return None + + work_manager = get_work_manager() + return work_manager.getWorkInfoById(self.work_request.getId()).get() + + @property + @skip_if_not_running_from_android_device + def worker_running(self): + info = self.work_info + if info is None: + print('Work request not started') + return False + state = info.getState() + print('Work request state:', state.toString()) + return not state.isFinished() + app = App(__name__) TESTS_TO_PERFORM = dict() @@ -91,6 +146,7 @@ def index(): 'index.html', platform='Android' if RUNNING_ON_ANDROID else 'Desktop', service_running=current_app.service_running, + worker_running=current_app.worker_running, ) @@ -198,3 +254,23 @@ def service(): else: current_app.service_stop() return ('', 204) + + +@app.route('/worker') +def worker(): + if not RUNNING_ON_ANDROID: + print(NON_ANDROID_DEVICE_MSG, '...cancelled worker.') + return (NON_ANDROID_DEVICE_MSG, 400) + args = request.args + if 'action' not in args: + print('ERROR: asked to manage worker but no action specified') + return ('No action specified', 400) + + action = args['action'] + if action == 'start': + current_app.worker_start() + elif action == 'stop': + current_app.worker_stop() + else: + return ('Invalid action "{}"'.format(action), 400) + return ('', 204) diff --git a/testapps/on_device_unit_tests/test_app/app_kivy.py b/testapps/on_device_unit_tests/test_app/app_kivy.py index 94ae5fe511..5b56ce797b 100644 --- a/testapps/on_device_unit_tests/test_app/app_kivy.py +++ b/testapps/on_device_unit_tests/test_app/app_kivy.py @@ -2,12 +2,15 @@ import subprocess +from android.runnable import run_on_ui_thread from os.path import split from kivy.app import App from kivy.clock import Clock +from kivy.event import EventDispatcher from kivy.properties import ( BooleanProperty, + BoundedNumericProperty, DictProperty, ListProperty, StringProperty, @@ -19,11 +22,13 @@ get_android_python_activity, get_failed_unittests_from, get_images_with_extension, + get_work_manager, load_kv_from, raise_error, run_test_suites_into_buffer, setup_lifecycle_callbacks, vibrate_with_pyjnius, + work_info_observer, ) from widgets import TestImage @@ -34,11 +39,79 @@ ScreenKeyboard: ScreenOrientation: ScreenService: + ScreenWorker: ''' load_kv_from('screen_unittests.kv') load_kv_from('screen_keyboard.kv') load_kv_from('screen_orientation.kv') load_kv_from('screen_service.kv') +load_kv_from('screen_worker.kv') + + +class Work(EventDispatcher): + '''Event dispatcher for WorkRequests''' + + progress = BoundedNumericProperty(0, min=0, max=100) + state = StringProperty() + running = BooleanProperty(False) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._request = None + self._observer = work_info_observer(self.update) + self._live_data = None + + def update(self, work_info): + progress_data = work_info.getProgress() + self.progress = progress_data.getInt('PROGRESS', 0) + + state = work_info.getState() + self.state = state.name() + if state.isFinished(): + self.running = False + self._live_data.removeObserver(self._observer) + + def enqueue(self, data): + from jnius import autoclass + + OneTimeWorkRequestBuilder = autoclass('androidx.work.OneTimeWorkRequest$Builder') + Worker = autoclass('org.test.unit_tests_app.P4a_test_workerWorker') + + input_data = Worker.buildInputData(data) + self._request = ( + OneTimeWorkRequestBuilder(Worker._class) + .setInputData(input_data) + .build() + ) + + work_manager = get_work_manager() + op = work_manager.enqueue(self._request) + op.getResult().get() + + self.running = True + request_id = self._request.getId() + self._live_data = work_manager.getWorkInfoByIdLiveData(request_id) + self._add_observer() + + def cancel(self): + if not self.running: + return + + work_manager = get_work_manager() + request_id = self._request.getId() + op = work_manager.cancelWorkById(request_id) + op.getResult().get() + + @run_on_ui_thread + def _add_observer(self): + self._live_data.observeForever(self._observer) + + def on_progress(self, instance, progress): + print('work progress: {}%'.format(progress)) + + def on_state(self, instance, state): + print('work state:', state) class TestKivyApp(App): @@ -51,6 +124,7 @@ class TestKivyApp(App): def build(self): self.reset_unittests_results() + self.work = Work() self.sm = Builder.load_string(screen_manager_app) return self.sm @@ -165,3 +239,20 @@ def stop_service(self): service = self.service_time activity = get_android_python_activity() service.stop(activity) + + def worker_button_pressed(self, *args): + if RUNNING_ON_ANDROID: + if not self.work.running: + print('Starting worker') + self.start_worker() + else: + print('Stopping worker') + self.stop_worker() + else: + raise_error('Worker test not supported on desktop') + + def start_worker(self): + self.work.enqueue('10') + + def stop_worker(self): + self.work.cancel() diff --git a/testapps/on_device_unit_tests/test_app/app_worker.py b/testapps/on_device_unit_tests/test_app/app_worker.py new file mode 100644 index 0000000000..5073595238 --- /dev/null +++ b/testapps/on_device_unit_tests/test_app/app_worker.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import time + +from jnius import autoclass +from os import environ + +DataBuilder = autoclass('androidx.work.Data$Builder') +P4a_test_workerWorker = autoclass('org.test.unit_tests_app.P4a_test_workerWorker') + + +def set_progress(progress): + progress_data = DataBuilder().putInt('PROGRESS', progress).build() + P4a_test_workerWorker.mWorker.setProgressAsync(progress_data) + + +argument = environ.get('PYTHON_SERVICE_ARGUMENT', '') +print( + 'app_worker.py was successfully called with argument: "{}"'.format( + argument, + ), +) + +try: + duration = int(argument) +except ValueError: + duration = 60 + +print('Running the test worker for {} seconds'.format(duration)) + +remaining = duration +while remaining > 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 92a08c4f9e..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,18 +134,66 @@

Android tests

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