From 9e8f896f6b1ec3eefadc3748bc557861be5c81a3 Mon Sep 17 00:00:00 2001 From: ElliotGarbus Date: Fri, 24 Jul 2026 20:53:06 -0700 Subject: [PATCH 1/2] android: implement Kivy 3's bootstrap contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kivy 3 no longer reflects a hardcoded org.kivy.android.PythonActivity. It imports a top-level `_kivy_bootstrap` module supplied by whoever built the APK and pulls the current Activity from it, so Kivy stops depending on a p4a implementation detail and p4a becomes one bootstrap satisfying the contract. The `android` recipe is the natural home: it is already a hard dependency of the `kivy` recipe, so every Kivy app has it, and it already knows ACTIVITY_CLASS_NAME. Reading the class from the generated `android.config` means --activity-class-name is honoured rather than assumed, which the hardcoded name in Kivy silently broke. The contract's optional `remove_presplash()` is implemented too, restoring for Kivy 3 the capability negotiation Kivy 2.x got from `try: import android`. It asks the activity for removeLoadingScreen instead of branching on the bootstrap name, since not every build has one and such a list has already drifted. Nothing imports the module unless Kivy 3 does, so this is inert for existing builds; Kivy 2.3.1 keeps using the `android` module as before. doc/source/kivy_bootstrap.rst documents the contract for bootstrap authors — what to ship, the rules it must obey, and this file as the worked example. --- doc/source/bootstraps.rst | 4 +- doc/source/index.rst | 1 + doc/source/kivy_bootstrap.rst | 248 ++++++++++++++++++ .../recipes/android/src/_kivy_bootstrap.py | 64 +++++ pythonforandroid/recipes/android/src/setup.py | 3 + 5 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 doc/source/kivy_bootstrap.rst create mode 100644 pythonforandroid/recipes/android/src/_kivy_bootstrap.py diff --git a/doc/source/bootstraps.rst b/doc/source/bootstraps.rst index db82d56ebc..9ce485c06a 100644 --- a/doc/source/bootstraps.rst +++ b/doc/source/bootstraps.rst @@ -4,7 +4,9 @@ Bootstraps This page is about creating new bootstrap backends. For build options of existing bootstraps (i.e. with SDL2, Webview, etc.), see -:ref:`build options `. +:ref:`build options `. If your bootstrap is +to run Kivy 3 apps, it must also satisfy +:ref:`Kivy's bootstrap contract `. python-for-android (p4a) supports multiple *bootstraps*. These fulfill a similar role to recipes, but instead of describing how to compile a diff --git a/doc/source/index.rst b/doc/source/index.rst index e6b2016bbe..c71d3081d1 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -58,6 +58,7 @@ Contents distutils recipes bootstraps + kivy_bootstrap services troubleshooting docker diff --git a/doc/source/kivy_bootstrap.rst b/doc/source/kivy_bootstrap.rst new file mode 100644 index 0000000000..865b7f214b --- /dev/null +++ b/doc/source/kivy_bootstrap.rst @@ -0,0 +1,248 @@ +.. _kivy_bootstrap_contract: + +Supporting Kivy 3 (the Kivy bootstrap contract) +=============================================== + +Kivy 3 asks the bootstrap for the current Android ``Activity`` instead of +reflecting a class name of its own. Any tool that builds Android APKs — this +project, another build tool, or a bootstrap you write yourself — makes Kivy 3 +work by answering that question. + +This page is the implementer's guide: what to ship, the rules it must obey, and +a working example to copy. p4a's own implementation is +``pythonforandroid/recipes/android/src/_kivy_bootstrap.py``, reproduced below. + +Kivy 2.3.1 does not use this contract and is unaffected; see +`Supporting Kivy 2.3.1 as well`_ if you need both. + +What you ship +------------- + +One pure-Python module named ``_kivy_bootstrap``, importable at the **top +level** of the running app's ``sys.path``. Nothing else: no registration call, +no import order to arrange, no Kivy dependency. + +The name is Kivy's, not any bootstrap's. That is the point of the arrangement — +Kivy depends on the name, so it depends on no particular bootstrap, and an +unmodified Kivy runs on an APK built by any of them. + +Kivy imports the module the first time it needs the Activity, so it must be on +``sys.path`` before the application's ``main.py`` runs. In p4a it is a +``py_modules`` entry of the ``android`` recipe, so it lands beside the app's +other top-level modules in ``site-packages``. + +``get_activity()`` (required) +----------------------------- + +Return the current ``android.app.Activity``, or ``None`` where there is none. + +.. code-block:: python + + def get_activity(): + return SomeActivityClass.mActivity + +Kivy calls this **live on every access** and never caches the result, so your +implementation must not cache it either (see rule 2 below). ``None`` is a +legitimate answer, not a failure: a background service runs with no Activity, +and Kivy treats that as the ordinary case it is. + +``get_context()`` (optional) +---------------------------- + +Return an ``android.content.Context``, or ``None``. + +Implement this only if your bootstrap runs processes that never have an +Activity and still need a Context. When it is absent — or returns ``None`` — +Kivy derives the Application context from the current Activity, which is +equivalent for everything Kivy uses a Context for. Neither p4a nor kivyforge +implements it. + +``remove_presplash()`` (optional) +--------------------------------- + +Dismiss your boot splash. Kivy calls this once it has drawn its first frame, +which is the one thing about a splash screen that only Kivy knows. + +*How* a splash is dismissed is entirely yours, and the mechanisms differ +fundamentally: p4a overlays a ``View`` and removes it, while a bootstrap using +the Android 12 system splash releases a keep-on-screen condition instead — not +a method on the Activity at all. That is why this is an optional function on +your module rather than a method Kivy calls on the Activity. + +If you have no splash to dismiss, omit the function. Kivy treats its absence as +the no-op it is, with no warning. + +Rules +----- + +1. **Never import Kivy from this module.** Kivy reads its ``KIVY_*`` + environment and builds its configuration at import time, so importing it + before the application runs would freeze that configuration before the app's + ``main.py`` could set it. Kivy pulls from you precisely so that you never + have to import it. + +2. **Never cache the Activity.** Android destroys and recreates the Activity on + configuration changes (rotation, dark mode, locale, multi-window) and after + process death. A stored instance goes stale, and holding one in a Python + global pins a JNI reference to a dead Activity. Read it fresh on every call + and staleness stops being your problem. + +3. **Resolve reflection inside the call, not at import.** Resolve the Java + class on first call and cache *the class* if you like, but do not do it at + module import: a reflection failure would then surface from Kivy's discovery + import, where it looks like a missing module rather than the real fault. + +4. **Fail at import only with an ImportError, and only if you mean it.** Kivy + treats ``ImportError`` as "this bootstrap does not implement the contract" + and moves on to raise a diagnostic. Any *other* exception escaping your + module's import is treated as a fault in your module and propagates + unchanged, so it is not mistaken for an absent bootstrap. + +5. **A presplash hook must not raise, and must tolerate repeat calls.** + Kivy does not guard the call and makes no promise about calling it exactly + once. Doing nothing is a correct outcome; raising is not. + +6. **Hold no state beyond the resolved class.** Kivy may call these from more + than one thread. A stateless module is thread-safe without a lock, and the + contract is designed so that statelessness costs nothing. + +What Kivy does with it +---------------------- + +Useful when debugging your implementation: + +* The module is imported lazily, on first use, and the outcome is cached — + including failure, so a build with no such module does not pay for a failed + import on every geometry read. +* If the module is missing (or exposes no callable ``get_activity``) **while an + Android runtime is present**, Kivy raises ``ActivityProviderMissing``. This + is deliberately not caught by Kivy's display-geometry getters, which would + otherwise mask a broken build as plausible-looking defaults. +* ``kivy.mobile.get_app_context()`` prefers your ``get_context()`` and + otherwise calls ``getApplicationContext()`` on the current Activity. +* ``kivy.mobile.get_activity()`` returns exactly what your function returned, + ``None`` included. + +Minimal implementation +---------------------- + +Enough to satisfy the contract, if your activity class is fixed at build time: + +.. code-block:: python + + from jnius import autoclass + + _ACTIVITY_CLASS = "com.example.MyActivity" # your class; Kivy never sees it + + _activity_class = None + + + def get_activity(): + global _activity_class + if _activity_class is None: + _activity_class = autoclass(_ACTIVITY_CLASS) + return _activity_class.mActivity + +Your activity needs some way to expose the running instance. p4a and kivyforge +both use a ``public static`` field assigned in ``onCreate``; anything reachable +by reflection will do. + +Worked example: python-for-android +---------------------------------- + +The complete p4a implementation, from +``pythonforandroid/recipes/android/src/_kivy_bootstrap.py`` (docstrings +trimmed here; the file explains each decision at length): + +.. code-block:: python + + from jnius import autoclass + + from android.config import ACTIVITY_CLASS_NAME + + _activity_class = None + + + def get_activity(): + """The current android.app.Activity, or None if there is none.""" + global _activity_class + if _activity_class is None: + # Resolved on first use rather than at import so that reflection + # failures surface from the call, not from Kivy's discovery import. + _activity_class = autoclass(ACTIVITY_CLASS_NAME) + return _activity_class.mActivity + + + def remove_presplash(): + """Dismiss the loading screen, if this build has one.""" + activity = get_activity() + if activity is None: + return + remove = getattr(activity, "removeLoadingScreen", None) + if remove is not None: + remove() + +Two things there are worth imitating. + +The class name comes from the build-time generated ``android.config`` rather +than a literal, so a custom activity passed to ``--activity-class-name`` is +honoured: Kivy gets the class *this build* chose. If your tool lets users +override the activity, resolve the name the same way. + +``remove_presplash()`` asks the activity for ``removeLoadingScreen`` instead of +consulting a list of bootstraps that have one. Not every p4a build does — +``service_only`` has no such method, and a custom activity need not inherit +one — and a hardcoded list has already drifted once in this codebase. + +A second example: kivyforge +--------------------------- + +kivyforge, a Gradle- and wheel-based Android builder, implements the same +contract in its own ``_kivy_bootstrap.py`` against an unmodified Kivy. It +differs in exactly the ways the contract allows: the activity class is a +constant it owns, since it has no ``android.config``; and it omits +``remove_presplash()`` entirely, because it uses the androidx +core-splashscreen system splash, which the framework dismisses itself and which +exposes no view for anyone to tear down. + +That two bootstraps this different need no cooperation from each other, and no +change in Kivy, is the contract working as intended. + +Verifying on a device +--------------------- + +The failure this contract guards against is a build that starts fine and dies +the moment Kivy first needs the Activity, so check it on a device or emulator +rather than in a unit test: + +.. code-block:: python + + import _kivy_bootstrap + + activity = _kivy_bootstrap.get_activity() + assert activity is not None + assert activity.getPackageName() # answers as a Context: it is live + +Then, from inside a running Kivy app, confirm Kivy agrees with you: + +.. code-block:: python + + from kivy.mobile import get_activity, get_app_context + + assert get_activity().equals(_kivy_bootstrap.get_activity()) + assert get_app_context() is not None + +Beyond that, the contract is exercised by anything in Kivy that needs the +Activity: display metrics (``kivy.metrics``), ``App.user_data_dir``, the +clipboard provider, and the splash removal after the first frame. If those work, +your implementation is complete. + +Supporting Kivy 2.3.1 as well +----------------------------- + +Kivy 2.3.1 predates this contract. It reaches p4a's Python ``android`` module +directly — ``android.remove_presplash()`` and friends — and hardcodes +``org.kivy.android.PythonActivity``. A bootstrap that wants to run both Kivy +2.3.1 and Kivy 3 therefore has to satisfy both: ship ``_kivy_bootstrap`` for +Kivy 3, and provide the ``android`` module (and an activity of that class name) +for 2.3.1. A bootstrap targeting Kivy 3 alone needs only this contract. diff --git a/pythonforandroid/recipes/android/src/_kivy_bootstrap.py b/pythonforandroid/recipes/android/src/_kivy_bootstrap.py new file mode 100644 index 0000000000..ddd3093427 --- /dev/null +++ b/pythonforandroid/recipes/android/src/_kivy_bootstrap.py @@ -0,0 +1,64 @@ +"""python-for-android's implementation of Kivy's Android bootstrap contract. + +Kivy 3 holds no bootstrap class name of its own: it imports ``_kivy_bootstrap`` +and asks it for the current ``android.app.Activity`` instead. Kivy pulls on +first use rather than having the bootstrap register at startup, which suits p4a: +``start.c`` runs the user's ``main.py`` as the process entry point, so there is +no p4a-owned Python before the app to register from. It also means p4a never +imports Kivy, which would otherwise fix Kivy's ``KIVY_*`` environment and config +before the app had a chance to set them. + +The activity class comes from the build-time generated ``android.config``, so a +custom activity set with ``--activity-class-name`` is honoured rather than +assumed to be the default. + +Besides the required ``get_activity()``, this implements the contract's optional +``remove_presplash()``. ``get_context()`` is not implemented: p4a's Activity can +always supply the Application context, and Kivy falls back to deriving it. + +This module deliberately holds no state beyond the resolved class: the Activity +is read fresh on every call, so it stays correct across the recreation Android +performs on rotation, configuration changes and process death. +""" + +from jnius import autoclass + +from android.config import ACTIVITY_CLASS_NAME + +_activity_class = None + + +def get_activity(): + """Return the current ``android.app.Activity``, or ``None`` if there is none. + + ``None`` is a legitimate answer — a p4a service runs without an Activity. + """ + global _activity_class + if _activity_class is None: + # Resolved on first use rather than at import so that reflection + # failures surface from the call, not from Kivy's discovery import. + _activity_class = autoclass(ACTIVITY_CLASS_NAME) + return _activity_class.mActivity + + +def remove_presplash(): + """Dismiss the loading screen, if this build has one. + + Kivy calls this once it has drawn its first frame — the moment only Kivy + knows. How the splash goes away is p4a's business: here it means removing + the View that ``PythonActivity`` laid over the app, which the Java method + marshals onto the UI thread itself, so there is nothing to arrange here. + + Not every p4a build has a splash to remove: ``service_only`` has no + ``removeLoadingScreen`` at all, and a custom ``--activity-class-name`` need + not inherit one. So the activity is asked, rather than a hardcoded list of + bootstraps consulted — that list has already drifted once, ``android``'s own + ``remove_presplash`` being gated to sdl2/sdl3 though the webview activity has + the method too. Doing nothing is a valid outcome and Kivy treats it as one. + """ + activity = get_activity() + if activity is None: + return + remove = getattr(activity, "removeLoadingScreen", None) + if remove is not None: + remove() diff --git a/pythonforandroid/recipes/android/src/setup.py b/pythonforandroid/recipes/android/src/setup.py index 030f5fb126..2c67c77be0 100755 --- a/pythonforandroid/recipes/android/src/setup.py +++ b/pythonforandroid/recipes/android/src/setup.py @@ -34,5 +34,8 @@ version='1.0', packages=['android'], package_dir={'android': 'android'}, + # Top-level, not under `android`: the name is Kivy's, and Kivy imports it + # without knowing which bootstrap built the app. + py_modules=['_kivy_bootstrap'], ext_modules=cythonized_modules ) From 8980856f827addad49aa481a313459727f6ca34b Mon Sep 17 00:00:00 2001 From: ElliotGarbus Date: Sat, 25 Jul 2026 12:10:41 -0700 Subject: [PATCH 2/2] docs: trim kivy_bootstrap guide to the contract itself Drop the minimal-implementation, worked-example, kivyforge, and on-device verification sections per maintainer feedback. Point readers at p4a's `_kivy_bootstrap.py` instead of reproducing it, and stop naming other build tools. --- doc/source/kivy_bootstrap.rst | 123 ++-------------------------------- 1 file changed, 4 insertions(+), 119 deletions(-) diff --git a/doc/source/kivy_bootstrap.rst b/doc/source/kivy_bootstrap.rst index 865b7f214b..37f6c90f76 100644 --- a/doc/source/kivy_bootstrap.rst +++ b/doc/source/kivy_bootstrap.rst @@ -8,9 +8,9 @@ reflecting a class name of its own. Any tool that builds Android APKs — this project, another build tool, or a bootstrap you write yourself — makes Kivy 3 work by answering that question. -This page is the implementer's guide: what to ship, the rules it must obey, and -a working example to copy. p4a's own implementation is -``pythonforandroid/recipes/android/src/_kivy_bootstrap.py``, reproduced below. +This page is the implementer's guide: what to ship, the rules it must obey. +p4a's own implementation is +``pythonforandroid/recipes/android/src/_kivy_bootstrap.py``. Kivy 2.3.1 does not use this contract and is unaffected; see `Supporting Kivy 2.3.1 as well`_ if you need both. @@ -54,8 +54,7 @@ Return an ``android.content.Context``, or ``None``. Implement this only if your bootstrap runs processes that never have an Activity and still need a Context. When it is absent — or returns ``None`` — Kivy derives the Application context from the current Activity, which is -equivalent for everything Kivy uses a Context for. Neither p4a nor kivyforge -implements it. +equivalent for everything Kivy uses a Context for. p4a does not implement it. ``remove_presplash()`` (optional) --------------------------------- @@ -123,120 +122,6 @@ Useful when debugging your implementation: * ``kivy.mobile.get_activity()`` returns exactly what your function returned, ``None`` included. -Minimal implementation ----------------------- - -Enough to satisfy the contract, if your activity class is fixed at build time: - -.. code-block:: python - - from jnius import autoclass - - _ACTIVITY_CLASS = "com.example.MyActivity" # your class; Kivy never sees it - - _activity_class = None - - - def get_activity(): - global _activity_class - if _activity_class is None: - _activity_class = autoclass(_ACTIVITY_CLASS) - return _activity_class.mActivity - -Your activity needs some way to expose the running instance. p4a and kivyforge -both use a ``public static`` field assigned in ``onCreate``; anything reachable -by reflection will do. - -Worked example: python-for-android ----------------------------------- - -The complete p4a implementation, from -``pythonforandroid/recipes/android/src/_kivy_bootstrap.py`` (docstrings -trimmed here; the file explains each decision at length): - -.. code-block:: python - - from jnius import autoclass - - from android.config import ACTIVITY_CLASS_NAME - - _activity_class = None - - - def get_activity(): - """The current android.app.Activity, or None if there is none.""" - global _activity_class - if _activity_class is None: - # Resolved on first use rather than at import so that reflection - # failures surface from the call, not from Kivy's discovery import. - _activity_class = autoclass(ACTIVITY_CLASS_NAME) - return _activity_class.mActivity - - - def remove_presplash(): - """Dismiss the loading screen, if this build has one.""" - activity = get_activity() - if activity is None: - return - remove = getattr(activity, "removeLoadingScreen", None) - if remove is not None: - remove() - -Two things there are worth imitating. - -The class name comes from the build-time generated ``android.config`` rather -than a literal, so a custom activity passed to ``--activity-class-name`` is -honoured: Kivy gets the class *this build* chose. If your tool lets users -override the activity, resolve the name the same way. - -``remove_presplash()`` asks the activity for ``removeLoadingScreen`` instead of -consulting a list of bootstraps that have one. Not every p4a build does — -``service_only`` has no such method, and a custom activity need not inherit -one — and a hardcoded list has already drifted once in this codebase. - -A second example: kivyforge ---------------------------- - -kivyforge, a Gradle- and wheel-based Android builder, implements the same -contract in its own ``_kivy_bootstrap.py`` against an unmodified Kivy. It -differs in exactly the ways the contract allows: the activity class is a -constant it owns, since it has no ``android.config``; and it omits -``remove_presplash()`` entirely, because it uses the androidx -core-splashscreen system splash, which the framework dismisses itself and which -exposes no view for anyone to tear down. - -That two bootstraps this different need no cooperation from each other, and no -change in Kivy, is the contract working as intended. - -Verifying on a device ---------------------- - -The failure this contract guards against is a build that starts fine and dies -the moment Kivy first needs the Activity, so check it on a device or emulator -rather than in a unit test: - -.. code-block:: python - - import _kivy_bootstrap - - activity = _kivy_bootstrap.get_activity() - assert activity is not None - assert activity.getPackageName() # answers as a Context: it is live - -Then, from inside a running Kivy app, confirm Kivy agrees with you: - -.. code-block:: python - - from kivy.mobile import get_activity, get_app_context - - assert get_activity().equals(_kivy_bootstrap.get_activity()) - assert get_app_context() is not None - -Beyond that, the contract is exercised by anything in Kivy that needs the -Activity: display metrics (``kivy.metrics``), ``App.user_data_dir``, the -clipboard provider, and the splash removal after the first frame. If those work, -your implementation is complete. - Supporting Kivy 2.3.1 as well -----------------------------