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

Add Location implementation to Toga GTK #2999

Merged
merged 41 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7057a1c
Add Location implementation to Toga GTK
sarayourfriend Nov 26, 2024
0e0ec80
Add the testbed with most tests passing
sarayourfriend Nov 27, 2024
d6c7634
Remove unused flatpak/sandboxed attribute
sarayourfriend Nov 27, 2024
8bcefbb
Add changenote
sarayourfriend Nov 27, 2024
53ea54d
Add GTK location docs
sarayourfriend Nov 27, 2024
2fdeda1
Sort out grant vs allow testbed helpers
sarayourfriend Nov 27, 2024
33b5267
Add Flatpak and sandboxed to wordlist
sarayourfriend Nov 27, 2024
58a7466
Add geoclue2 system requirement notes
sarayourfriend Nov 27, 2024
6ab8db2
Merge branch 'main' into add/gtk-geoclue-geolocation
sarayourfriend Nov 27, 2024
ac8853b
Simplify Toga GTK location permissions
sarayourfriend Nov 27, 2024
6548dc5
Check gnome system location settings when available
sarayourfriend Nov 28, 2024
da2a257
Handle gsettings in tests and clarify edge cases
sarayourfriend Nov 28, 2024
3ffd287
Respect gsettings location max-accuracy-level
sarayourfriend Nov 28, 2024
19e75c3
Add Geoclue to CI environment
sarayourfriend Nov 29, 2024
be5ae2d
Merge remote-tracking branch 'upstream/main' into add/gtk-geoclue-geo…
sarayourfriend Nov 29, 2024
8d3dd1a
Fix line-lengths for new linter config
sarayourfriend Nov 29, 2024
282da75
Provide a better test failure error when Geoclue is unavailable
sarayourfriend Nov 29, 2024
4547f39
Split non/sandboxed location tests
sarayourfriend Dec 2, 2024
059e2af
Merge branch 'main' into add/gtk-geoclue-geolocation
sarayourfriend Dec 2, 2024
4e7517a
Allow module-level skip
sarayourfriend Dec 2, 2024
e64ac5e
Simplify and document ``list_probes``
sarayourfriend Dec 5, 2024
82f1bed
Ignore GNOME location settings
sarayourfriend Dec 5, 2024
146793c
Run linters
sarayourfriend Dec 5, 2024
f85cf57
Add Flatpak GObject library to CI
sarayourfriend Dec 5, 2024
153d964
Match permission and error cases with reality
sarayourfriend Dec 5, 2024
6cee226
Skip tracking start failure test on platforms where it cannot happen
sarayourfriend Dec 6, 2024
62d32df
Merge branch 'main' into add/gtk-geoclue-geolocation
sarayourfriend Dec 11, 2024
64957c0
Set feature as available rather than provisional
sarayourfriend Dec 11, 2024
da8a17b
Clean up wording in documentation
sarayourfriend Dec 11, 2024
e019c5d
Use a more descriptive and less tentative changelog entry
sarayourfriend Dec 11, 2024
c7d34ec
Clarify permission error docs re Geoclue versions
sarayourfriend Dec 11, 2024
9e3b1d6
Simplify location probe usage in tests
sarayourfriend Dec 11, 2024
1ba7515
Further simplify start tracking error setup
sarayourfriend Dec 11, 2024
47bbd88
Merge branch 'main' into add/gtk-geoclue-geolocation
sarayourfriend Dec 16, 2024
951f323
Add GeoClue testing dev docs
sarayourfriend Dec 16, 2024
9c1a51b
Fix GeoClue et co capitalisation in prose
sarayourfriend Dec 16, 2024
d6c104b
Further detail GeoClue requirement for users
sarayourfriend Dec 16, 2024
dea7ac6
Obviate need for background permission fixture
sarayourfriend Dec 16, 2024
280065a
Remove not-implemented error in favour of empty implementation
sarayourfriend Dec 16, 2024
99376e0
Fix attribute name on probes
sarayourfriend Dec 18, 2024
0de43b9
Remove errant capitalisation from spelling wordlist
sarayourfriend Dec 18, 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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ jobs:
- backend: "linux-x11"
platform: "linux"
runs-on: "ubuntu-24.04"
# The package list should be the same as in tutorial-0.rst, and the BeeWare
# The package list should be the same as in unix-prerequisites.rst, and the BeeWare
# tutorial, plus blackbox to provide a window manager. We need a window
# manager that is reasonably lightweight, honors full screen mode, and
# treats the window position as the top-left corner of the *window*, not the
Expand All @@ -275,7 +275,7 @@ jobs:
sudo apt update -y
sudo apt install -y --no-install-recommends \
blackbox pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \
gir1.2-webkit2-4.1 gir1.2-xapp-1.0
gir1.2-webkit2-4.1 gir1.2-xapp-1.0 gir1.2-geoclue-2.0 gir1.2-flatpak-1.0

