feat(android): on-device OS notification capture (NotificationListenerService)

Add an Android backend to os_notification_listener.py so notifications on the
experimental Android-TV build drive the existing NotificationColorStripSource
LED effects (flash/pulse/sweep, per-app colors + sounds) at app-name parity
with the Windows/Linux backends.

A Kotlin NotificationListenerService forwards the posting app's display label
across the Chaquopy JNI boundary into a new push-based _AndroidBackend +
module-level push_notification() receiver; the existing color-strip pipeline,
per-app colors/filters, and history endpoint are reused unchanged.

- Python: _AndroidBackend (probed first), push_notification() receiver,
  _LinuxBackend.probe() hardened with is_linux() to exclude Android (which
  also reports platform.system() == "Linux").
- Android: LedGrabNotificationListener NLS — serial single-thread executor,
  full crash isolation around Python.getInstance(), label-only forwarding
  (never notification title/body), ongoing/group-summary/self-package noise
  filtering. Manifest service exported + gated by
  BIND_NOTIFICATION_LISTENER_SERVICE (no new uses-permission).
- UX: prompt-once notification-access + manual "Grant notification access"
  button wired into the D-pad focus chain (computed from visible controls);
  en/ru/zh strings.
- Tests: 11 isolated unit tests — module-global + tmp_path history isolation,
  push routing contract, callback-exception swallowing, None app-name, and a
  desktop-regression lock on backend selection order.
- Docs: README OS-support Android column (notification + audio cells),
  ANDROID-REVIEW status flipped to Implemented.

