Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ef72121
backend: Add new Android backend for usage with Chaquopy / briefcase
timrid Apr 1, 2026
108936f
docs: Remove reference to python for android from android backend
timrid Apr 3, 2026
b0cc712
docs: Fixed typo
timrid Apr 4, 2026
47c29e9
docs: change .md to .rst
timrid Apr 4, 2026
f78ae29
tests: fixed typo
timrid Apr 4, 2026
d06c576
android: Add missing check
timrid Apr 3, 2026
c07b7fd
android: flushPendingScanResults is not required
timrid Apr 3, 2026
163cf5e
android: Add missing permission check, when not using a Scanner
timrid Apr 3, 2026
c9cebb3
android: ParamSpec is available since python 3.10
timrid Apr 4, 2026
a7b4277
android: Refactor error handling in dispatcher by logging it only and…
timrid Apr 4, 2026
fb08ad8
android: Dont use deprecated api on onDescriptorRead and API Level 33…
timrid Apr 4, 2026
690ff29
android: Fix mtu variable
timrid Apr 4, 2026
c77ba1d
android: Update writeCharacteristic and writeDescriptor handling for …
timrid Apr 4, 2026
d02d8c0
android: Add None check for `getBluetoothLeScanner()`.
timrid Apr 4, 2026
947ee57
android: cleanup future after it is awaited
timrid Apr 4, 2026
1159757
android: Dont request ACCESS_BACKGROUND_LOCATION permission by defaul…
timrid Apr 6, 2026
17f1674
android: Improve error handling in `start_notify()`
timrid Apr 6, 2026
2f93c31
android: Check gatt explicitly
timrid Apr 6, 2026
e81c940
android: Notifications and indications should not be used together.
timrid Apr 6, 2026
4b87b46
android: Handle scanner state reset on startScan failure
timrid Apr 6, 2026
f86e17c
android: Make Android CI work again, by using skinless emulator.
timrid Apr 7, 2026
ca3520d
android: Update Toga dependencies to specific commit for stability
timrid Apr 7, 2026
389c7f2
android: Ignore notifications for unsubscribed handles in Bluetooth G…
timrid Apr 7, 2026
b52bb29
android: Add an early serial port check to prevent to long waiting ti…
timrid Apr 7, 2026
8d8b115
android: Improve permission denial exception message.
timrid Apr 7, 2026
e5d5d99
docs: Update android docs to clarify Python version requirement and B…
timrid Apr 7, 2026
7e39ba3
android: Disable snapshots for emulator to ensure a clean start in in…
timrid Apr 19, 2026
dd0f21a
android: remove unnecessary try-except blocks
timrid Apr 19, 2026
9a1ffe5
docs: Wrap after approx 80 characters
timrid Apr 19, 2026
d106ca3
docs: renamed android-beeware.rst to android.rst
timrid Apr 19, 2026
0a8e262
docs: update warning message
timrid Apr 19, 2026
987aeeb
docs: clarify reason for skipping pairing tests on Android backend
timrid Apr 19, 2026
edf337a
android: update mypy android config to 3.13
timrid Apr 19, 2026
177a0ff
docs: fix toc after file rename
timrid Apr 19, 2026
fa9e237
ci: run integration tests on android api level 31 and 33
timrid Apr 19, 2026
4a5e56e
ci: fix pyright error that was introduces with pyright v1.1.409
timrid Apr 24, 2026
92a7849
ci: update KVM permissions comment for Android emulator setup
timrid Apr 25, 2026
9488372
Merge commit '058c4e274c58e99d1d3dfdd87f7d8f1bd0fb2321' into android-…
timrid May 2, 2026
6d63bb5
tests: fix issue 1885 test on Android
timrid Apr 25, 2026
bc8b83a
android: simplify dispatcher
timrid May 2, 2026
baefc3b
android: remove unnecessary type conversion
timrid May 2, 2026
6864668
tests: remove unnecessary testbed files
timrid May 2, 2026
f3ee7e5
tests: add comment what the testbed does
timrid May 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:
export FORCE_COLOR=1
export CI=true
export GITHUB_ACTIONS=true
uv run --python ${{ matrix.python-version }} pytest -v tests --bleak-bluez-vhci -v --cov-report=xml --cov-report=html:../.htmlcov --junitxml=junit.xml -o junit_family=legacy -o cache_dir=../.pytest_cache
uv run --python ${{ matrix.python-version }} --no-dev --group test pytest -v tests --bleak-bluez-vhci -v --cov-report=xml --cov-report=html:../.htmlcov --junitxml=junit.xml -o junit_family=legacy -o cache_dir=../.pytest_cache
'

- name: Upload coverage reports to Codecov
Expand All @@ -108,3 +108,83 @@ jobs:
report_type: test_results
flags: bluez-integration-py${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}

integration_tests_android:
name: "Android integration tests"
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python-version: [
"3.13",
# some binary dependencies of bumble are not yet available for 3.14, like cryptography or aiohttp
# "3.14"
]
api-level: [31, 33]
env:
FORCE_COLOR: "1"
steps:
- uses: actions/checkout@v4

- name: Install the latest version of uv
uses: astral-sh/setup-uv@v6
with:
version: "latest"
python-version: ${{ matrix.python-version }}
activate-environment: true

