Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(winforms): location support #3025

Open
wants to merge 69 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 58 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
d2e1e19
feat(winforms): location support
Mause Nov 24, 2024
edc1e02
chore: isort
Mause Nov 24, 2024
8a1fcb1
chore: move AddReference call
Mause Nov 24, 2024
6b22256
chore: change log entry
Mause Nov 24, 2024
80fd37f
Update test_location.py
Mause Nov 24, 2024
2f5c2e8
probe
Mause Nov 24, 2024
2b6d427
chore: export Location class
Mause Nov 24, 2024
146e9c4
test: probes
Mause Nov 24, 2024
2823a85
chore: use futures better
Mause Nov 24, 2024
3c6a121
make permission settable
Mause Nov 24, 2024
7de79b5
timeout
Mause Nov 24, 2024
8aab6b4
add missing methods
Mause Nov 24, 2024
4dbd6c3
improve types
Mause Nov 25, 2024
1266849
add missing functions
Mause Nov 24, 2024
9087b15
reject_permission not supported
Mause Nov 25, 2024
8bce630
fix import
Mause Nov 25, 2024
63e1000
Update widgets_by_platform.csv
Mause Nov 25, 2024
75c4376
don't track by default
Mause Dec 1, 2024
a719a58
get location directly from watcher
Mause Dec 1, 2024
35217f0
call on_change callback if available
Mause Dec 1, 2024
714e7f3
call on_change from probe
Mause Dec 1, 2024
7728c86
raise PermissionError if foreground not granted
Mause Dec 2, 2024
704059c
sanity
Mause Dec 2, 2024
3a6b1e9
fill out simulate_location
Mause Dec 2, 2024
7d35190
Init cached field
Mause Dec 2, 2024
67ad190
override watcher impl
Mause Dec 2, 2024
312e51c
add missing interface field
Mause Dec 2, 2024
c078b7e
remove dud field
Mause Dec 2, 2024
76eb2b0
set IsUnknown
Mause Dec 2, 2024
0faf901
set watcher position
Mause Dec 2, 2024
12294c5
set spec
Mause Dec 2, 2024
ace1c5f
impl simulate_location_update
Mause Dec 2, 2024
daa168b
add lookup
Mause Dec 3, 2024
d43b4ad
remove dud hook
Mause Dec 3, 2024
909775d
change permission default
Mause Dec 3, 2024
cd0f234
restore _has_background_permission
Mause Dec 3, 2024
9567bb5
implement simulate_location_error properly
Mause Dec 3, 2024
d16f780
fix assignment
Mause Dec 3, 2024
b6360ec
remove dud field
Mause Dec 3, 2024
01dcc10
add docstring
Mause Dec 3, 2024
da71e61
can't deny location access with winforms api
Mause Dec 3, 2024
bd3717a
use different Start overload
Mause Dec 3, 2024
5fadf74
use Permission property
Mause Dec 3, 2024
4c4ca08
set Permission property as required
Mause Dec 3, 2024
8a29fc7
remove dud method
Mause Dec 3, 2024
27beaa8
add TODO
Mause Dec 3, 2024
dceba83
correct setting Permission
Mause Dec 3, 2024
b69ac70
add spec
Mause Dec 3, 2024
07e46fc
remove skips
Mause Dec 3, 2024
8bb86b0
swap hardcoded True
Mause Dec 3, 2024
0cdd98c
restore skip
Mause Dec 3, 2024
4885cc8
fix data_file value
Mause Dec 3, 2024
e5332b3
correct file name
Mause Dec 4, 2024
8fb7b8d
use GITHUB_WORKSPACE
Mause Dec 5, 2024
2f6f1da
include hidden files
Mause Dec 5, 2024
5deb049
update docstring
Mause Dec 5, 2024
9ca2091
handle None location
Mause Dec 8, 2024
fe39dfd
ensure watcher starts, regardless of permissions state
Mause Dec 7, 2024
66a8081
add context manager for running the watcher
Mause Dec 11, 2024
c4b1fc5
use boolean to track if tracking is on or off
Mause Dec 11, 2024
8b28b68
disable needs
Mause Dec 11, 2024
5bad2e2
add dummy event handler
Mause Dec 11, 2024
6fcac27
add missing type
Mause Dec 11, 2024
cefe8eb
rework current_location
Mause Dec 11, 2024
ad2380f
restore needs
Mause Dec 20, 2024
6394e7b
Merge pull request #6 from Mause/contextual
Mause Dec 20, 2024
8bb9752
Merge branch 'main' into winforms-location
Mause Dec 20, 2024
de30f0b
Merge branch 'main' into winforms-location
Mause Dec 20, 2024
0714dcb
Set supports_background_permission to False
Mause Dec 20, 2024
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions changes/2979.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Location support is now available on Windows
2 changes: 1 addition & 1 deletion docs/reference/data/widgets_by_platform.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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|,,
Location,Hardware,:class:`~toga.hardware.location.Location`,A sensor that can capture the geographical location of the device.,|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|,,
Expand Down
4 changes: 3 additions & 1 deletion testbed/tests/hardware/test_location.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

@pytest.fixture
async def location_probe(monkeypatch, app_probe):
skip_on_platforms("linux", "windows")
skip_on_platforms("linux")
probe = get_probe(monkeypatch, app_probe, "Location")
yield probe
probe.cleanup()
Expand Down Expand Up @@ -90,6 +90,8 @@ async def test_grant_background_permission(app, location_probe):

async def test_deny_background_permission(app, location_probe):
"""A user can deny background permission to use location."""
skip_on_platforms("windows")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach we take for this kind of skip is to make the probe do the skip/xfail when an attempt is made to use the feature. In this case, we'd make request_background_location() raise an XFAIL - we use skip to indicate a feature hasn't been implemented yet, and XFAIL to indicate "this won't ever be implemented because the platform fundamentally doesn't support this idea".


# Foreground permissions haven't been approved, so requesting background permissions
# will raise an error.
with pytest.raises(
Expand Down
10 changes: 8 additions & 2 deletions testbed/tests/testbed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)
Expand Down
2 changes: 2 additions & 0 deletions winforms/src/toga_winforms/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +62,7 @@ def not_implemented(feature):
"Divider",
"ImageView",
"Label",
"Location",
"MapView",
"MultilineTextInput",
"NumberInput",
Expand Down
3 changes: 3 additions & 0 deletions winforms/src/toga_winforms/hardware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import clr

clr.AddReference("System.Device")
70 changes: 70 additions & 0 deletions winforms/src/toga_winforms/hardware/location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

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._has_background_permission = False

def _position_changed(
self, sender, 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

def request_permission(self, future: AsyncResult[bool]) -> None:
self.watcher.Start(False) # TODO: where can we call stop?
future.set_result(self.has_permission())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is Start() a blocking method? A method that never returns False? My concern here is whether has_permission() is given a chance to return False on the basis of user input.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this overload of Start is blocking yes, but the docs aren't super clear, and it doesn't seem possible to clear the flag on the python exe to test locally properly


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:
self.watcher.Start() # ensure watcher has started
loco = toga_location(self.watcher.Position.Location)
result.set_result(loco["location"] if loco else None)

def start_tracking(self) -> None:
self.watcher.Start() # ensure watcher has started
self.watcher.add_PositionChanged(self._handler)

def stop_tracking(self) -> None:
self.watcher.remove_PositionChanged(self._handler)
Empty file.
9 changes: 9 additions & 0 deletions winforms/tests_backend/hardware/hardware.py
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions winforms/tests_backend/hardware/location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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):
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])
Loading