Skip to content

Commit f7798e6

Browse files
authored
android: implement Kivy 3's bootstrap contract (_kivy_bootstrap) (#3356)
* android: implement Kivy 3's bootstrap contract 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. * 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.
1 parent 33598d1 commit f7798e6

5 files changed

Lines changed: 204 additions & 1 deletion

File tree

doc/source/bootstraps.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ Bootstraps
44

55
This page is about creating new bootstrap backends. For build options
66
of existing bootstraps (i.e. with SDL2, Webview, etc.), see
7-
:ref:`build options <bootstrap_build_options>`.
7+
:ref:`build options <bootstrap_build_options>`. If your bootstrap is
8+
to run Kivy 3 apps, it must also satisfy
9+
:ref:`Kivy's bootstrap contract <kivy_bootstrap_contract>`.
810

911
python-for-android (p4a) supports multiple *bootstraps*. These fulfill a
1012
similar role to recipes, but instead of describing how to compile a

doc/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Contents
5858
distutils
5959
recipes
6060
bootstraps
61+
kivy_bootstrap
6162
services
6263
troubleshooting
6364
docker

doc/source/kivy_bootstrap.rst

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
.. _kivy_bootstrap_contract:
2+
3+
Supporting Kivy 3 (the Kivy bootstrap contract)
4+
===============================================
5+
6+
Kivy 3 asks the bootstrap for the current Android ``Activity`` instead of
7+
reflecting a class name of its own. Any tool that builds Android APKs — this
8+
project, another build tool, or a bootstrap you write yourself — makes Kivy 3
9+
work by answering that question.
10+
11+
This page is the implementer's guide: what to ship, the rules it must obey.
12+
p4a's own implementation is
13+
``pythonforandroid/recipes/android/src/_kivy_bootstrap.py``.
14+
15+
Kivy 2.3.1 does not use this contract and is unaffected; see
16+
`Supporting Kivy 2.3.1 as well`_ if you need both.
17+
18+
What you ship
19+
-------------
20+
21+
One pure-Python module named ``_kivy_bootstrap``, importable at the **top
22+
level** of the running app's ``sys.path``. Nothing else: no registration call,
23+
no import order to arrange, no Kivy dependency.
24+
25+
The name is Kivy's, not any bootstrap's. That is the point of the arrangement —
26+
Kivy depends on the name, so it depends on no particular bootstrap, and an
27+
unmodified Kivy runs on an APK built by any of them.
28+
29+
Kivy imports the module the first time it needs the Activity, so it must be on
30+
``sys.path`` before the application's ``main.py`` runs. In p4a it is a
31+
``py_modules`` entry of the ``android`` recipe, so it lands beside the app's
32+
other top-level modules in ``site-packages``.
33+
34+
``get_activity()`` (required)
35+
-----------------------------
36+
37+
Return the current ``android.app.Activity``, or ``None`` where there is none.
38+
39+
.. code-block:: python
40+
41+
def get_activity():
42+
return SomeActivityClass.mActivity
43+
44+
Kivy calls this **live on every access** and never caches the result, so your
45+
implementation must not cache it either (see rule 2 below). ``None`` is a
46+
legitimate answer, not a failure: a background service runs with no Activity,
47+
and Kivy treats that as the ordinary case it is.
48+
49+
``get_context()`` (optional)
50+
----------------------------
51+
52+
Return an ``android.content.Context``, or ``None``.
53+
54+
Implement this only if your bootstrap runs processes that never have an
55+
Activity and still need a Context. When it is absent — or returns ``None`` —
56+
Kivy derives the Application context from the current Activity, which is
57+
equivalent for everything Kivy uses a Context for. p4a does not implement it.
58+
59+
``remove_presplash()`` (optional)
60+
---------------------------------
61+
62+
Dismiss your boot splash. Kivy calls this once it has drawn its first frame,
63+
which is the one thing about a splash screen that only Kivy knows.
64+
65+
*How* a splash is dismissed is entirely yours, and the mechanisms differ
66+
fundamentally: p4a overlays a ``View`` and removes it, while a bootstrap using
67+
the Android 12 system splash releases a keep-on-screen condition instead — not
68+
a method on the Activity at all. That is why this is an optional function on
69+
your module rather than a method Kivy calls on the Activity.
70+
71+
If you have no splash to dismiss, omit the function. Kivy treats its absence as
72+
the no-op it is, with no warning.
73+
74+
Rules
75+
-----
76+
77+
1. **Never import Kivy from this module.** Kivy reads its ``KIVY_*``
78+
environment and builds its configuration at import time, so importing it
79+
before the application runs would freeze that configuration before the app's
80+
``main.py`` could set it. Kivy pulls from you precisely so that you never
81+
have to import it.
82+
83+
2. **Never cache the Activity.** Android destroys and recreates the Activity on
84+
configuration changes (rotation, dark mode, locale, multi-window) and after
85+
process death. A stored instance goes stale, and holding one in a Python
86+
global pins a JNI reference to a dead Activity. Read it fresh on every call
87+
and staleness stops being your problem.
88+
89+
3. **Resolve reflection inside the call, not at import.** Resolve the Java
90+
class on first call and cache *the class* if you like, but do not do it at
91+
module import: a reflection failure would then surface from Kivy's discovery
92+
import, where it looks like a missing module rather than the real fault.
93+
94+
4. **Fail at import only with an ImportError, and only if you mean it.** Kivy
95+
treats ``ImportError`` as "this bootstrap does not implement the contract"
96+
and moves on to raise a diagnostic. Any *other* exception escaping your
97+
module's import is treated as a fault in your module and propagates
98+
unchanged, so it is not mistaken for an absent bootstrap.
99+
100+
5. **A presplash hook must not raise, and must tolerate repeat calls.**
101+
Kivy does not guard the call and makes no promise about calling it exactly
102+
once. Doing nothing is a correct outcome; raising is not.
103+
104+
6. **Hold no state beyond the resolved class.** Kivy may call these from more
105+
than one thread. A stateless module is thread-safe without a lock, and the
106+
contract is designed so that statelessness costs nothing.
107+
108+
What Kivy does with it
109+
----------------------
110+
111+
Useful when debugging your implementation:
112+
113+
* The module is imported lazily, on first use, and the outcome is cached —
114+
including failure, so a build with no such module does not pay for a failed
115+
import on every geometry read.
116+
* If the module is missing (or exposes no callable ``get_activity``) **while an
117+
Android runtime is present**, Kivy raises ``ActivityProviderMissing``. This
118+
is deliberately not caught by Kivy's display-geometry getters, which would
119+
otherwise mask a broken build as plausible-looking defaults.
120+
* ``kivy.mobile.get_app_context()`` prefers your ``get_context()`` and
121+
otherwise calls ``getApplicationContext()`` on the current Activity.
122+
* ``kivy.mobile.get_activity()`` returns exactly what your function returned,
123+
``None`` included.
124+
125+
Supporting Kivy 2.3.1 as well
126+
-----------------------------
127+
128+
Kivy 2.3.1 predates this contract. It reaches p4a's Python ``android`` module
129+
directly — ``android.remove_presplash()`` and friends — and hardcodes
130+
``org.kivy.android.PythonActivity``. A bootstrap that wants to run both Kivy
131+
2.3.1 and Kivy 3 therefore has to satisfy both: ship ``_kivy_bootstrap`` for
132+
Kivy 3, and provide the ``android`` module (and an activity of that class name)
133+
for 2.3.1. A bootstrap targeting Kivy 3 alone needs only this contract.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""python-for-android's implementation of Kivy's Android bootstrap contract.
2+
3+
Kivy 3 holds no bootstrap class name of its own: it imports ``_kivy_bootstrap``
4+
and asks it for the current ``android.app.Activity`` instead. Kivy pulls on
5+
first use rather than having the bootstrap register at startup, which suits p4a:
6+
``start.c`` runs the user's ``main.py`` as the process entry point, so there is
7+
no p4a-owned Python before the app to register from. It also means p4a never
8+
imports Kivy, which would otherwise fix Kivy's ``KIVY_*`` environment and config
9+
before the app had a chance to set them.
10+
11+
The activity class comes from the build-time generated ``android.config``, so a
12+
custom activity set with ``--activity-class-name`` is honoured rather than
13+
assumed to be the default.
14+
15+
Besides the required ``get_activity()``, this implements the contract's optional
16+
``remove_presplash()``. ``get_context()`` is not implemented: p4a's Activity can
17+
always supply the Application context, and Kivy falls back to deriving it.
18+
19+
This module deliberately holds no state beyond the resolved class: the Activity
20+
is read fresh on every call, so it stays correct across the recreation Android
21+
performs on rotation, configuration changes and process death.
22+
"""
23+
24+
from jnius import autoclass
25+
26+
from android.config import ACTIVITY_CLASS_NAME
27+
28+
_activity_class = None
29+
30+
31+
def get_activity():
32+
"""Return the current ``android.app.Activity``, or ``None`` if there is none.
33+
34+
``None`` is a legitimate answer — a p4a service runs without an Activity.
35+
"""
36+
global _activity_class
37+
if _activity_class is None:
38+
# Resolved on first use rather than at import so that reflection
39+
# failures surface from the call, not from Kivy's discovery import.
40+
_activity_class = autoclass(ACTIVITY_CLASS_NAME)
41+
return _activity_class.mActivity
42+
43+
44+
def remove_presplash():
45+
"""Dismiss the loading screen, if this build has one.
46+
47+
Kivy calls this once it has drawn its first frame — the moment only Kivy
48+
knows. How the splash goes away is p4a's business: here it means removing
49+
the View that ``PythonActivity`` laid over the app, which the Java method
50+
marshals onto the UI thread itself, so there is nothing to arrange here.
51+
52+
Not every p4a build has a splash to remove: ``service_only`` has no
53+
``removeLoadingScreen`` at all, and a custom ``--activity-class-name`` need
54+
not inherit one. So the activity is asked, rather than a hardcoded list of
55+
bootstraps consulted — that list has already drifted once, ``android``'s own
56+
``remove_presplash`` being gated to sdl2/sdl3 though the webview activity has
57+
the method too. Doing nothing is a valid outcome and Kivy treats it as one.
58+
"""
59+
activity = get_activity()
60+
if activity is None:
61+
return
62+
remove = getattr(activity, "removeLoadingScreen", None)
63+
if remove is not None:
64+
remove()

pythonforandroid/recipes/android/src/setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@
3434
version='1.0',
3535
packages=['android'],
3636
package_dir={'android': 'android'},
37+
# Top-level, not under `android`: the name is Kivy's, and Kivy imports it
38+
# without knowing which bootstrap built the app.
39+
py_modules=['_kivy_bootstrap'],
3740
ext_modules=cythonized_modules
3841
)

0 commit comments

Comments
 (0)