diff --git a/README.rst b/README.rst index aaab68d..8a1b42b 100644 --- a/README.rst +++ b/README.rst @@ -29,21 +29,16 @@ However, if you *do* want use this template directly... your app). The remainder of these instructions will assume a `name` of ``my-project``, and a formal name of ``My Project``. -3. `Obtain a Python support package for Android`_, and extract it into - the ``My Project`` directory generated by the template. This will create - ``app/libs`` and ``app/src/main/assets`` folders containing a self contained - Python install. - -4. Add your code to the template, into the - ``My Project/app/src/main/assets/python/app`` directory. At the very minimum, - you need to have an - ``My Project/app/src/main/assets/python/app//__main__.py`` file - that instantiates an instance of ``org.beeware.android.IPythonApp``, and - then invokes ``org.beeware.android.MainActivity.setPythonApp()``, providing - the ``IPythonApp`` instance. - - If your code has any dependencies, they should be installed into the - ``My Project/app/src/main/assets/python/app_packages`` directory. +3. Add your code to the template, into the ``My Project/app/src/main/python`` + directory. At the very minimum, you need to have an ``/__main__.py`` file that invokes + ``org.beeware.android.MainActivity.setPythonApp()``, providing an + ``IPythonApp`` instance. This provides the hooks into the Android application + lifecycle (``onCreate``, ``onResume`` and so on); it's up to you what your + code does with those lifecycle hooks. + + If your code has any dependencies, they should be listed in the file + ``My Project/app/requirements.txt``. If you've done this correctly, a project with a formal name of ``My Project``, with an app name of ``my-project`` should have a directory structure that @@ -53,14 +48,10 @@ looks something like:: app/ src/ main/ - assets/ - python/ - app/ - my_project/ - __init__.py - __main__.py (declares IPythonApp) - app_packages/ - ... + python/ + my_project/ + __init__.py + __main__.py (declares IPythonApp) cpp/ ... java/ @@ -70,6 +61,7 @@ looks something like:: AndroidManifest.xml build.gradle proguard-rules.pro + requirements.txt briefcase.toml build.gradle gradle.properties @@ -77,41 +69,27 @@ looks something like:: gradlew.bat settings.gradle -You're now ready to run build and run your project! Set - - $ ./gradlew installDebug +You're now ready to build and run your project! Either open the ``My Project`` +directory in Android Studio, or `use the command line tools +`__. Next steps ---------- Of course, running Python code isn't very interesting by itself - you'll be -able to output to the console, and see that output in Gradle, but if you tap the +able to output to the console, and see that output in the Logcat, but if you tap the app icon on your phone, you won't see anything - because there isn't a visible console on an Android. To do something interesting, you'll need to work with the native Android system -libraries to draw widgets and respond to screen taps. The `Rubicon`_ Java +libraries to draw widgets and respond to screen taps. The `Chaquopy`_ Java bridging library can be used to interface with the Android system libraries. + Alternatively, you could use a cross-platform widget toolkit that supports -Android (such as `Toga`_) to provide a GUI for your application. - -Regardless of whether you use Toga, or you write an application natively, the -template project will run the `__main__` module associated with the app name -that you provided when you generated the tempalte. That Python code must -define an instance of ``org.beeware.android.IPythonApp``, and invoke -``org.beeware.android.MainActivity.setPythonApp()`` to set that instance as the -active Python app. This app will coordinate provides the hooks into the -Android application lifecycle (``onCreate``, ``onResume`` and so on); it's -up to you what your code does with those lifecycle hooks. If ``setPythonApp`` -is not set, an error will be logged, and the Python interpreter will be shut -down. - -If you have any external library dependencies (like Toga, or anything other -third-party library), you should install the library code into the -``app_packages`` directory. This directory is the same as a ``site_packages`` -directory on a desktop Python install. +Android (such as `Toga`_) to provide a GUI for your application. Toga +automatically handles creating the ``IPythonApp`` instance and responding to the +app's lifecycle hooks. .. _cookiecutter: https://github.com/cookiecutter/cookiecutter -.. _Obtain a Python support package for Android: https://github.com/beeware/Python-Android-support -.. _Rubicon: https://github.com/beeware/rubicon-java +.. _Chaquopy: https://chaquo.com/chaquopy/ .. _Toga: https://beeware.org/project/projects/libraries/toga diff --git a/{{ cookiecutter.safe_formal_name }}/app/build.gradle b/{{ cookiecutter.safe_formal_name }}/app/build.gradle index cbe1064..e44a343 100644 --- a/{{ cookiecutter.safe_formal_name }}/app/build.gradle +++ b/{{ cookiecutter.safe_formal_name }}/app/build.gradle @@ -1,24 +1,39 @@ apply plugin: 'com.android.application' +apply plugin: 'com.chaquo.python' android { compileSdkVersion 32 defaultConfig { applicationId "{{ cookiecutter.package_name }}.{{ cookiecutter.module_name }}" - - // JNI crashes may happen on older versions: - // https://github.com/beeware/rubicon-java/issues/74 - minSdkVersion 26 - - targetSdkVersion 32 versionCode {{ cookiecutter.version_code }} versionName "{{ cookiecutter.version }}" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // Briefcase currently requires API level 24 for the `pidof` command, and the `--pid` + // argument to `adb logcat`. This supports over 90% of active devices + // (https://github.com/beeware/rubicon-java/issues/74). + minSdkVersion 24 + targetSdkVersion 33 + + python { + version "{{ cookiecutter.python_version|py_tag }}" + pip { + install "-r", "requirements.txt" + } + } externalNativeBuild { cmake { cppFlags "-std=c++14" } } + ndk { + // Chaquopy also supports x86, but it's not very useful anymore, so we'll + // disable it to speed up the build. For armeabi-v7a, see + // https://github.com/chaquo/chaquopy/issues/709. + abiFilters "arm64-v8a", "armeabi-v7a", "x86_64" + } } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 @@ -36,19 +51,10 @@ android { } sourceSets { main { - jniLibs.srcDirs = ['libs'] - } - } - aaptOptions { - // Use default ignore rule *except* allow directories starting with _, - // so that pyc files in __pycache__ directories flow through into apps. - // https://android.googlesource.com/platform/frameworks/base/+/b41af58f49d371cedf041443d20a1893f7f6c840/tools/aapt/AaptAssets.cpp#60 - ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' - } - packagingOptions { - jniLibs { - // MainActivity requires libpython to be extracted as a separate file. - useLegacyPackaging true + python.srcDirs = [ + "src/main/python", // App code + "src/main/python-briefcase", // Template code + ] } } } @@ -60,9 +66,5 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - implementation files('libs/rubicon.jar') implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" } -repositories { - mavenCentral() -} diff --git a/{{ cookiecutter.safe_formal_name }}/app/requirements.txt b/{{ cookiecutter.safe_formal_name }}/app/requirements.txt new file mode 100644 index 0000000..93d52c4 --- /dev/null +++ b/{{ cookiecutter.safe_formal_name }}/app/requirements.txt @@ -0,0 +1,2 @@ +# If your app needs any 3rd party packages, list them here as shown at +# https://pip.pypa.io/en/stable/reference/requirements-file-format/ diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/assets/python/app_packages/README b/{{ cookiecutter.safe_formal_name }}/app/src/main/assets/python/app_packages/README deleted file mode 100644 index d8d9515..0000000 --- a/{{ cookiecutter.safe_formal_name }}/app/src/main/assets/python/app_packages/README +++ /dev/null @@ -1 +0,0 @@ -This directory exists so that 3rd party packages can be installed here. diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/Helpers.java b/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/Helpers.java deleted file mode 100644 index 8f87663..0000000 --- a/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/Helpers.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.beeware.android; - -import android.content.res.AssetManager; -import android.util.Log; -import java.io.*; -import java.nio.file.Files; -import java.nio.file.StandardCopyOption; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -public class Helpers { - public static final String TAG = "Briefcase"; - - public static void unpackAssetPrefix(AssetManager assets, String assetPrefix, File outputDir) throws IOException { - Log.d(TAG, "Clearing out old assets"); - deleteRecursively(outputDir); - - String [] list = assets.list(assetPrefix); - if (list == null) { - throw new IOException("Unable to obtain asset list"); - } - if (list.length == 0) { - throw new IOException("No assets at prefix " + assetPrefix); - } - for (String file: list) { - unpackAssetPath(assets, assetPrefix + "/" + file.toString(), assetPrefix.length(), outputDir); - } - } - - public static void ensureDirExists(File dir) throws IOException { - if (!dir.exists()) { - Log.d(TAG, "Creating dir " + dir.getAbsolutePath()); - if (!dir.mkdirs()) { - throw new IOException("Failed to mkdir " + dir.getAbsolutePath()); - } - } - } - - private static void copyAsset(InputStream source, File targetPath) throws IOException { - Log.d(TAG, "Copying asset " + targetPath.getAbsolutePath()); - byte[] buffer = new byte[4096 * 64]; - int len = source.read(buffer); - FileOutputStream target = new FileOutputStream(targetPath); - while (len != -1) { - target.write(buffer, 0, len); - len = source.read(buffer); - } - target.close(); - } - - private static void unpackAssetPath(AssetManager assets, String assetPath, int assetPrefixLength, File outputDir) throws IOException { - String [] subPaths = assets.list(assetPath); - if (subPaths == null) { - throw new IOException("Unable to list assets at path " + assetPath); - } - if (subPaths.length == 0) { - // It's a file. Copy it. - File outputFile = new File(outputDir.getAbsolutePath() + "/" + assetPath.substring(assetPrefixLength)); - ensureDirExists(outputFile.getParentFile()); - copyAsset(assets.open(assetPath), outputFile); - } else { - for (String subPath: subPaths) { - unpackAssetPath(assets, assetPath + "/" + subPath, assetPrefixLength, outputDir); - } - } - } - - private static boolean deleteRecursively(File dir) { - Log.d(TAG, "Deleting " + dir.getAbsolutePath()); - File[] allContents = dir.listFiles(); - if (allContents != null) { - for (File file : allContents) { - deleteRecursively(file); - } - } - return dir.delete(); - } - - public static void unzipTo(ZipInputStream inputStream, File outputDir) throws IOException { - if (outputDir.exists()) { - Log.d(TAG, "Clearing out old zip artefacts"); - deleteRecursively(outputDir); - } - ensureDirExists(outputDir); - ZipEntry zipEntry = inputStream.getNextEntry(); - byte[] buf = new byte[1024 * 1024 * 4]; - while (zipEntry != null) { - File outputFile = new File(outputDir.getAbsolutePath() + "/" + zipEntry); - if (zipEntry.isDirectory()) { - Log.d(TAG, "Unpacking dir " + outputFile.getAbsolutePath()); - if (!outputFile.mkdirs()) { - throw new IOException("Unable to mkdirs " + outputFile.getAbsolutePath()); - } - } else { - Log.d(TAG, "Unpacking file " + outputFile.getAbsolutePath()); - FileOutputStream fos = new FileOutputStream(outputFile.getAbsolutePath()); - int len = inputStream.read(buf); - while (len > 0) { - fos.write(buf, 0, len); - len = inputStream.read(buf); - } - fos.close(); - } - zipEntry = inputStream.getNextEntry(); - } - inputStream.closeEntry(); - inputStream.close(); - } -} diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/MainActivity.java b/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/MainActivity.java index 31d97a3..6d3f88d 100644 --- a/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/MainActivity.java +++ b/{{ cookiecutter.safe_formal_name }}/app/src/main/java/org/beeware/android/MainActivity.java @@ -1,14 +1,8 @@ package org.beeware.android; -import android.content.Context; import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; import android.content.res.Configuration; -import android.os.Build; import android.os.Bundle; -import android.system.ErrnoException; -import android.system.Os; import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -16,33 +10,20 @@ import androidx.appcompat.app.AppCompatActivity; -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.zip.ZipInputStream; - -import org.beeware.rubicon.Python; +import com.chaquo.python.Kwarg; +import com.chaquo.python.PyException; +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; import {{ cookiecutter.package_name }}.{{ cookiecutter.module_name }}.R; -import static org.beeware.android.Helpers.ensureDirExists; -import static org.beeware.android.Helpers.unpackAssetPrefix; -import static org.beeware.android.Helpers.unzipTo; public class MainActivity extends AppCompatActivity { // To profile app launch, use `adb -s MainActivity`; look for "onCreate() start" and "onResume() completed". private String TAG = "MainActivity"; - private static IPythonApp pythonApp; + private static PyObject pythonApp; /** * This method is called by `app.__main__` over JNI in Python when the BeeWare @@ -52,7 +33,7 @@ public class MainActivity extends AppCompatActivity { */ @SuppressWarnings("unused") public static void setPythonApp(IPythonApp app) { - pythonApp = app; + pythonApp = PyObject.fromJava(app); } /** @@ -61,179 +42,6 @@ public static void setPythonApp(IPythonApp app) { */ public static MainActivity singletonThis; - private Map getPythonPaths() throws IOException { - Map paths = new HashMap(); - File base = new File(getApplicationContext().getFilesDir().getAbsolutePath() + "/python/"); - - // `stdlib` is used as PYTHONHOME. - File stdlib = new File(base.getAbsolutePath() + "/stdlib/"); - ensureDirExists(stdlib); - paths.put("stdlib", stdlib); - - // We cache the stdlib by checking the contents of this file. - paths.put("stdlib-last-filename", new File(base.getAbsolutePath() + "/stdlib.last-filename")); - - // Put `user_code` into paths so we can unpack the assets into it. - paths.put("user_code", new File(base.getAbsolutePath() + "/user_code/")); - - // `app` and `app_packages` store user code and user code dependencies, - // respectively. These paths exist within the `python/` assets tree. - File app = new File(base.getAbsolutePath() + "/user_code/app/"); - paths.put("app", app); - - File app_packages = new File(base.getAbsolutePath() + "/user_code/app_packages/"); - paths.put("app_packages", app_packages); - return paths; - } - - private void unpackPython(Map paths) throws IOException { - // Try to find `lastUpdateTime` on disk; compare it to actual `lastUpdateTime` from package manager. - // https://developer.android.com/reference/android/content/pm/PackageInfo.html#lastUpdateTime - Context context = this.getApplicationContext(); - File lastUpdateTimeFile = new File(context.getCacheDir(), "last-update-time"); - String storedLastUpdateTime = null; - String actualLastUpdateTime = null; - - if (lastUpdateTimeFile.exists()) { - BufferedReader reader = new BufferedReader( - new InputStreamReader( - new FileInputStream(lastUpdateTimeFile), StandardCharsets.UTF_8 - ) - ); - storedLastUpdateTime = reader.readLine(); - } - try { - PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); - actualLastUpdateTime = String.valueOf(packageInfo.lastUpdateTime); - } catch (PackageManager.NameNotFoundException e) { - Log.e(TAG, "Unable to find package; using default actualLastUpdateTime"); - } - if (storedLastUpdateTime != null && storedLastUpdateTime.equals(actualLastUpdateTime)) { - Log.d(TAG, "unpackPython() complete: Exiting early due to lastUpdateTime match: " + storedLastUpdateTime); - return; - } - - String myAbi = Build.SUPPORTED_ABIS[0]; - File pythonHome = paths.get("stdlib"); - - // Get list of assets under the stdlib/ directory, filtering for our ABI. - String[] stdlibAssets = this.getAssets().list("stdlib"); - String pythonHomeZipFilename = null; - String abiZipSuffix = myAbi + ".zip"; - for (int i = 0; i < stdlibAssets.length; i++) { - String asset = stdlibAssets[i]; - if (asset.startsWith("pythonhome.") && asset.endsWith(abiZipSuffix)) { - pythonHomeZipFilename = "stdlib/" + asset; - break; - } - } - // Unpack stdlib, except if it's missing, abort; and if we already unpacked a - // file of the same name, then skip it. That way, the filename can serve as - // a cache identifier. - if (pythonHomeZipFilename == null) { - throw new RuntimeException( - "Unable to find file matching pythonhome.* and " + abiZipSuffix - ); - } - File stdlibLastFilenamePath = paths.get("stdlib-last-filename"); - boolean cacheOk = false; - if (stdlibLastFilenamePath.exists()) { - BufferedReader reader = new BufferedReader( - new InputStreamReader( - new FileInputStream(stdlibLastFilenamePath), StandardCharsets.UTF_8 - ) - ); - String stdlibLastFilename = reader.readLine(); - if (stdlibLastFilename.equals(pythonHomeZipFilename)) { - cacheOk = true; - } - } - if (cacheOk) { - Log.d(TAG, "Python stdlib already exists for " + pythonHomeZipFilename); - } else { - Log.d(TAG, "Unpacking Python stdlib " + pythonHomeZipFilename); - unzipTo(new ZipInputStream(this.getAssets().open(pythonHomeZipFilename)), pythonHome); - BufferedWriter writer = new BufferedWriter( - new OutputStreamWriter( - new FileOutputStream(stdlibLastFilenamePath), StandardCharsets.UTF_8 - ) - ); - writer.write(pythonHomeZipFilename, 0, pythonHomeZipFilename.length()); - writer.close(); - } - - File userCodeDir = paths.get("user_code"); - Log.d(TAG, "Unpacking Python assets to " + userCodeDir.getAbsolutePath()); - unpackAssetPrefix(getAssets(), "python", userCodeDir); - if (actualLastUpdateTime != null) { - Log.d(TAG, "Replacing old lastUpdateTime = " + storedLastUpdateTime + " with actualLastUpdateTime = " + actualLastUpdateTime); - BufferedWriter timeWriter = new BufferedWriter( - new OutputStreamWriter( - new FileOutputStream(lastUpdateTimeFile), StandardCharsets.UTF_8 - ) - ); - timeWriter.write(actualLastUpdateTime, 0, actualLastUpdateTime.length()); - timeWriter.close(); - } - Log.d(TAG, "unpackPython() complete"); - } - - private void setPythonEnvVars(String pythonHome) throws IOException, ErrnoException { - Log.d(TAG, "setPythonEnvVars() start"); - Log.v(TAG, "pythonHome=" + pythonHome); - Context applicationContext = this.getApplicationContext(); - File cacheDir = applicationContext.getCacheDir(); - - // Set stdout and stderr to be unbuffered. We are overriding stdout/stderr and would - // prefer to avoid delays. - Os.setenv("PYTHONUNBUFFERED", "1", true); - - // Tell rubicon-java's Python code where to find the C library, to access it via ctypes. - Os.setenv("RUBICON_LIBRARY", this.getApplicationInfo().nativeLibraryDir + "/librubicon.so", true); - Os.setenv("TMPDIR", cacheDir.getAbsolutePath(), true); - Os.setenv("LD_LIBRARY_PATH", this.getApplicationInfo().nativeLibraryDir, true); - Os.setenv("PYTHONHOME", pythonHome, true); - Os.setenv("ACTIVITY_CLASS_NAME", "org/beeware/android/MainActivity", true); - Log.d(TAG, "setPythonEnvVars() complete"); - } - - private void startPython(Map paths) throws Exception { - this.unpackPython(paths); - String pythonHome = paths.get("stdlib").getAbsolutePath(); - this.setPythonEnvVars(pythonHome); - - Log.d(TAG, "Computing Python version."); - // Compute Python version number so that we can make sure it's first on sys.path when we - // configure sys.path with Python.init(). - String[] libpythons = new File(this.getApplicationInfo().nativeLibraryDir).list( - new FilenameFilter() { - @Override - public boolean accept(File file, String s) { - return s.startsWith("libpython") && s.endsWith(".so"); - } - } - ); - if (libpythons.length == 0) { - throw new Exception("Unable to compute Python version"); - } - String pythonVersion = libpythons[0].replace("libpython", "").replaceAll("m*.so", ""); - Log.d(TAG, "Computed Python version: " + pythonVersion); - - // `app` is the last item in the sysPath list. - String sysPath = (pythonHome + "/lib/python" + pythonVersion + "/") + ":" - + paths.get("app_packages").getAbsolutePath() + ":" + paths.get("app").getAbsolutePath(); - if (Python.init(pythonHome, sysPath, null) != 0) { - throw new Exception("Unable to start Python interpreter."); - } - Log.d(TAG, "Python.init() complete"); - - // Run the app's main module, similar to `python -m`. - Log.d(TAG, "Python.run() start"); - Python.run("{{ cookiecutter.module_name }}", new String[0]); - Log.d(TAG, "Python.run() end"); - Log.d(TAG, "startPython() end"); - } - protected void onCreate(Bundle savedInstanceState) { Log.d(TAG, "onCreate() start"); this.captureStdoutStderr(); @@ -244,30 +52,34 @@ protected void onCreate(Bundle savedInstanceState) { LinearLayout layout = new LinearLayout(this); this.setContentView(layout); singletonThis = this; - try { - Map paths = getPythonPaths(); - this.startPython(paths); - } catch (Exception e) { - Log.e(TAG, "Failed to create Python app", e); - return; + + if (Python.isStarted()) { + Log.d(TAG, "Python already started"); + } else { + Log.d(TAG, "Starting Python"); + Python.start(new AndroidPlatform(this)); } - Log.d(TAG, "user code onCreate() start"); - pythonApp.onCreate(); - Log.d(TAG, "user code onCreate() complete"); + Python py = Python.getInstance(); + Log.d(TAG, "Running main module"); + py.getModule("runpy").callAttr("run_module", "{{ cookiecutter.module_name }}", + new Kwarg("run_name", "__main__"), + new Kwarg("alter_sys", true)); + + userCode("onCreate"); Log.d(TAG, "onCreate() complete"); } protected void onStart() { Log.d(TAG, "onStart() start"); super.onStart(); - pythonApp.onStart(); + userCode("onStart"); Log.d(TAG, "onStart() complete"); } protected void onResume() { Log.d(TAG, "onResume() start"); super.onResume(); - pythonApp.onResume(); + userCode("onResume"); Log.d(TAG, "onResume() complete"); } @@ -275,33 +87,48 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { Log.d(TAG, "onActivityResult() start"); super.onActivityResult(requestCode, resultCode, data); - pythonApp.onActivityResult(requestCode, resultCode, data); + userCode("onActivityResult", requestCode, resultCode, data); Log.d(TAG, "onActivityResult() complete"); } public void onConfigurationChanged(Configuration newConfig) { Log.d(TAG, "onConfigurationChanged() start"); super.onConfigurationChanged(newConfig); - pythonApp.onConfigurationChanged(newConfig); + userCode("onConfigurationChanged", newConfig); Log.d(TAG, "onConfigurationChanged() complete"); } public boolean onOptionsItemSelected(MenuItem menuitem) { - boolean result; Log.d(TAG, "onOptionsItemSelected() start"); - result = pythonApp.onOptionsItemSelected(menuitem); + PyObject pyResult = userCode("onOptionsItemSelected", menuitem); + boolean result = (pyResult == null) ? false : pyResult.toBoolean(); Log.d(TAG, "onOptionsItemSelected() complete"); return result; } public boolean onPrepareOptionsMenu(Menu menu) { - boolean result; Log.d(TAG, "onPrepareOptionsMenu() start"); - result = pythonApp.onPrepareOptionsMenu(menu); + PyObject pyResult = userCode("onPrepareOptionsMenu", menu); + boolean result = (pyResult == null) ? false : pyResult.toBoolean(); Log.d(TAG, "onPrepareOptionsMenu() complete"); return result; } + private PyObject userCode(String methodName, Object... args) { + if (pythonApp == null) { + // Could be a non-graphical app such as Python-support-testbed. + return null; + } + try { + return pythonApp.callAttr(methodName, args); + } catch (PyException e) { + if (e.getMessage().startsWith("NotImplementedError")) { + return null; + } + throw e; + } + } + private native boolean captureStdoutStderr(); static { diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/__init__.py b/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/__init__.py new file mode 100644 index 0000000..fe5b774 --- /dev/null +++ b/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/__init__.py @@ -0,0 +1,88 @@ +"""Implements the Rubicon API using Chaquopy""" + +from java import cast, chaquopy, dynamic_proxy, jarray, jclass + + +# --- Globals ----------------------------------------------------------------- + +from java import jboolean, jbyte, jshort, jint, jlong, jfloat, jdouble, jchar, jvoid +jstring = jclass("java.lang.String") + +JavaClass = jclass + +# This wouldn't work if a class implements multiple interfaces, but Rubicon +# doesn't support that anyway. +def JavaInterface(name): + return dynamic_proxy(jclass(name)) + +def JavaNull(cls): + return cast(_java_class(cls), None) + + +# --- Class attributes -------------------------------------------------------- + +@property +def __null__(cls): + return cast(_java_class(cls), None) +chaquopy.JavaClass.__null__ = __null__ + +def __cast__(cls, obj, globalref=False): + return cast(_java_class(cls), obj) +chaquopy.JavaClass.__cast__ = __cast__ + +# This isn't part of Rubicon's public API, but Toga uses it to work around +# limitations in Rubicon's discovery of which interfaces a class implements. +@property +def _alternates(cls): + return [] +chaquopy.JavaClass._alternates = _alternates + +# For Rubicon unit tests. +@property +def _signature(self): + return self.sig.encode("UTF-8") +chaquopy.NoneCast._signature = _signature + + +# --- Instance attributes ----------------------------------------------------- + +Object = jclass("java.lang.Object") + +# In Chaquopy, all Java objects exposed to Python code already have global JNI +# references. +def __global__(self): + return self +Object.__global__ = __global__ + + +# ----------------------------------------------------------------------------- + +def _java_class(cls): + if isinstance(cls, list): + if len(cls) != 1: + raise ValueError("Expressions for an array class must contain a single item") + return jarray(_java_class(cls[0])) + if isinstance(cls, bytes): + cls = cls.decode("UTF-8") + if isinstance(cls, str): + cls = jclass(cls) + if isinstance(cls, Object): + # This isn't documented, but it's covered by the Rubicon unit tests. + cls = type(cls) + if not isinstance(cls, type): + raise ValueError(f"Cannot convert {cls!r} to a Java class") + + try: + return { + bool: jboolean, + int: jint, + float: jfloat, + str: jstring, + bytes: jarray(jbyte), + }[cls] + except KeyError: + pass + if isinstance(cls, chaquopy.DynamicProxyClass): + # Remove the dynamic_proxy wrapper which JavaInterface added above. + return cls.implements[0] + return cls diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/android_events.py b/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/android_events.py new file mode 100644 index 0000000..9aad57e --- /dev/null +++ b/{{ cookiecutter.safe_formal_name }}/app/src/main/python-briefcase/rubicon/java/android_events.py @@ -0,0 +1,428 @@ +import asyncio +import asyncio.base_events +import asyncio.events +import asyncio.log +import heapq +import selectors +import sys +import threading + +from . import JavaClass, JavaInterface + +Looper = JavaClass("android/os/Looper") +Handler = JavaClass("android/os/Handler") +OnFileDescriptorEventListener = JavaInterface( + "android/os/MessageQueue$OnFileDescriptorEventListener" +) +FileDescriptor = JavaClass("java/io/FileDescriptor") +Runnable = JavaInterface("java/lang/Runnable") + +# Some methods in this file are based on CPython's implementation. +# Per https://github.com/python/cpython/blob/master/LICENSE , re-use is permitted +# via the Python Software Foundation License Version 2, which includes inclusion +# into this project under its BSD license terms so long as we retain this copyright notice: +# Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, +# 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; All Rights Reserved. + + +class AndroidEventLoop(asyncio.SelectorEventLoop): + # `AndroidEventLoop` exists to support starting the Python event loop cooperatively with + # the built-in Android event loop. Since it's cooperative, it has a `run_forever_cooperatively()` + # method which returns immediately. This is is different from the parent class's `run_forever()`, + # which blocks. + # + # In some cases, for simplicity of implementation, this class reaches into the internals of the + # parent and grandparent classes. + # + # A Python event loop handles two kinds of tasks. It needs to run delayed tasks after waiting + # the right amount of time, and it needs to do I/O when file descriptors are ready for I/O. + # + # `SelectorEventLoop` uses an approach we **cannot** use: it calls the `select()` method + # to block waiting for specific file descriptors to be come ready for I/O, or a timeout + # corresponding to the soonest delayed task, whichever occurs sooner. + # + # To handle delayed tasks, `AndroidEventLoop` asks the Android event loop to wake it up when + # its soonest delayed task is ready. To accomplish this, it relies on a `SelectorEventLoop` + # implementation detail: `_scheduled` is a collection of tasks sorted by soonest wakeup time. + # + # To handle waking up when it's possible to do I/O, `AndroidEventLoop` will register file descriptors + # with the Android event loop so the platform can wake it up accordingly. It does not do this yet. + def __init__(self): + # Tell the parent constructor to use our custom Selector. + selector = AndroidSelector(self) + super().__init__(selector) + # Create placeholders for lazily-created objects. + self.android_interop = AndroidInterop() + + # Override parent `run_in_executor()` to run all code synchronously. This disables the + # `executor` thread that typically exists in event loops. The event loop itself relies + # on `run_in_executor()` for DNS lookups. In the future, we can restore `run_in_executor()`. + async def run_in_executor(self, executor, func, *args): + return func(*args) + + # Override parent `_call_soon()` to ensure Android wakes us up to do the delayed task. + def _call_soon(self, callback, args, context): + ret = super()._call_soon(callback, args, context) + self.enqueue_android_wakeup_for_delayed_tasks() + return ret + + # Override parent `_add_callback()` to ensure Android wakes us up to do the delayed task. + def _add_callback(self, handle): + ret = super()._add_callback(handle) + self.enqueue_android_wakeup_for_delayed_tasks() + return ret + + def run_forever_cooperatively(self): + """Configure the event loop so it is started, doing as little work as possible to + ensure that. Most Android interop objects are created lazily so that the cost of + event loop interop is not paid by apps that don't use the event loop.""" + # Based on `BaseEventLoop.run_forever()` in CPython. + if self.is_running(): + raise RuntimeError("Refusing to start since loop is already running.") + if self._closed: + raise RuntimeError("Event loop is closed. Create a new object.") + self._set_coroutine_origin_tracking(self._debug) + self._thread_id = threading.get_ident() + + self._old_agen_hooks = sys.get_asyncgen_hooks() + sys.set_asyncgen_hooks( + firstiter=self._asyncgen_firstiter_hook, + finalizer=self._asyncgen_finalizer_hook, + ) + asyncio.events._set_running_loop(self) + + def enqueue_android_wakeup_for_delayed_tasks(self): + """Ask Android to wake us up when delayed tasks are ready to be handled. + + Since this is effectively the actual event loop, it also handles stopping the loop.""" + # If we are supposed to stop, actually stop. + if self._stopping: + self._stopping = False + self._thread_id = None + asyncio.events._set_running_loop(None) + self._set_coroutine_origin_tracking(False) + sys.set_asyncgen_hooks(*self._old_agen_hooks) + # Remove Android event loop interop objects. + self.android_interop = None + return + + # If we have actually already stopped, then do nothing. + if self._thread_id is None: + return + + timeout = self._get_next_delayed_task_wakeup() + if timeout is None: + # No delayed tasks. + return + + # Ask Android to wake us up to run delayed tasks. Running delayed tasks also + # checks for other tasks that require wakeup by calling this method. The fact that + # running delayed tasks can trigger the next wakeup is what makes this event loop a "loop." + self.android_interop.call_later( + self.run_delayed_tasks, timeout * 1000, + ) + + def _set_coroutine_origin_tracking(self, debug): + # If running on Python 3.7 or 3.8, integrate with upstream event loop's debug feature, allowing + # unawaited coroutines to have some useful info logged. See https://bugs.python.org/issue32591 + if hasattr(super(), "_set_coroutine_origin_tracking"): + super()._set_coroutine_origin_tracking(debug) + + def _get_next_delayed_task_wakeup(self): + """Compute the time to sleep before we should be woken up to handle delayed tasks.""" + # This is based heavily on the CPython's implementation of `BaseEventLoop._run_once()` + # before it blocks on `select()`. + _MIN_SCHEDULED_TIMER_HANDLES = 100 + _MIN_CANCELLED_TIMER_HANDLES_FRACTION = 0.5 + MAXIMUM_SELECT_TIMEOUT = 24 * 3600 + + sched_count = len(self._scheduled) + if ( + sched_count > _MIN_SCHEDULED_TIMER_HANDLES + and self._timer_cancelled_count / sched_count + > _MIN_CANCELLED_TIMER_HANDLES_FRACTION + ): + # Remove delayed calls that were cancelled if their number + # is too high + new_scheduled = [] + for handle in self._scheduled: + if handle._cancelled: + handle._scheduled = False + else: + new_scheduled.append(handle) + + heapq.heapify(new_scheduled) + self._scheduled = new_scheduled + self._timer_cancelled_count = 0 + else: + # Remove delayed calls that were cancelled from head of queue. + while self._scheduled and self._scheduled[0]._cancelled: + self._timer_cancelled_count -= 1 + handle = heapq.heappop(self._scheduled) + handle._scheduled = False + + timeout = None + if self._ready or self._stopping: + if self._debug: + print("AndroidEventLoop: self.ready is", self._ready) + timeout = 0 + elif self._scheduled: + # Compute the desired timeout. + when = self._scheduled[0]._when + timeout = min(max(0, when - self.time()), MAXIMUM_SELECT_TIMEOUT) + + return timeout + + def run_delayed_tasks(self): + """Android-specific: Run any delayed tasks that have become ready. Additionally, check if + there are more delayed tasks to execute in the future; if so, schedule the next wakeup.""" + # Based heavily on `BaseEventLoop._run_once()` from CPython -- specifically, the part + # after blocking on `select()`. + # Handle 'later' callbacks that are ready. + end_time = self.time() + self._clock_resolution + while self._scheduled: + handle = self._scheduled[0] + if handle._when >= end_time: + break + handle = heapq.heappop(self._scheduled) + handle._scheduled = False + self._ready.append(handle) + + # This is the only place where callbacks are actually *called*. + # All other places just add them to ready. + # Note: We run all currently scheduled callbacks, but not any + # callbacks scheduled by callbacks run this time around -- + # they will be run the next time (after another I/O poll). + # Use an idiom that is thread-safe without using locks. + ntodo = len(self._ready) + for i in range(ntodo): + handle = self._ready.popleft() + if handle._cancelled: + continue + if self._debug: + try: + self._current_handle = handle + t0 = self.time() + handle._run() + dt = self.time() - t0 + if dt >= self.slow_callback_duration: + asyncio.log.logger.warning( + "Executing %s took %.3f seconds", + asyncio.base_events._format_handle(handle), + dt, + ) + finally: + self._current_handle = None + else: + handle._run() + handle = None # Needed to break cycles when an exception occurs. + + # End code borrowed from CPython, within this method. + self.enqueue_android_wakeup_for_delayed_tasks() + + +class AndroidInterop: + """Encapsulate details of Android event loop cooperation.""" + + def __init__(self): + # `_runnable_by_fn` is a one-to-one mapping from Python callables to Java Runnables. + # This allows us to avoid creating more than one Java object per Python callable, which + # would prevent removeCallbacks from working. + self._runnable_by_fn = {} + # The handler must be created on the Android UI thread. + self.handler = Handler() + + def get_or_create_runnable(self, fn): + if fn in self._runnable_by_fn: + return self._runnable_by_fn[fn] + + self._runnable_by_fn[fn] = PythonRunnable(fn) + return self._runnable_by_fn[fn] + + def call_later(self, fn, timeout_millis): + """Enqueue a Python callable `fn` to be run after `timeout_millis` milliseconds.""" + runnable = self.get_or_create_runnable(fn) + self.handler.removeCallbacks(runnable) + self.handler.postDelayed(runnable, int(timeout_millis)) + + +class PythonRunnable(Runnable): + """Bind a specific Python callable in a Java `Runnable`.""" + + def __init__(self, fn): + super().__init__() + self._fn = fn + + def run(self): + self._fn() + + +class AndroidSelector(selectors.SelectSelector): + """Subclass of selectors.Selector which cooperates with the Android event loop + to learn when file descriptors become ready for I/O. + + AndroidSelector's `select()` raises NotImplementedError; see its comments.""" + + def __init__(self, loop): + super().__init__() + self.loop = loop + # Lazily-created AndroidSelectorFileDescriptorEventsListener. + self._file_descriptor_event_listener = None + # Keep a `_debug` flag so that a developer can modify it for more debug printing. + self._debug = False + + @property + def file_descriptor_event_listener(self): + if self._file_descriptor_event_listener is not None: + return self._file_descriptor_event_listener + self._file_descriptor_event_listener = AndroidSelectorFileDescriptorEventsListener( + android_selector=self, + ) + return self._file_descriptor_event_listener + + @property + def message_queue(self): + return Looper.getMainLooper().getQueue() + + # File descriptors can be registered and unregistered by the event loop. + # The events for which we listen can be modified. For register & unregister, + # we mostly rely on the parent class. For modify(), the parent class calls + # unregister() and register(), so we rely on that as well. + + def register(self, fileobj, events, data=None): + if self._debug: + print( + "register() fileobj={fileobj} events={events} data={data}".format( + fileobj=fileobj, events=events, data=data + ) + ) + ret = super().register(fileobj, events, data=data) + self.register_with_android(fileobj, events) + return ret + + def unregister(self, fileobj): + self.message_queue.removeOnFileDescriptorEventListener(_create_java_fd(fileobj)) + return super().unregister(fileobj) + + def reregister_with_android_soon(self, fileobj): + def _reregister(): + # If the fileobj got unregistered, exit early. + key = self._key_from_fd(fileobj) + if key is None: + if self._debug: + print( + "reregister_with_android_soon reregister_temporarily_ignored_fd exiting early; key=None" + ) + return + if self._debug: + print( + "reregister_with_android_soon reregistering key={key}".format( + key=key + ) + ) + self.register_with_android(key.fd, key.events) + + # Use `call_later(0, fn)` to ensure the Python event loop runs to completion before re-registering. + self.loop.call_later(0, _reregister) + + def register_with_android(self, fileobj, events): + if self._debug: + print( + "register_with_android() fileobj={fileobj} events={events}".format( + fileobj=fileobj, events=events + ) + ) + # `events` is a bitset comprised of `selectors.EVENT_READ` and `selectors.EVENT_WRITE`. + # Register this FD for read and/or write events from Android. + self.message_queue.addOnFileDescriptorEventListener( + _create_java_fd(fileobj), + events, # Passing `events` as-is because Android and Python use the same values for read & write events. + self.file_descriptor_event_listener, + ) + + def handle_fd_wakeup(self, fd, events): + """Accept a FD and the events that it is ready for (read and/or write). + + Filter the events to just those that are registered, then notify the loop.""" + key = self._key_from_fd(fd) + if key is None: + print( + "Warning: handle_fd_wakeup: wakeup for unregistered fd={fd}".format( + fd=fd + ) + ) + return + + key_event_pairs = [] + for event_type in (selectors.EVENT_READ, selectors.EVENT_WRITE): + if events & event_type and key.events & event_type: + key_event_pairs.append((key, event_type)) + if key_event_pairs: + if self._debug: + print( + "handle_fd_wakeup() calling parent for key_event_pairs={key_event_pairs}".format( + key_event_pairs=key_event_pairs + ) + ) + # Call superclass private method to notify. + self.loop._process_events(key_event_pairs) + else: + print( + "Warning: handle_fd_wakeup(): unnecessary wakeup fd={fd} events={events} key={key}".format( + fd=fd, events=events, key=key + ) + ) + + # This class declines to implement the `select()` method, purely as + # a safety mechanism. On Android, this would be an error -- it would result + # in the app freezing, triggering an App Not Responding pop-up from the + # platform, and the user killing the app. + # + # Instead, the AndroidEventLoop cooperates with the native Android event + # loop to be woken up to get work done as needed. + def select(self, *args, **kwargs): + raise NotImplementedError("AndroidSelector refuses to select(); see comments.") + + +class AndroidSelectorFileDescriptorEventsListener(OnFileDescriptorEventListener): + """Notify an `AndroidSelector` instance when file descriptors become readable/writable.""" + + def __init__(self, android_selector): + super().__init__() + self.android_selector = android_selector + # Keep a `_debug` flag so that a developer can modify it for more debug printing. + self._debug = False + + def onFileDescriptorEvents(self, fd_obj, events): + """Receive a Java FileDescriptor object and notify the Python event loop that the FD + is ready for read and/or write. + + As an implementation detail, this relies on the fact that Android EVENT_INPUT and Python + selectors.EVENT_READ have the same value (1) and Android EVENT_OUTPUT and Python + selectors.EVENT_WRITE have the same value (2).""" + # Call hidden (non-private) method to get the numeric FD, so we can pass that to Python. + fd = getattr(fd_obj, "getInt$")() + if self._debug: + print( + "onFileDescriptorEvents woke up for fd={fd} events={events}".format( + fd=fd, events=events + ) + ) + # Tell the Python event loop that the FD is ready for read and/or write. + self.android_selector.handle_fd_wakeup(fd, events) + # Tell Android we don't want any more wake-ups from this FD until the event loop runs. + # To do that, we return 0. + # + # We also need Python to request wake-ups once the event loop has finished. + self.android_selector.reregister_with_android_soon(fd) + return 0 + + +def _create_java_fd(int_fd): + """Given a numeric file descriptor, create a `java.io.FileDescriptor` object.""" + # On Android, the class exposes hidden (non-private) methods `getInt$()` and `setInt$()`. Because + # they aren't valid Python identifier names, we need to use `getattr()` to grab them. + # See e.g. https://android.googlesource.com/platform/prebuilts/fullsdk/sources/android-28/+/refs/heads/master/java/io/FileDescriptor.java#149 # noqa: E501 + java_fd = FileDescriptor() + getattr(java_fd, "setInt$")(int_fd) + return java_fd diff --git a/{{ cookiecutter.safe_formal_name }}/app/src/main/assets/python/app/README b/{{ cookiecutter.safe_formal_name }}/app/src/main/python/README similarity index 100% rename from {{ cookiecutter.safe_formal_name }}/app/src/main/assets/python/app/README rename to {{ cookiecutter.safe_formal_name }}/app/src/main/python/README diff --git a/{{ cookiecutter.safe_formal_name }}/briefcase.toml b/{{ cookiecutter.safe_formal_name }}/briefcase.toml index 8dcede6..0d78210 100644 --- a/{{ cookiecutter.safe_formal_name }}/briefcase.toml +++ b/{{ cookiecutter.safe_formal_name }}/briefcase.toml @@ -1,9 +1,7 @@ # Generated using Python {{ cookiecutter.python_version }} [paths] -app_path = "app/src/main/assets/python/app" -app_packages_path = "app/src/main/assets/python/app_packages" -support_path = "app" -{{ {"3.8": "support_revision = 5", "3.9": "support_revision = 3", "3.10": "support_revision = 2"}.get(cookiecutter.python_version|py_tag, "") }} +app_path = "app/src/main/python" +app_requirements_path = "app/requirements.txt" icon.round.48 = "app/src/main/res/mipmap-mdpi/ic_launcher_round.png" icon.round.72 = "app/src/main/res/mipmap-hdpi/ic_launcher_round.png" diff --git a/{{ cookiecutter.safe_formal_name }}/build.gradle b/{{ cookiecutter.safe_formal_name }}/build.gradle index 120e45a..203aa47 100644 --- a/{{ cookiecutter.safe_formal_name }}/build.gradle +++ b/{{ cookiecutter.safe_formal_name }}/build.gradle @@ -4,10 +4,10 @@ buildscript { repositories { google() mavenCentral() - } dependencies { - classpath 'com.android.tools.build:gradle:4.2.0+' + classpath 'com.android.tools.build:gradle:4.2.2' + classpath 'com.chaquo.python:gradle:13.0.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/{{ cookiecutter.safe_formal_name }}/gradle.properties b/{{ cookiecutter.safe_formal_name }}/gradle.properties index fe26d99..fdbd962 100644 --- a/{{ cookiecutter.safe_formal_name }}/gradle.properties +++ b/{{ cookiecutter.safe_formal_name }}/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -15,8 +15,7 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Do not automatically convert third-party libraries to use AndroidX, since -# rubicon-java fails this conversion. -android.enableJetifier=false -# Ensure pythonlib is extracted from AAB bundles at runtime -android.bundle.enableUncompressedNativeLibs=false +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file