Zero new Python deps; no build.gradle.kts / Chaquopy pip changes.
This commit is contained in:
2026-06-02 11:47:13 +03:00
parent 4b2e8fc5ec
commit 0be3f833df
11 changed files with 532 additions and 23 deletions
@@ -0,0 +1,237 @@
"""Tests for the Android push-based notification backend.
These run on desktop CI (no Android device needed): ``is_android`` is
monkeypatched and the app label is pushed directly into the module-level
``push_notification`` receiver, exactly as the Kotlin
``NotificationListenerService`` would across the Chaquopy bridge.
Isolation (critical): the listener keeps process-global state
(``_android_target``, ``_instance``) and persists history to a hardcoded
``data/notification_history.json``. Every test resets those globals and
repoints ``_HISTORY_FILE`` to ``tmp_path`` so the suite never leaks state
between tests or clobbers the real repo data file.
"""
from datetime import datetime, timezone
import pytest
import ledgrab.core.processing.os_notification_listener as nl
from ledgrab.storage.color_strip_source import NotificationColorStripSource
PLATFORM_MOD = "ledgrab.utils.platform"
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _FakeStream:
"""Stub NotificationColorStripStream — records fire() calls."""
def __init__(self, accept: bool = True):
self._accept = accept
self.fired_with: list = []
def fire(self, app_name=None) -> bool:
self.fired_with.append(app_name)
return self._accept
class _FakeStore:
def __init__(self, sources):
self._sources = sources
def get_all_sources(self):
return list(self._sources)
class _FakeStreamManager:
def __init__(self, streams):
self._streams = streams
def get_streams_by_source_id(self, source_id):
return list(self._streams)
def _notif_source(
*, source_id: str = "css_test", os_listener: bool = True
) -> NotificationColorStripSource:
now = datetime.now(timezone.utc)
return NotificationColorStripSource.create_from_kwargs(
id=source_id,
name="Test Notification Source",
source_type="notification",
created_at=now,
updated_at=now,
os_listener=os_listener,
)
# ---------------------------------------------------------------------------
# Fixtures — module-global + disk isolation
# ---------------------------------------------------------------------------
@pytest.fixture
def nl_mod(monkeypatch, tmp_path):
"""Reset module globals and repoint the history file to tmp_path.
``monkeypatch.setattr`` auto-restores originals on teardown, so even though
``start()``/``stop()`` rebind ``_android_target`` and ``_instance`` during a
test, the globals are returned to their pre-test values afterward — no
cross-test leakage and no write to the real repo ``data/`` dir.
"""
monkeypatch.setattr(nl, "_android_target", None)
monkeypatch.setattr(nl, "_instance", None)
monkeypatch.setattr(nl, "_HISTORY_FILE", tmp_path / "notification_history.json")
return nl
# ---------------------------------------------------------------------------
# _AndroidBackend.probe()
# ---------------------------------------------------------------------------
def test_probe_true_on_android(nl_mod, monkeypatch):
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: True)
assert nl_mod._AndroidBackend.probe() is True
def test_probe_false_on_desktop(nl_mod, monkeypatch):
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: False)
assert nl_mod._AndroidBackend.probe() is False
# ---------------------------------------------------------------------------
# push_notification() routing contract
# ---------------------------------------------------------------------------
def test_push_is_noop_before_start(nl_mod):
# _android_target is None → no callback, no exception.
nl_mod.push_notification("Telegram") # must not raise
def test_push_routes_after_start_and_stops_after_stop(nl_mod):
received: list = []
backend = nl_mod._AndroidBackend(on_notification=received.append)
backend.start()
nl_mod.push_notification("Telegram")
assert received == ["Telegram"]
backend.stop()
nl_mod.push_notification("Signal") # no-op after stop
assert received == ["Telegram"]
def test_push_swallows_callback_exception(nl_mod):
def boom(_app):
raise RuntimeError("callback exploded")
nl_mod._AndroidBackend(on_notification=boom).start()
# JNI entry point must never propagate — would crash the bound service.
nl_mod.push_notification("X")
# ---------------------------------------------------------------------------
# Integration — start() selects Android, push fires the stream + records history
# ---------------------------------------------------------------------------
def test_android_selected_push_fires_stream_and_records_history(nl_mod, monkeypatch, tmp_path):
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: True)
stream = _FakeStream(accept=True)
listener = nl_mod.OsNotificationListener(
_FakeStore([_notif_source(os_listener=True)]),
_FakeStreamManager([stream]),
)
listener.start()
assert listener.available is True # flips True on backend selection, not on push
nl_mod.push_notification("Telegram")
assert stream.fired_with == ["Telegram"]
assert listener.recent_history[0]["app"] == "Telegram"
assert listener.recent_history[0]["fired"] == 1
# history written under tmp_path — never the repo data/ dir
assert nl_mod._HISTORY_FILE.exists()
assert nl_mod._HISTORY_FILE.parent == tmp_path
listener.stop()
def test_push_with_none_app_name_is_recorded(nl_mod, monkeypatch):
# The Windows (_extract_app_name) and Linux D-Bus paths can yield None;
# the Android path falls back to the package name, but None must still be
# handled end-to-end without raising.
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: True)
stream = _FakeStream(accept=True)
listener = nl_mod.OsNotificationListener(
_FakeStore([_notif_source(os_listener=True)]),
_FakeStreamManager([stream]),
)
listener.start()
nl_mod.push_notification(None)
assert stream.fired_with == [None]
assert listener.recent_history[0]["app"] is None
listener.stop()
def test_get_os_notification_listener_tracks_started_instance(nl_mod, monkeypatch):
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: True)
assert nl_mod.get_os_notification_listener() is None
listener = nl_mod.OsNotificationListener(_FakeStore([]), _FakeStreamManager([]))
listener.start()
assert nl_mod.get_os_notification_listener() is listener
listener.stop()
def test_source_with_os_listener_off_does_not_fire(nl_mod, monkeypatch):
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: True)
stream = _FakeStream()
listener = nl_mod.OsNotificationListener(
_FakeStore([_notif_source(os_listener=False)]),
_FakeStreamManager([stream]),
)
listener.start()
nl_mod.push_notification("Telegram")
assert stream.fired_with == [] # os_listener=False → skipped
listener.stop()
# ---------------------------------------------------------------------------
# Desktop regression — the probe-order change must not alter desktop selection
# ---------------------------------------------------------------------------
def test_android_probe_false_on_real_desktop(nl_mod, monkeypatch):
# With is_android() False, the new first-in-tuple backend must not be selectable.
monkeypatch.setattr(f"{PLATFORM_MOD}.is_android", lambda: False)
assert nl_mod._AndroidBackend.probe() is False
def test_desktop_selection_unchanged_windows_wins(nl_mod, monkeypatch):
# Deterministically control probes and stub start() so no real polling thread spawns.
# Order under test is (_AndroidBackend, _WindowsBackend, _LinuxBackend): Android skipped,
# Windows is the first True → it must be the selected backend, exactly as before.
monkeypatch.setattr(nl_mod._AndroidBackend, "probe", staticmethod(lambda: False))
monkeypatch.setattr(nl_mod._WindowsBackend, "probe", staticmethod(lambda: True))
monkeypatch.setattr(nl_mod._LinuxBackend, "probe", staticmethod(lambda: False))
started: list = []
monkeypatch.setattr(nl_mod._WindowsBackend, "start", lambda self: started.append("win"))
listener = nl_mod.OsNotificationListener(_FakeStore([]), _FakeStreamManager([]))
listener.start()
assert listener.available is True
assert isinstance(listener._backend, nl_mod._WindowsBackend)
assert started == ["win"]