Skip to content

Commit ef72121

Browse files
committed
backend: Add new Android backend for usage with Chaquopy / briefcase
1 parent 58c6b6f commit ef72121

42 files changed

Lines changed: 4479 additions & 504 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build_and_test.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,80 @@ jobs:
106106
report_type: test_results
107107
flags: bluez-integration-py${{ matrix.python-version }}
108108
token: ${{ secrets.CODECOV_TOKEN }}
109+
110+
integration_tests_android:
111+
name: "Android integration tests"
112+
runs-on: ubuntu-24.04
113+
strategy:
114+
fail-fast: false
115+
matrix:
116+
python-version: [
117+
"3.13",
118+
# some binary dependencies of bumble are not yet available for 3.14, like cryptography or aiohttp
119+
# "3.14"
120+
]
121+
env:
122+
FORCE_COLOR: "1"
123+
steps:
124+
- uses: actions/checkout@v4
125+
126+
- name: Install the latest version of uv
127+
uses: astral-sh/setup-uv@v6
128+
with:
129+
version: "latest"
130+
python-version: ${{ matrix.python-version }}
131+
activate-environment: true
132+
133+
- name: Setup Environment
134+
run: |
135+
# Use GitHub's preinstalled JDK 17 for Android builds
136+
echo JAVA_HOME="${JAVA_HOME_17_X64:-$JAVA_HOME_17_arm64}" | tee -a ${GITHUB_ENV}
137+
# Enable KVM permissions for the emulator
138+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
139+
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
140+
sudo udevadm control --reload-rules
141+
sudo udevadm trigger --name-match=kvm
142+
143+
- name: Cache build dependencies
144+
uses: actions/cache@v4
145+
with:
146+
path: |
147+
~/.cache/briefcase
148+
~/.gradle/wrapper/dists
149+
~/.gradle/caches
150+
/usr/local/lib/android/sdk/cmdline-tools/19.0
151+
/usr/local/lib/android/sdk/emulator
152+
/usr/local/lib/android/sdk/system-images/android-31
153+
key: build-deps-${{ runner.os }}-${{ hashFiles('testbed/pyproject.toml') }}
154+
restore-keys: |
155+
build-deps-${{ runner.os }}-
156+
157+
- name: Run App
158+
run: uv run testbed/run_android_tests_emulator.py --ci
159+
160+
- name: Upload coverage reports to Codecov
161+
uses: codecov/codecov-action@v5
162+
with:
163+
report_type: coverage
164+
flags: android-integration-py${{ matrix.python-version }}
165+
token: ${{ secrets.CODECOV_TOKEN }}
166+
167+
- name: Upload test results to Codecov
168+
if: ${{ !cancelled() }}
169+
uses: codecov/codecov-action@v5
170+
with:
171+
report_type: test_results
172+
flags: android-integration-py${{ matrix.python-version }}
173+
token: ${{ secrets.CODECOV_TOKEN }}
174+
175+
- name: Type checking
176+
# We don't do this in the lint job because we have conditionals
177+
# on both the platform and the Python version in the code, so we
178+
# need to check each matrix combination.
179+
run: |
180+
# For pyright a new release is missing that includes https://github.com/microsoft/pyright/pull/11221
181+
# When building pyright from main locally, it works.
182+
# uv tool install pyright
183+
# uv run pyright --project pyrightconfig-android.json
184+
uv tool install mypy
185+
uv run mypy --config-file=mypy-android.ini

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ nosetests.xml
5151
coverage.xml
5252
*,cover
5353
.hypothesis/
54+
junit.xml
5455

5556
# Integration test VM
5657
.alpine-vm-build/

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Added
2828
* Added ``bleak.exc.BleakGATTProtocolError`` and ``bleak.exc.BleakGATTProtocolErrorCode`` classes.
2929
* Added type hints and documentation for ``use_cached`` kwarg for ``read_gatt_char()`` and ``read_gatt_descriptor()`` methods in ``BleakClient``.
3030
* Added support for ``"use_cached"`` kwarg to ``read_gatt_char()`` and ``read_gatt_descriptor()`` methods in BlueZ backend.
31+
* Added new Android backend using Chaquopy/briefcase.
3132

