diff --git a/CHANGELOG.md b/CHANGELOG.md index 172a974c..44ac24bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - refactor(core): use `dpkg-query` instead of `apt` python api as loading `Cache` in `apt` is slow and use it in docker service - refactor(system): add response for docker commands and service commands - feat(lightdm): add installation options for lightdm package +- refactor(notifications): update the `NotificationWidget` when it is visible and a new notification with the same id is dispatched ## Version 0.15.9 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 190cae61..c07c1008 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -11,10 +11,6 @@ function cleanup() { trap cleanup ERR trap cleanup EXIT -perl -i -pe 's/^(packages = \[.*)$/\1\nexclude = ["ubo_app\/services\/*-voice\/models\/*"]/' pyproject.toml -poetry build -cleanup - LATEST_VERSION=$(basename $(ls -rt dist/*.whl | tail -n 1)) deps=${deps:-"False"} bootstrap=${bootstrap:-"False"} @@ -22,6 +18,10 @@ run=${run:-"False"} restart=${restart:-"False"} env=${env:-"False"} +perl -i -pe 's/^(packages = \[.*)$/\1\nexclude = ["ubo_app\/services\/*-voice\/models\/*"]/' pyproject.toml +poetry build +cleanup + function run_on_pod() { if [ $# -lt 1 ]; then echo "Usage: run_on_pod " diff --git a/scripts/test_on_device.sh b/scripts/test_on_device.sh index 5e8a5143..68046112 100755 --- a/scripts/test_on_device.sh +++ b/scripts/test_on_device.sh @@ -4,6 +4,13 @@ set -o errexit set -o pipefail set -o nounset +# Signal handler +function cleanup() { + run_on_pod "killall -9 pytest" +} +trap cleanup ERR +trap cleanup EXIT + copy=${copy:-"False"} deps=${deps:-"False"} run=${run:-"False"} diff --git a/tests/flows/test_wireless.py b/tests/flows/test_wireless.py index ed08579c..01f035fe 100644 --- a/tests/flows/test_wireless.py +++ b/tests/flows/test_wireless.py @@ -2,16 +2,11 @@ from __future__ import annotations -from dataclasses import asdict from typing import TYPE_CHECKING import pytest from tenacity import wait_fixed -from ubo_app.store.services.wifi import ( - WiFiConnection, - WiFiState, -) from ubo_app.utils import IS_RPI if TYPE_CHECKING: @@ -26,6 +21,7 @@ ) from tests.fixtures.menu import WaitForEmptyMenu, WaitForMenuItem from ubo_app.store.main import RootState + from ubo_app.store.services.wifi import WiFiState @pytest.mark.skipif(not IS_RPI, reason='Only runs on Raspberry Pi') @@ -39,42 +35,29 @@ async def test_wireless_flow( camera: MockCamera, wait_for_menu_item: WaitForMenuItem, wait_for_empty_menu: WaitForEmptyMenu, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test the wireless flow.""" + from sdbus_async.networkmanager import ( # pyright: ignore [reportMissingModuleSource] + AccessPoint, + ) + + async def strength() -> int: + return 100 + + monkeypatch.setattr( + AccessPoint, + 'strength', + property(lambda self: (self, strength())[1]), + ) + from ubo_app.menu_app.menu import MenuApp from ubo_app.store.core import ChooseMenuItemByIconEvent, ChooseMenuItemByLabelEvent from ubo_app.store.main import dispatch, store from ubo_app.store.services.keypad import Key, KeypadKeyPressAction - def store_snapshot_selector(state: RootState) -> WiFiState | None: - """Select the store snapshot.""" - wifi_state = state.wifi - - return WiFiState( - connections=[ - WiFiConnection( - **dict( - asdict(connection), - signal_strength=100 if connection.signal_strength > 0 else 0, - ), - ) - for connection in wifi_state.connections - ] - if wifi_state.connections is not None - else None, - state=wifi_state.state, - current_connection=WiFiConnection( - **dict( - asdict(wifi_state.current_connection), - signal_strength=100 - if wifi_state.current_connection.signal_strength > 0 - else 0, - ), - ) - if wifi_state.current_connection is not None - else None, - has_visited_onboarding=wifi_state.has_visited_onboarding, - ) + def store_snapshot_selector(state: RootState) -> WiFiState: + return state.wifi app = MenuApp() app_context.set_app(app) diff --git a/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc b/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc index 5f1b052a..6a8935d8 100644 --- a/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc +++ b/tests/integration/results/test_services/all_services_register/store-desktop-000.jsonc @@ -64,7 +64,7 @@ "status": "not_available" }, "service": { - "status": "not_running", + "status": "not_installed", "usernames": {} } }, @@ -80,9 +80,9 @@ "is_connected": true }, "lightdm": { - "is_active": true, - "is_enabled": true, - "is_installed": true, + "is_active": false, + "is_enabled": false, + "is_installed": false, "is_installing": false }, "main": { @@ -469,7 +469,7 @@ 1, 1 ], - "icon": "[color=#008000]󰪥[/color]", + "icon": "[color=#ffff00]󰝦[/color]", "is_short": false, "key": "lightdm", "label": "LightDM", diff --git a/ubo_app/menu_app/menu_notification_handler.py b/ubo_app/menu_app/menu_notification_handler.py index 6054152f..98c3a637 100644 --- a/ubo_app/menu_app/menu_notification_handler.py +++ b/ubo_app/menu_app/menu_notification_handler.py @@ -3,10 +3,12 @@ import functools from dataclasses import replace +from typing import TYPE_CHECKING from kivy.clock import Clock, mainthread from ubo_gui.app import UboApp from ubo_gui.constants import DANGER_COLOR, INFO_COLOR +from ubo_gui.menu.stack_item import StackApplicationItem from ubo_gui.notification import NotificationWidget from ubo_gui.page import PAGE_MAX_ITEMS @@ -14,6 +16,7 @@ from ubo_app.store.core import CloseApplicationEvent, OpenApplicationEvent from ubo_app.store.main import dispatch, subscribe_event from ubo_app.store.services.notifications import ( + Notification, NotificationActionItem, NotificationDisplayType, NotificationsClearAction, @@ -22,20 +25,32 @@ ) from ubo_app.store.services.voice import VoiceReadTextAction +if TYPE_CHECKING: + from collections.abc import Callable + + from ubo_gui.menu.menu_widget import MenuWidget + class MenuNotificationHandler(UboApp): + menu_widget: MenuWidget + @mainthread def display_notification( # noqa: C901 self: MenuNotificationHandler, event: NotificationsDisplayEvent, ) -> None: - def run_notification_action(action: NotificationActionItem) -> None: - result = action.action() - if action.dismiss_notification: - dismiss() - else: - close() - return result + if ( + event.notification.id + and any( + isinstance(stack_item, StackApplicationItem) + and isinstance(stack_item.application, NotificationWidget) + and stack_item.application.notification_id == event.notification.id + for stack_item in self.menu_widget.stack + ) + ) or event.notification.display_type is NotificationDisplayType.BACKGROUND: + return + + subscriptions = [] notification = event.notification is_closed = False @@ -46,7 +61,8 @@ def close(_: object = None) -> None: if is_closed: return is_closed = True - unsubscribe() + for unsubscribe in subscriptions: + unsubscribe() notification_application.unbind(on_close=close) dispatch(CloseApplicationEvent(application=notification_application)) if notification.dismiss_on_close: @@ -54,12 +70,70 @@ def close(_: object = None) -> None: if notification.on_close: notification.on_close() + notification_application = NotificationWidget( + notification_title=notification.title, + content=notification.content, + icon=notification.icon, + color=notification.color, + items=self._notification_items(notification, close), + title=f'Notification ({event.index + 1}/{event.count})' + if event.index is not None + else ' ', + ) + notification_application.notification_id = notification.id + + dispatch(OpenApplicationEvent(application=notification_application)) + + if notification.display_type is NotificationDisplayType.FLASH: + Clock.schedule_once(close, notification.flash_time) + + notification_application.bind(on_close=close) + + @mainthread + def clear_notification(event: NotificationsClearEvent) -> None: + if event.notification == notification: + close() + + def renew_notification(event: NotificationsDisplayEvent) -> None: + nonlocal notification + if event.notification.id == notification.id: + notification = event.notification + self._update_notification_widget(notification_application, event, close) + + subscriptions.append( + subscribe_event( + NotificationsClearEvent, + clear_notification, + ), + ) + if notification.id is not None: + subscriptions.append( + subscribe_event( + NotificationsDisplayEvent, + renew_notification, + keep_ref=False, + ), + ) + + def _notification_items( + self: MenuNotificationHandler, + notification: Notification, + close: Callable[[], None], + ) -> list[NotificationActionItem | None]: def dismiss(_: object = None) -> None: close() if not notification.dismiss_on_close: dispatch(NotificationsClearAction(notification=notification)) - items = [] + def run_notification_action(action: NotificationActionItem) -> None: + result = action.action() + if action.dismiss_notification: + dismiss() + else: + close() + return result + + items: list[NotificationActionItem | None] = [] if notification.extra_information: text = notification.extra_information.text @@ -106,32 +180,25 @@ def open_info() -> None: ), ) - items = [None] * (PAGE_MAX_ITEMS - len(items)) + items + return [None] * (PAGE_MAX_ITEMS - len(items)) + items - notification_application = NotificationWidget( - notification_title=notification.title, - content=notification.content, - icon=notification.icon, - color=notification.color, - items=items, - title=f'Notification ({event.index + 1}/{event.count})' - if event.index is not None - else ' ', + @mainthread + def _update_notification_widget( + self: MenuNotificationHandler, + notification_application: NotificationWidget, + event: NotificationsDisplayEvent, + close: Callable[[], None], + ) -> None: + notification_application.notification_title = event.notification.title + notification_application.content = event.notification.content + notification_application.icon = event.notification.icon + notification_application.color = event.notification.color + notification_application.items = self._notification_items( + event.notification, + close, ) - - dispatch(OpenApplicationEvent(application=notification_application)) - - if notification.display_type is NotificationDisplayType.FLASH: - Clock.schedule_once(close, notification.flash_time) - - notification_application.bind(on_close=close) - - @mainthread - def clear_notification(event: NotificationsClearEvent) -> None: - if event.notification == notification: - close() - - unsubscribe = subscribe_event( - NotificationsClearEvent, - clear_notification, + notification_application.title = ( + f'Notification ({event.index + 1}/{event.count})' + if event.index is not None + else ' ' ) diff --git a/ubo_app/services/010-notifications/reducer.py b/ubo_app/services/010-notifications/reducer.py index 25fd13e1..68ccb351 100644 --- a/ubo_app/services/010-notifications/reducer.py +++ b/ubo_app/services/010-notifications/reducer.py @@ -15,7 +15,6 @@ from ubo_app.store.services.audio import AudioPlayChimeAction from ubo_app.store.services.notifications import ( Importance, - NotificationDisplayType, NotificationsAction, NotificationsAddAction, NotificationsClearAction, @@ -45,11 +44,7 @@ def reducer( if isinstance(action, NotificationsAddAction): events = [] - if action.notification.display_type in ( - NotificationDisplayType.FLASH, - NotificationDisplayType.STICKY, - ): - events.append(NotificationsDisplayEvent(notification=action.notification)) + events.append(NotificationsDisplayEvent(notification=action.notification)) if action.notification in state.notifications: return CompleteReducerResult(state=state, events=events) kivy_color = get_color_from_hex(action.notification.color) @@ -130,17 +125,11 @@ def reducer( events=[NotificationsClearEvent(notification=action.notification)], ) if isinstance(action, NotificationsClearByIdAction): - to_be_removed = next( - ( - notification - for notification in state.notifications - if notification.id == action.id - ), - None, - ) - if to_be_removed is None: - return state - + to_be_removed = [ + notification + for notification in state.notifications + if notification.id == action.id + ] new_notifications = [ notification for notification in state.notifications @@ -154,7 +143,10 @@ def reducer( 1 for notification in new_notifications if not notification.is_read ), ), - events=[NotificationsClearEvent(notification=to_be_removed)], + events=[ + NotificationsClearEvent(notification=notification) + for notification in to_be_removed + ], ) if isinstance(action, NotificationsClearAllAction): return replace(state, notifications=[], unread_count=0) diff --git a/ubo_app/services/030-wifi/wifi_manager.py b/ubo_app/services/030-wifi/wifi_manager.py index fe93b0bc..d2c55e33 100644 --- a/ubo_app/services/030-wifi/wifi_manager.py +++ b/ubo_app/services/030-wifi/wifi_manager.py @@ -402,7 +402,7 @@ async def get_connections() -> list[WiFiConnection]: active_connection_state = await get_active_connection_state() active_connection_ssid = await get_active_connection_ssid() saved_ssids = await get_saved_ssids() - access_point_ssids = { + access_point_by_ssids = { ( await wait_for( i.ssid, @@ -415,9 +415,9 @@ async def get_connections() -> list[WiFiConnection]: WiFiConnection( ssid=ssid, signal_strength=await wait_for( - access_point_ssids[ssid].strength, + access_point_by_ssids[ssid].strength, ) - if ssid in access_point_ssids + if ssid in access_point_by_ssids else 0, state=active_connection_state if active_connection_ssid == ssid diff --git a/ubo_app/setup.py b/ubo_app/setup.py index 87f37cd5..a852d555 100644 --- a/ubo_app/setup.py +++ b/ubo_app/setup.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import signal +import subprocess from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -22,6 +24,44 @@ async def communicate(self: _FakeAsyncProcess) -> tuple[bytes, bytes]: return cast(bytes, self.output), b'' +original_subprocess_run = subprocess.run + + +def _fake_subprocess_run( + command: list[str], + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 +) -> object: + if any(i in command[0] for i in ('reboot', 'poweroff')): + return Fake() + return original_subprocess_run(command, *args, **kwargs) + + +original_asyncio_create_subprocess_exec = asyncio.create_subprocess_exec + + +async def _fake_create_subprocess_exec( + *_args: str, + **kwargs: Any, # noqa: ANN401 +) -> object: + command = _args[0] + args = _args[1:] + + if command == '/usr/bin/env': + command = args[0] + args = args[1:] + if isinstance(command, Path): + command = command.as_posix() + if any(i in command for i in ('reboot', 'poweroff')): + return Fake() + if command in {'curl', 'tar'} or command.endswith('/code'): + return original_asyncio_create_subprocess_exec(*args, **kwargs) + if command == 'dpkg-query': + return Fake(_Fake__return_value=Fake(_Fake__await_value=(b'', b''))) + + return await original_asyncio_create_subprocess_exec(*_args, **kwargs) + + def setup() -> None: """Set up for different environments.""" import sys @@ -38,9 +78,6 @@ def setup() -> None: from ubo_app.utils import IS_RPI if not IS_RPI: - import asyncio - import subprocess - sys.modules['adafruit_rgb_display.st7789'] = Fake() sys.modules['alsaaudio'] = Fake() sys.modules['apt'] = Fake() @@ -62,41 +99,9 @@ def setup() -> None: ), }, ) - original_subprocess_run = subprocess.run - - def fake_subprocess_run( - command: list[str], - *args: Any, # noqa: ANN401 - **kwargs: Any, # noqa: ANN401 - ) -> object: - if any(i in command[0] for i in ('reboot', 'poweroff')): - return Fake() - return original_subprocess_run(command, *args, **kwargs) - - subprocess.run = fake_subprocess_run - - async def fake_create_subprocess_exec( - *_args: str, - **kwargs: Any, # noqa: ANN401 - ) -> object: - command = _args[0] - args = _args[1:] - - if command == '/usr/bin/env': - command = args[0] - args = args[1:] - if isinstance(command, Path): - command = command.as_posix() - if any(i in command for i in ('reboot', 'poweroff')): - return Fake() - if command in {'curl', 'tar'} or command.endswith('/code'): - return original_asyncio_create_subprocess_exec(*args, **kwargs) - - return await original_asyncio_create_subprocess_exec(*_args, **kwargs) - - original_asyncio_create_subprocess_exec = asyncio.create_subprocess_exec - - asyncio.create_subprocess_exec = fake_create_subprocess_exec + subprocess.run = _fake_subprocess_run + + asyncio.create_subprocess_exec = _fake_create_subprocess_exec asyncio.open_unix_connection = ( Fake(_Fake__return_value=Fake(_Fake__await_value=(Fake(), Fake()))),