- name: Setup Environment
run: |
# Use GitHub's preinstalled JDK 17 for Android builds
echo JAVA_HOME="${JAVA_HOME_17_X64:-$JAVA_HOME_17_arm64}" | tee -a ${GITHUB_ENV}
# Enable KVM permissions for the Android emulator as recommended in
# https://github.blog/changelog/2024-04-02-github-actions-hardware-accelerated-android-virtualization-now-available/
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Cache build dependencies
uses: actions/cache@v4
with:
path: |
~/.cache/briefcase
~/.gradle/wrapper/dists
~/.gradle/caches
/usr/local/lib/android/sdk/cmdline-tools/19.0
/usr/local/lib/android/sdk/emulator
/usr/local/lib/android/sdk/system-images/android-${{ matrix.api-level }}
key: build-deps-${{ runner.os }}-api${{ matrix.api-level }}-${{ hashFiles('testbed/pyproject.toml') }}
restore-keys: |
build-deps-${{ runner.os }}-api${{ matrix.api-level }}-

- name: Run App
run: uv run testbed/run_android_tests_emulator.py --ci --api-level ${{ matrix.api-level }}

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
report_type: coverage
flags: android-integration-py${{ matrix.python-version }}-api${{ matrix.api-level }}
token: ${{ secrets.CODECOV_TOKEN }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v5
with:
report_type: test_results
flags: android-integration-py${{ matrix.python-version }}-api${{ matrix.api-level }}
token: ${{ secrets.CODECOV_TOKEN }}

- name: Type checking
# We don't do this in the lint job because we have conditionals
# on both the platform and the Python version in the code, so we
# need to check each matrix combination.
run: |
# For pyright a new release is missing that includes https://github.com/microsoft/pyright/pull/11221
# When building pyright from main locally, it works.
# uv tool install pyright
# uv run pyright --project pyrightconfig-android.json
uv tool install mypy
uv run mypy --config-file=mypy-android.ini
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
junit.xml

# Integration test VM
.alpine-vm-build/
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Added
* Added ``bleak.exc.BleakGATTProtocolError`` and ``bleak.exc.BleakGATTProtocolErrorCode`` classes.
* Added type hints and documentation for ``use_cached`` kwarg for ``read_gatt_char()`` and ``read_gatt_descriptor()`` methods in ``BleakClient``.
* Added support for ``"use_cached"`` kwarg to ``read_gatt_char()`` and ``read_gatt_descriptor()`` methods in BlueZ backend.
* Added new Android backend using Chaquopy/briefcase.

Changed
-------
Expand Down
8 changes: 8 additions & 0 deletions bleak/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class BleakBackend(str, enum.Enum):
Python for Android backend.
"""

ANDROID = "android"
"""
Android backend using chaquopy.
"""

BLUEZ_DBUS = "bluez_dbus"
"""
BlueZ D-Bus backend for Linux.
Expand Down Expand Up @@ -57,6 +62,9 @@ def get_default_backend() -> BleakBackend:
if os.environ.get("P4A_BOOTSTRAP") is not None:
return BleakBackend.P4ANDROID

if sys.platform == "android":
return BleakBackend.ANDROID

if platform.system() == "Linux":
return BleakBackend.BLUEZ_DBUS

Expand Down
Empty file.
81 changes: 81 additions & 0 deletions bleak/backends/android/broadcast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

import sys
from typing import TYPE_CHECKING

if TYPE_CHECKING:
if sys.platform != "android":
assert False, "This backend is only available on Android"

import logging
from typing import Callable

from android.content import BroadcastReceiver as _BroadcastReceiver
from android.content import Context, Intent, IntentFilter
from android.os import Handler, HandlerThread
from java import Override, jvoid, static_proxy

from bleak.backends._utils import external_thread_callback
from bleak.backends.android.utils import context

logger = logging.getLogger(__name__)


# Copied BroadcastReceiver logic from python-for-android and adapted it to chaquopy.
# See https://github.com/kivy/python-for-android/blob/6f3ab805972e0d9531e3a207a6bc51c0effd8eb9/pythonforandroid/recipes/android/src/android/broadcast.py
class BroadcastReceiver(static_proxy(_BroadcastReceiver)): # type: ignore[misc]
def __init__(
self,
callback: Callable[[Context, Intent], None],
actions: list[str] | None = None,
categories: list[str] | None = None,
):
super(BroadcastReceiver, self).__init__()
self.context = context
self.callback = callback

if not actions and not categories:
raise Exception("You need to define at least actions or categories")

def _expand_partial_name(partial_name: str):
if "." in partial_name:
return partial_name # Its actually a full dotted name
else:
name = "ACTION_{}".format(partial_name.upper())
if not hasattr(Intent, name):
raise Exception("The intent {} does not exist".format(name))
return getattr(Intent, name)

# resolve actions/categories first
resolved_actions = [_expand_partial_name(x) for x in actions or []]
resolved_categories = [_expand_partial_name(x) for x in categories or []]

# create a thread for handling events from the receiver
self.handlerthread = HandlerThread("handlerthread")

# create a listener
self.receiver_filter = IntentFilter()
for x in resolved_actions:
self.receiver_filter.addAction(x)
for x in resolved_categories:
self.receiver_filter.addCategory(x)

def start(self):
self.handlerthread.start()
self.handler = Handler(self.handlerthread.getLooper())
self.context.registerReceiver(
self,
self.receiver_filter,
None, # type: ignore
self.handler,
)

def stop(self):
self.context.unregisterReceiver(self)
self.handlerthread.quit()

@Override(jvoid, [Context, Intent])
@external_thread_callback
def onReceive(self, context: Context, intent: Intent):
logger.debug(f"BroadcastReceiver received intent: {intent}")
self.callback(context, intent)
Loading
Loading