3233
Changed
3334
-------

bleak/backends/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class BleakBackend(str, enum.Enum):
2727
Python for Android backend.
2828
"""
2929

30+
ANDROID = "android"
31+
"""
32+
Android backend using chaquopy.
33+
"""
34+
3035
BLUEZ_DBUS = "bluez_dbus"
3136
"""
3237
BlueZ D-Bus backend for Linux.
@@ -57,6 +62,9 @@ def get_default_backend() -> BleakBackend:
5762
if os.environ.get("P4A_BOOTSTRAP") is not None:
5863
return BleakBackend.P4ANDROID
5964

65+
if sys.platform == "android":
66+
return BleakBackend.ANDROID
67+
6068
if platform.system() == "Linux":
6169
return BleakBackend.BLUEZ_DBUS
6270

bleak/backends/android/__init__.py

Whitespace-only changes.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
if sys.platform != "android":
8+
assert False, "This backend is only available on Android"
9+
10+
import logging
11+
from typing import Callable
12+
13+
from android.content import BroadcastReceiver as _BroadcastReceiver
14+
from android.content import Context, Intent, IntentFilter
15+
from android.os import Handler, HandlerThread
16+
from java import Override, jvoid, static_proxy
17+
18+
from bleak.backends._utils import external_thread_callback
19+
from bleak.backends.android.utils import context
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
# Copied BroadcastReceiver logic from python-for-android and adapted it to chaquopy.
25+
# See https://github.com/kivy/python-for-android/blob/6f3ab805972e0d9531e3a207a6bc51c0effd8eb9/pythonforandroid/recipes/android/src/android/broadcast.py
26+
class BroadcastReceiver(static_proxy(_BroadcastReceiver)): # type: ignore[misc]
27+
def __init__(
28+
self,
29+
callback: Callable[[Context, Intent], None],
30+
actions: list[str] | None = None,
31+
categories: list[str] | None = None,
32+
):
33+
super(BroadcastReceiver, self).__init__()
34+
self.context = context
35+
self.callback = callback
36+
37+
if not actions and not categories:
38+
raise Exception("You need to define at least actions or categories")
39+
40+
def _expand_partial_name(partial_name: str):
41+
if "." in partial_name:
42+
return partial_name # Its actually a full dotted name
43+
else:
44+
name = "ACTION_{}".format(partial_name.upper())
45+
if not hasattr(Intent, name):
46+
raise Exception("The intent {} does not exist".format(name))
47+
return getattr(Intent, name)
48+
49+
# resolve actions/categories first
50+
resolved_actions = [_expand_partial_name(x) for x in actions or []]
51+
resolved_categories = [_expand_partial_name(x) for x in categories or []]
52+
53+
# create a thread for handling events from the receiver
54+
self.handlerthread = HandlerThread("handlerthread")
55+
56+
# create a listener
57+
self.receiver_filter = IntentFilter()
58+
for x in resolved_actions:
59+
self.receiver_filter.addAction(x)
60+
for x in resolved_categories:
61+
self.receiver_filter.addCategory(x)
62+
63+
def start(self):
64+
self.handlerthread.start()
65+
self.handler = Handler(self.handlerthread.getLooper())
66+
self.context.registerReceiver(
67+
self,
68+
self.receiver_filter,
69+
None, # type: ignore
70+
self.handler,
71+
)
72+
73+
def stop(self):
74+
self.context.unregisterReceiver(self)
75+
self.handlerthread.quit()
76+
77+
@Override(jvoid, [Context, Intent])
78+
@external_thread_callback
79+
def onReceive(self, context: Context, intent: Intent):
80+
logger.debug(f"BroadcastReceiver received intent: {intent}")
81+
self.callback(context, intent)

0 commit comments

Comments
 (0)