# Start Virtual X Server
echo "Start X server..."
Expand All @@ -293,13 +293,13 @@ jobs:
- backend: "linux-wayland"
platform: "linux"
runs-on: "ubuntu-24.04"
# The package list should be the same as in tutorial-0.rst, and the BeeWare
# The package list should be the same as in unix-prerequisites.rst, and the BeeWare
# tutorial, plus mutter to provide a window manager.
pre-command: |
sudo apt update -y
sudo apt install -y --no-install-recommends \
mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \
gir1.2-webkit2-4.1 gir1.2-xapp-1.0
gir1.2-webkit2-4.1 gir1.2-xapp-1.0 gir1.2-geoclue-2.0 gir1.2-flatpak-1.0

# Start Virtual X Server
echo "Start X server..."
Expand Down
1 change: 1 addition & 0 deletions changes/2990.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga GTK now has basic support for the ``Location`` API.
sarayourfriend marked this conversation as resolved.
Show resolved Hide resolved
29 changes: 27 additions & 2 deletions docs/reference/api/hardware/location.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ location is obtained:

If you no longer wish to receive location updates, call :any:`Location.stop_tracking()`.

.. _location-system-requires:

System requirements
-------------------

* Using location services on Linux requires that the user has installed the system
packages for Geoclue2, plus the GObject Introspection bindings for Geoclue2. The name
of the system package required is distribution dependent:

- Ubuntu and Debian: ``gir1.2-geoclue-2.0``
- Fedora: ``geoclue2-libs``
- Arch/Manjaro: ``geoclue``
- OpenSUSE Tumbleweed: ``geoclue2 typelib(geoclue2)``
- FreeBSD: ``geoclue``

Notes
-----

Expand All @@ -91,8 +106,18 @@ Notes
want to track location while the app is in the background, you must also define the
permission ``android.permission.ACCESS_BACKGROUND_LOCATION``.

* On macOS, there is no distinction between "background" permissions and "while-running"
permissions.
* On macOS and GTK, there is no distinction between "background" permissions and "while-running"
permissions for location tracking.

* On Linux, there are no reliable permission controls for non-sandboxed applications.
Sandboxed applications (e.g., Flatpak apps) request location information via the XDG
Portal Location API, which has coarse grained permissions allowing users to reliably
disallow location access on a per-app basis. However, `Linux users should be aware of
the limitations of location privacy for non-sandboxed applications
<linux-location-privacy_>`_. This applies to all Linux applications, not just ones
using Toga GTK's Location implementation.

.. _linux-location-privacy: https://gitlab.freedesktop.org/geoclue/geoclue/-/issues/111

* On iOS, if the user has provided "allow once" permission for foreground location
tracking, requests for background location permission will be rejected.
Expand Down
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
5 changes: 4 additions & 1 deletion docs/reference/platforms/unix-prerequisites.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ know so we can improve this documentation!)

Some widgets (most notably, the :ref:`WebView <webview-system-requires>` and
:ref:`MapView <mapview-system-requires>` widgets) have additional system requirements.
See the documentation of those widgets for details.
Likewise, certain hardware features (:ref:`Location <location-system-requires>`) have
system requirements.

See the documentation of those widgets and hardware features for details.
3 changes: 3 additions & 0 deletions docs/spelling_wordlist
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ Ctrl
dialogs
Django
draggable
Flatpak
Flexbox
GBulb
Geoclue
geolocation
GMail
GObject
Expand Down Expand Up @@ -66,6 +68,7 @@ Ren
resizable
reStructuredText
runtime
sandboxed
scrollable
scrollers
Segoe
Expand Down
3 changes: 3 additions & 0 deletions gtk/src/toga_gtk/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 @@ -48,6 +49,8 @@ def not_implemented(feature):
"Image",
"Paths",
"dialogs",
# Hardware
"Location",
# Status icons
"MenuStatusIcon",
"SimpleStatusIcon",
Expand Down
251 changes: 251 additions & 0 deletions gtk/src/toga_gtk/hardware/location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
from __future__ import annotations

