diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74f78c1836..dd5903c47f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -403,6 +403,7 @@ jobs: with: name: testbed-failure-logs-${{ matrix.backend }} path: testbed/logs/* + include-hidden-files: true - name: Copy App Generated User Data if: failure() && matrix.backend != 'android' diff --git a/changes/2979.feature.rst b/changes/2979.feature.rst new file mode 100644 index 0000000000..779ca2a14b --- /dev/null +++ b/changes/2979.feature.rst @@ -0,0 +1 @@ +Location support is now available on Windows diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index dc5266a5f9..e82aa34458 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -30,7 +30,7 @@ ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that ca SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,,, OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,|y|,|y|,, Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|y|,,,|y|,|y|,, -Location,Hardware,:class:`~toga.hardware.location.Location`,A sensor that can capture the geographical location of the device.,|y|,|y|,,|y|,|y|,, +Location,Hardware,:class:`~toga.hardware.location.Location`,A sensor that can capture the geographical location of the device.,|y|,|y|,|y|,|y|,|y|,, Screen,Hardware,:class:`~toga.screens.Screen`,A representation of a screen attached to a device.,|y|,|y|,|y|,|y|,|y|,|b|,|b| App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|,,|b| Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,, diff --git a/testbed/tests/hardware/test_location.py b/testbed/tests/hardware/test_location.py index 3f0703351a..8420a6ecf0 100644 --- a/testbed/tests/hardware/test_location.py +++ b/testbed/tests/hardware/test_location.py @@ -8,7 +8,7 @@ from .probe import list_probes -@pytest.fixture(params=list_probes("location", skip_platforms=("windows",))) +@pytest.fixture(params=list_probes("location")) async def location_probe(monkeypatch, app_probe, request): probe_cls = request.param probe = probe_cls(monkeypatch, app_probe) diff --git a/testbed/tests/testbed.py b/testbed/tests/testbed.py index 857eaaf05a..89999879b2 100644 --- a/testbed/tests/testbed.py +++ b/testbed/tests/testbed.py @@ -139,11 +139,17 @@ def run_tests(app, cov, args, report_coverage, run_slow, running_in_ci): "win32": "toga_winforms", }.get(sys.platform) + if "CI" in os.environ: + data_file = ( + Path(os.environ["GITHUB_WORKSPACE"]) / "testbed" / "logs" / ".coverage" + ) + else: + data_file = None + # Start coverage tracking. # This needs to happen in the main thread, before the app has been created cov = coverage.Coverage( - # Don't store any coverage data - data_file=None, + data_file=data_file, branch=True, source_pkgs=[toga_backend], ) diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index de24bc1f62..e81adf5f9d 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -4,6 +4,7 @@ from .app import App from .command import Command from .fonts import Font +from .hardware.location import Location from .icons import Icon from .images import Image from .paths import Paths @@ -61,6 +62,7 @@ def not_implemented(feature): "Divider", "ImageView", "Label", + "Location", "MapView", "MultilineTextInput", "NumberInput", diff --git a/winforms/src/toga_winforms/hardware/__init__.py b/winforms/src/toga_winforms/hardware/__init__.py index e69de29bb2..ca55608a49 100644 --- a/winforms/src/toga_winforms/hardware/__init__.py +++ b/winforms/src/toga_winforms/hardware/__init__.py @@ -0,0 +1,3 @@ +import clr + +clr.AddReference("System.Device") diff --git a/winforms/src/toga_winforms/hardware/location.py b/winforms/src/toga_winforms/hardware/location.py new file mode 100644 index 0000000000..c851524d3c --- /dev/null +++ b/winforms/src/toga_winforms/hardware/location.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from System import EventHandler +from System.Device.Location import ( + GeoCoordinate, + GeoCoordinateWatcher, + GeoPositionAccuracy, + GeoPositionChangedEventArgs, + GeoPositionPermission, +) + +from toga import LatLng +from toga.handlers import AsyncResult + + +def toga_location(location: GeoCoordinate): + """Convert a GeoCoordinate into a Toga LatLng and altitude.""" + + if location.IsUnknown: + return None + + return { + "location": LatLng(location.Latitude, location.Longitude), + "altitude": location.Altitude, + } + + +class Location: + def __init__(self, interface): + self.interface = interface + self.watcher = GeoCoordinateWatcher(GeoPositionAccuracy.Default) + self._handler = EventHandler[GeoPositionChangedEventArgs[GeoCoordinate]]( + self._position_changed + ) + self._tracking = False + self._has_background_permission = False + self.watcher.OnPropertyChanged = self._property_changed + + def _property_changed(self, property_name: str): + if property_name == "Permission": + # TODO: handle permission changes + print("PERMISSION CHANGED", self.watcher.Permission) + + def _position_changed( + self, + sender: GeoCoordinateWatcher, + event: GeoPositionChangedEventArgs[GeoCoordinate], + ): + location = toga_location(event.Position.Location) + if location: + self.interface.on_change(**location) + + def has_permission(self): + return self.watcher.Permission == GeoPositionPermission.Granted + + def has_background_permission(self): + return self._has_background_permission + + @contextmanager + def context(self): + if not self._tracking: + self.watcher.Start(False) + try: + yield + finally: + if not self._tracking: # don't want to stop if we're tracking + self.watcher.Stop() + + def request_permission(self, future: AsyncResult[bool]) -> None: + with self.context(): + future.set_result(self.has_permission()) + + def request_background_permission(self, future: AsyncResult[bool]) -> None: + if not self.has_permission(): + raise PermissionError() + future.set_result(True) + self._has_background_permission = True + + def current_location(self, result: AsyncResult[dict]) -> None: + def cb(sender, event): + if ( + event.Position.Location.IsUnknown + or event.Position.Location.HorizontalAccuracy > 100 + ): + return + self.watcher.remove_PositionChanged(cb) + loco = toga_location(event.Position.Location) + result.set_result(loco["location"] if loco else None) + ctx.__exit__() + + ctx = self.context() + + ctx.__enter__() + + self.watcher.add_PositionChanged( + EventHandler[GeoPositionChangedEventArgs[GeoCoordinate]](cb) + ) + + def start_tracking(self) -> None: + self.watcher.Start() + self.watcher.add_PositionChanged(self._handler) + self._tracking = True + + def stop_tracking(self) -> None: + self.watcher.Stop() + self.watcher.remove_PositionChanged(self._handler) + self._tracking = False diff --git a/winforms/tests_backend/hardware/__init__.py b/winforms/tests_backend/hardware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/winforms/tests_backend/hardware/hardware.py b/winforms/tests_backend/hardware/hardware.py new file mode 100644 index 0000000000..9516e574b8 --- /dev/null +++ b/winforms/tests_backend/hardware/hardware.py @@ -0,0 +1,9 @@ +from ..app import AppProbe + + +class HardwareProbe(AppProbe): + + def __init__(self, monkeypatch, app_probe): + super().__init__(app_probe.app) + + self.monkeypatch = monkeypatch diff --git a/winforms/tests_backend/hardware/location.py b/winforms/tests_backend/hardware/location.py new file mode 100644 index 0000000000..8eccc6e8b6 --- /dev/null +++ b/winforms/tests_backend/hardware/location.py @@ -0,0 +1,74 @@ +from unittest.mock import Mock + +from pytest import xfail +from System.Device.Location import ( + GeoCoordinate, + GeoCoordinateWatcher, + GeoPositionPermission, +) + +from toga.types import LatLng + +from .hardware import HardwareProbe + + +class LocationProbe(HardwareProbe): + supports_background_permission = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.app.location._impl.watcher = Mock(spec=GeoCoordinateWatcher) + self.reset_locations() + + def cleanup(self): + # Delete the location service instance. This ensures that a freshly mocked + # LocationManager is installed for each test. + try: + del self.app._location + except AttributeError: + pass + + def allow_permission(self): + self.app.location._impl.watcher.Permission = GeoPositionPermission.Granted + + def grant_permission(self): + self.app.location._impl.watcher.Permission = GeoPositionPermission.Granted + + def reject_permission(self): + self.app.location._impl.watcher.Permission = GeoPositionPermission.Denied + + def add_location(self, location: LatLng, altitude, cached=False): + m = Mock(spec=GeoCoordinate) + m.Position = Mock() + m.Position.Location = Mock() + m.Position.Location.IsUnknown = False + m.Position.Location.Latitude = location.lat + m.Position.Location.Longitude = location.lng + m.Position.Location.Altitude = altitude + + self._locations.append(m) + self.app.location._impl.watcher.Position = m.Position + + def reset_locations(self): + self._locations = [] + + def allow_background_permission(self): + """ + winforms doesn't distinguish between foreground and background access + """ + pass + + async def simulate_location_error(self, loco): + await self.redraw("Wait for location error") + + xfail("Winforms's location service doesn't raise errors on failure") + + async def simulate_current_location(self, location): + await self.redraw("Wait for current location") + + self.reset_locations() + + return await location + + async def simulate_location_update(self): + self.app.location._impl._position_changed(None, self._locations[-1])