from enum import IntEnum, auto

from toga import LatLng
from toga_gtk.libs import Flatpak, Geoclue, Gio, GLib, GObject


def toga_location(location):
"""Convert a ``Geoclue.Location`` into ``OnLocationChangeHandler`` kwargs."""
latlng = LatLng(location.props.latitude, location.props.longitude)
altitude = location.get_property("altitude")

return {
"location": latlng,
"altitude": altitude,
}


KNOWN_PERMISSION_ERRORS = [
(Gio.DBusError.quark(), Gio.DBusError.ACCESS_DENIED),
]

if Flatpak is not None:
KNOWN_PERMISSION_ERRORS.append(
(Flatpak.PortalError.quark(), Flatpak.PortalError.NOT_ALLOWED),
)
else: # pragma: no cover
# Non-sandboxed and no Flatpak library installed. That's a valid system
# configuration, but there's no meaningful way to test it; it can just be ignored
pass


def is_permissions_error(error):
"""Determine if a ``GLib.Error`` is one of the known Geoclue permissions errors.

In practice, these permissions errors may not occur due to a bug in
``Geoclue.Simple``'s error handling
https://gitlab.freedesktop.org/geoclue/geoclue/-/issues/205

:param error: a GLib.Error instance
:returns: whether the error is one of the known permissions errors
"""
return any(
error.matches(error_domain, error_code)
for error_domain, error_code in KNOWN_PERMISSION_ERRORS
)


class State(IntEnum):
INITIAL = auto()
"""Geoclue has not been started."""

STARTING = auto()
"""Waiting for the Geoclue client to finish starting."""

READY = auto()
"""Geoclue is ready to connect."""

MONITORING = auto()
"""Actively monitoring the location."""

FAILED = auto()
"""``Geoclue`` was unable to retrieve the location due to a generic error."""

DENIED = auto()
"""Location access was denied to the application."""

@classmethod
def is_available(cls, state):
return State.READY <= state <= State.MONITORING

@classmethod
def is_uninitialised(cls, state):
return state <= State.STARTING


class Location(GObject.Object):
#: State of Geoclue location service initialisation and communication
state = GObject.Property(type=int, default=State.INITIAL)

def __init__(self, interface):
if Geoclue is None:
# CI (where coverage is enforced) must always have Geoclue available
# in order to perform the rest of the tests
raise RuntimeError( # pragma: no cover
"Unable to import Geoclue. Ensure that the system package "
"providing Geoclue and its GTK bindings have been installed. See "
"https://toga.readthedocs.io/en/stable/reference/api/hardware/location.html#system-requirements " # noqa: E501
"for details."
)

super().__init__()

self.interface = interface

#: ``Geoclue.Simple`` instance
self.native: Geoclue.Simple = None

#: Handle ID for ``Geoclue.Simple`` location listener
self.notify_location_handle: None | int = None

#: Handle ID for client active notify listener
self.notify_active_listener: None | int = None

self.permission_result: None | bool = None

def _start(self):
"""Asynchronously initialize ``Geoclue.Simple``.

The act of initializing ``Geoclue.Simple`` will itself trigger
a permission request. As such, delay this until permission is checked.
"""
self.props.state = State.STARTING

Geoclue.Simple.new(
self.interface.app.app_id,
Geoclue.AccuracyLevel.EXACT,
None,
self._new_finish,
)

def _new_finish(self, _, async_result):
try:
self.native = Geoclue.Simple.new_finish(async_result)
except GLib.Error as e:
if is_permissions_error(e):
self.props.state = State.DENIED
else:
self.props.state = State.FAILED

return
else:
self.props.state = State.READY

client = self.native.get_client()
# Geoclue docs indicate a client proxy is not used in sandboxed environments
# https://gitlab.freedesktop.org/geoclue/geoclue/-/blob/master/libgeoclue/gclue-simple.c?ref_type=heads#L978-979
if client:

def notify_client_active(*args):
if not self.native.get_client().props.active:
# If the client explicitly becomes inactive, this indicates
# a failure to retrieve the location
# e.g., when the geoclue service is stopped on the host
self.props.state = State.FAILED
self._stop_tracking()
else: # pragma: no cover
# Not possible to meaningfully test in a platform agnostic manner
self.props.state = State.READY

self.notify_active_listener = client.connect(
"notify::active", notify_client_active
)

def has_permission(self):
return bool(self.permission_result)
Copy link
Member

Choose a reason for hiding this comment

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

What happens if permission is revoked after being initially allowed? Is that even a possibility? If it is, it looks like the listener handle is removed once an "initialised" state is received.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you run an example in Flatpak and use Flatseal, you can deny permissions while the app is running and already has location permission... but the XDG Location Portal doesn't send any signal to that fact, and location updates still propagate. If the app restarts, then permission does go away. I haven't found any way to revoke permissions while the app is running and after it's already requested permissions.

In a non-sandboxed environment, the state can end up in FAILED if the system service closes, which is handled by the listener attached to the client:

https://github.com/sarayourfriend/toga/blob/add/gtk-geoclue-geolocation/gtk/src/toga_gtk/hardware/location.py#L136-L154

A "client" is only available in non-sandboxed environments, as mentioned in the Geoclue documentation for the method linked to in the section I've linked above.

In that case, tracking will cease, and calls to current_location and start_tracking will fail with a RuntimeError.

This isn't a permissions issue, though. It could be the Geoclue agent crashing, for example, and you can reproduce it by running the app outside a sandbox and running systemctl stop geoclue. That is why the code I linked above sets the state to FAILED rather than DENIED, and doesn't reset the permission result.


def request_permission(self, result):
if self.permission_result is not None:
result.set_result(self.permission_result)
return

def wait_for_client(*args):
if State.is_uninitialised(self.props.state): # pragma: no cover
return

self.permission_result = State.is_available(self.props.state)

result.set_result(self.permission_result)
self.disconnect(listener_handle)

listener_handle = self.connect("notify::state", wait_for_client)

if self.props.state == State.INITIAL:
self._start()
else: # pragma: no cover
# This cannot be tested because the test probe runs a sync initialisation
# There is no method by which initialisation can be triggered in the test
# environment and then permissions requested before initialisation finishes
# In other words, there is no situation in test where the status is starting
pass

def has_background_permission(self):
"""Check for background permission.

Background location permission has no meaning for Geoclue,
as all location access is mediated through the same location
tracking APIs. Therefore, background location permission
is handled in identical terms to foreground location permission.
"""
return self.has_permission()

def request_background_permission(self, result):
"""Request background permission.

This method should never actually run, because the upstream location
API enforces requesting general location permissions first. Once
general location permissions are available, background permission
will already be available (see :meth:`~.Location.has_background_permission()`),
and the upstream location API never has to call this method.
"""
raise NotImplementedError("Background permissions are non-existent on Linux")
sarayourfriend marked this conversation as resolved.
Show resolved Hide resolved

def location_listener(self, *args):
self.interface.on_change(**toga_location(self.native.get_location()))

def start_tracking(self):
"""Start tracking Geoclue location updates."""
if self.props.state == State.READY:
self.notify_location_handle = self.native.connect(
"notify::location", self.location_listener
)
self.props.state = State.MONITORING
# Manually notify when connecting in order to propagate the initial location
self.native.notify("location")
elif self.props.state == State.MONITORING:
# Already monitoring, noop
pass
else:
raise RuntimeError(
"Unable to start tracking (location service is unavailable)"
)

def _stop_tracking(self) -> bool:
"""If monitoring, stop tracking.

:return bool: Whether tracking was stopped"""
if self.notify_location_handle is not None:
self.native.disconnect(self.notify_location_handle)
return True
return False

def stop_tracking(self):
"""Stop tracking Geoclue location updates.

If not currently tracking, this method is a noop.
"""
if self._stop_tracking():
self.props.state = State.READY

def current_location(self, location):
"""Asynchronously retrieve the current location."""
if State.is_available(self.props.state):
location.set_result(toga_location(self.native.get_location())["location"])
else:
location.set_exception(
RuntimeError(
"Unable to obtain a location (location service is unavailable)"
)
)
Loading
Loading