Modify background permissions to be an explicit second step.

This commit is contained in:
Russell Keith-Magee 2024-03-27 13:58:19 +08:00
parent 389075e83e
commit b36bad80b5
No known key found for this signature in database
GPG key ID: 3D2DAB6A37BB5BC3
11 changed files with 205 additions and 104 deletions

View file

@ -35,8 +35,8 @@ class TogaLocationDelegate(NSObject):
@objc_method
def locationManagerDidChangeAuthorization_(self, manager) -> None:
while self.impl.permission_requests:
future = self.impl.permission_requests.pop()
future.set_result(self.impl.has_permission())
future, permission = self.impl.permission_requests.pop()
future.set_result(permission())
@objc_method
def locationManager_didUpdateLocations_(self, manager, locations) -> None:
@ -73,8 +73,8 @@ class Geolocation:
def has_permission(self):
return self.native.authorizationStatus in {
CLAuthorizationStatus.AuthorizedAlways.value,
CLAuthorizationStatus.AuthorizedWhenInUse.value,
CLAuthorizationStatus.AuthorizedAlways.value,
}
def has_background_permission(self):
@ -84,11 +84,11 @@ class Geolocation:
)
def request_permission(self, future):
self.permission_requests.append(future)
self.permission_requests.append((future, self.has_permission))
self.native.requestWhenInUseAuthorization()
def request_background_permission(self, future):
self.permission_requests.append(future)
self.permission_requests.append((future, self.has_background_permission))
self.native.requestAlwaysAuthorization()
def current_location(self, result):

View file

@ -25,17 +25,21 @@ class GeolocationProbe(AppProbe):
# be granted if requested but, has not been granted *yet*. Unless primed,
# permissions will be denied.
self._mock_permission = None
self._mock_background_permission = None
# Mock CLLocationManager
self._mock_location_manager = Mock()
# Mock the CLLocationManager.authorizationStatus property
def _mock_auth_status():
return {
2: CLAuthorizationStatus.AuthorizedAlways.value,
1: CLAuthorizationStatus.AuthorizedWhenInUse.value,
0: CLAuthorizationStatus.Denied.value,
}.get(self._mock_permission, CLAuthorizationStatus.NotDetermined.value)
if self._mock_background_permission == 1:
return CLAuthorizationStatus.AuthorizedAlways.value
elif self._mock_permission == 1:
return CLAuthorizationStatus.AuthorizedWhenInUse.value
elif self._mock_permission == 0:
return CLAuthorizationStatus.Denied.value
else:
return CLAuthorizationStatus.NotDetermined.value
type(self._mock_location_manager).authorizationStatus = PropertyMock(
side_effect=_mock_auth_status
@ -54,8 +58,10 @@ class GeolocationProbe(AppProbe):
)
def _mock_request_always():
if self._mock_permission == -2:
self._mock_permission = abs(self._mock_permission)
if self._mock_background_permission is None:
self._mock_background_permission = 0
else:
self._mock_background_permission = abs(self._mock_background_permission)
# Trigger delegate handling for permission change
self.app.geolocation._impl.delegate.locationManagerDidChangeAuthorization(
@ -104,13 +110,13 @@ class GeolocationProbe(AppProbe):
self._mock_permission = -1
def grant_background_permission(self):
self._mock_permission = -2
self._mock_background_permission = -1
def allow_permission(self):
self._mock_permission = 1
def allow_background_permission(self):
self._mock_permission = 2
self._mock_background_permission = 1
def reject_permission(self):
self._mock_permission = 0

View file

@ -61,6 +61,12 @@ class Geolocation:
If permission has already been granted, this will return without prompting the
user.
This method will only grant permission to access geolocation services while the
app is in the foreground. If you want your application to have permission to
track location while the app is in the background, you must call this method,
then make an *additional* permission request for background permissions using
:any:`GeoLocation.request_background_permission()`.
**This is an asynchronous method**. If you invoke this method in synchronous
context, it will start the process of requesting permissions, but will return
*immediately*. The return value can be awaited in an asynchronous context, but
@ -94,6 +100,11 @@ class Geolocation:
If permission has already been granted, this will return without prompting the
user.
Before requesting background permission, you must first request and receive
foreground geolocation permission using :any:`Geolocation.request_permission`.
If you ask for background permission before receiving foreground geolocation
permission, a :any:`PermissionError` will be raised.
**This is an asynchronous method**. If you invoke this method in synchronous
context, it will start the process of requesting permissions, but will return
*immediately*. The return value can be awaited in an asynchronous context, but
@ -102,10 +113,18 @@ class Geolocation:
:returns: An asynchronous result; when awaited, returns True if the app has
permission to capture the user's a geolocation while running in the
background; False otherwise.
:raises PermissionError: If the app has not already requested and received
permission to use geolocation services.
"""
result = PermissionResult(None)
if has_background_permission := self.has_background_permission:
if not self.has_permission:
result.set_exception(
PermissionError(
"Cannot ask for background geolocation permission "
"before confirming foreground geolocation permission."
)
)
elif has_background_permission := self.has_background_permission:
result.set_result(has_background_permission)
else:
self._impl.request_background_permission(result)

View file

@ -1,3 +1,4 @@
import contextlib
from unittest.mock import Mock
import pytest
@ -77,27 +78,46 @@ def test_request_permission_sync(app):
@pytest.mark.parametrize(
"initial, should_request, has_permission",
"foreground, initial, raise_error, should_request, has_background_permission",
[
(-2, True, True),
(-1, True, False),
(0, True, False),
(1, True, False),
(2, False, True),
(-1, -1, True, False, False),
(0, -1, True, False, False),
(1, -1, False, True, True),
(-1, 0, True, False, False),
(0, 0, True, False, False),
(1, 0, False, True, False),
# -1, 1 can't happen; background can't be approved if foreground isn't confirmed
# 0, 1 can't happen; background can't be approved if foreground was rejected
(1, 1, False, False, True),
],
)
def test_request_background_permission(app, initial, should_request, has_permission):
def test_request_background_permission(
app, foreground, initial, raise_error, should_request, has_background_permission
):
"""An app can request background permission to use geolocation."""
# The geolocation instance round-trips the app instance
assert app.geolocation.app == app
# Set initial permission
app.geolocation._impl._has_permission = initial
# Set initial permissions
app.geolocation._impl._has_permission = foreground
app.geolocation._impl._has_background_permission = initial
assert (
app.loop.run_until_complete(app.geolocation.request_background_permission())
== has_permission
)
if raise_error:
error_context = pytest.raises(
PermissionError,
match=(
r"Cannot ask for background geolocation permission "
r"before confirming foreground geolocation permission\."
),
)
else:
error_context = contextlib.nullcontext()
with error_context:
assert (
app.loop.run_until_complete(app.geolocation.request_background_permission())
== has_background_permission
)
if should_request:
assert_action_not_performed(app.geolocation, "request permission")
@ -107,13 +127,14 @@ def test_request_background_permission(app, initial, should_request, has_permiss
assert_action_not_performed(app.geolocation, "request background permission")
# As a result of requesting, geolocation permission is as expected
assert app.geolocation.has_background_permission == has_permission
assert app.geolocation.has_background_permission == has_background_permission
def test_request_background_permission_sync(app):
"""An app can synchronously request background permission to use geolocation."""
# Set initial permission
app.geolocation._impl._has_permission = -2
app.geolocation._impl._has_permission = 1
app.geolocation._impl._has_background_permission = -1
result = app.geolocation.request_background_permission()

View file

@ -37,13 +37,6 @@ permission APIs are paired with the specific actions performed on those APIs - t
to take a photo, you require :any:`Camera.has_permission`, which you can request using
:any:`Camera.request_permission()`.
The calls to request permissions *can* be invoked from a synchronous context (i.e., a
non ``async`` method); however, they are non-blocking when used in this way. Invoking a
method like :any:`Camera.request_permission()` will start the process of requesting
permission, but will return *immediately*, without waiting for the user's response. This
allows an app to *request* permissions as part of the startup process, prior to using
the camera APIs, without blocking the rest of app startup.
Toga will confirm whether the app has been granted permission to use the camera before
invoking any camera API. If permission has not yet been granted, the platform *may*
request access at the time of first camera access; however, this is not guaranteed to be

View file

@ -36,17 +36,14 @@ inside an asynchronous handler:
All platforms require some form of permission to access the geolocation service. To
confirm if you have permission to use the geolocation service while the app is running,
you can call :any:`Geolocation.has_permission`; you can request to permission using
:any:`Geolocation.request_permission()`. To confirm if you have permission to use
geolocation while the app is in the background, you can call
:any:`Geolocation.has_background_permission`; you can request to permission using
:any:`Geolocation.request_background_permission()`
:any:`Geolocation.request_permission()`.
The calls to request permissions *can* be invoked from a synchronous context (i.e., a
non ``async`` method); however, they are non-blocking when used in this way. Invoking a
method like :any:`Geolocation.request_permission()` will start the process of requesting
permission, but will return *immediately*, without waiting for the user's response. This
allows an app to *request* permissions as part of the startup process, prior to using
the geolocation APIs, without blocking the rest of app startup.
If you wish to track the location of the user while the app is in the background, you
must make a separate request for background location permissions using
:meth:`~toga.hardware.Geolocation.request_background_permission()` . This request must
be made *after* foreground permissions have been requested and confirmed. To confirm if
you have permission to use geolocation while the app is in the background, you can call
:any:`Geolocation.has_background_permission`.
Toga will confirm whether the app has been granted permission to use geolocation
services before invoking any geolocation API. If permission has not yet been granted, or
@ -97,17 +94,9 @@ Notes
* On macOS, there is no distinction between "background" permissions and "while-running"
permissions.
* On iOS, requesting permission to track location in the background will always require
2 interactions from the user - an initial request to use geolocation while the app is
running, then a second request to use location in the background. If you call
:meth:`~toga.hardware.Geolocation.request_background_permission()` before *any*
permissions have been confirmed, the user will be asked immediately for geolocation
permissions while the app is running; the request for background tracking will be
deferred until the first attempt to use location in the background, or a second call
to :meth:`~toga.hardware.Geolocation.request_background_permission()`. Background
location tracking will not be permitted unless the user allows geolocation "always"
while the app is running. If they only allow "once off" permission while the app is
running, requests for background processing will be ignored.
* On iOS, if the user has provided "once off" permission for foreground location
tracking, requests for background location permission will be rejected.
Reference
---------

View file

@ -9,12 +9,11 @@ class Geolocation(LoggedObject):
def __init__(self, interface):
self.interface = interface
# -2: background permission *could* be granted, but hasn't been
# -1: permission *could* be granted, but hasn't been
# 0: permission has been denied, or can't be granted
# 1: permission has been granted
# 2: background permission has been granted
self._has_permission = -1
self._has_background_permission = -1
self._location = LatLng(10.0, 20.0)
self._altitude = 0
@ -31,7 +30,7 @@ class Geolocation(LoggedObject):
def has_background_permission(self):
self._action("has background permission")
return self._has_permission > 1
return self._has_background_permission > 0
def request_permission(self, future):
self._action("request permission")
@ -40,8 +39,8 @@ class Geolocation(LoggedObject):
def request_background_permission(self, future):
self._action("request background permission")
self._has_permission = abs(self._has_permission)
future.set_result(self._has_permission > 1)
self._has_background_permission = abs(self._has_background_permission)
future.set_result(self._has_background_permission > 0)
def current_location(self, result):
location, altitude = self._next_location()

View file

@ -5,20 +5,6 @@ from toga.style import Pack
class ExampleHardwareApp(toga.App):
def startup(self):
try:
# This will provide a prompt for camera permissions at startup.
# If permission is denied, the app will continue.
self.camera.request_permission()
except NotImplementedError:
print("The Camera API is not implemented on this platform")
try:
# This will provide a prompt for camera permissions at startup.
# If permission is denied, the app will continue.
self.geolocation.request_background_permission()
except NotImplementedError:
print("The Geolocation API is not implemented on this platform")
#############################################################
# Camera
#############################################################
@ -108,14 +94,23 @@ class ExampleHardwareApp(toga.App):
async def take_photo(self, widget, **kwargs):
try:
if not self.camera.has_permission:
await self.camera.request_permission()
image = await self.camera.take_photo()
if image is None:
self.photo.image = "resources/default.png"
else:
self.photo.image = image
except NotImplementedError:
await self.main_window.info_dialog(
"Oh no!",
"The Camera API is not implemented on this platform",
)
except PermissionError:
await self.main_window.info_dialog(
"Oh no!", "You have not granted permission to take photos"
"Oh no!",
"You have not granted permission to take photos",
)
def location_changed(self, geo, location, altitude, **kwargs):
@ -130,8 +125,15 @@ class ExampleHardwareApp(toga.App):
async def update_location(self, widget, **kwargs):
try:
await self.geolocation.request_permission()
location = await self.geolocation.current_location()
self.location_changed(None, location, None)
except NotImplementedError:
await self.main_window.info_dialog(
"Oh no!",
"The Geolocation API is not implemented on this platform",
)
except PermissionError:
await self.main_window.info_dialog(
"Oh no!",
@ -140,7 +142,14 @@ class ExampleHardwareApp(toga.App):
async def start_location_updates(self, widget, **kwargs):
try:
await self.geolocation.request_permission()
self.geolocation.start()
except NotImplementedError:
await self.main_window.info_dialog(
"Oh no!",
"The Geolocation API is not implemented on this platform",
)
except PermissionError:
await self.main_window.info_dialog(
"Oh no!",
@ -149,7 +158,14 @@ class ExampleHardwareApp(toga.App):
async def stop_location_updates(self, widget, **kwargs):
try:
await self.geolocation.request_permission()
self.geolocation.stop()
except NotImplementedError:
await self.main_window.info_dialog(
"Oh no!",
"The Geolocation API is not implemented on this platform",
)
except PermissionError:
await self.main_window.info_dialog(
"Oh no!",
@ -157,10 +173,29 @@ class ExampleHardwareApp(toga.App):
)
async def request_background_location(self, widget, **kwargs):
if not await self.geolocation.request_background_permission():
try:
if self.geolocation.has_background_permission:
await self.main_window.info_dialog(
"All good!",
"Application has permission to perform background geolocation",
)
else:
if not await self.geolocation.request_permission():
await self.main_window.info_dialog(
"Oh no!",
"You have not granted permission for location tracking",
)
return
if not await self.geolocation.request_background_permission():
await self.main_window.info_dialog(
"Oh no!",
"You have not granted permission for background location tracking",
)
except NotImplementedError:
await self.main_window.info_dialog(
"Oh no!",
"You have not granted permission for background location tracking",
"The Geolocation API is not implemented on this platform",
)

View file

@ -38,11 +38,13 @@ class TogaLocationDelegate(NSObject):
@objc_method
def locationManagerDidChangeAuthorization_(self, manager) -> None:
while self.impl.permission_requests:
future = self.impl.permission_requests.pop()
future.set_result(self.impl.has_permission())
future, permission = self.impl.permission_requests.pop()
future.set_result(permission())
@objc_method
def locationManager_didUpdateLocations_(self, manager, locations) -> None:
# The API *can* send multiple locations in a single update; they should be
# sorted chronologically; only propagate the most recent one
toga_loc = toga_location(locations[-1])
# Set all outstanding location requests with location reported
@ -86,8 +88,8 @@ class Geolocation:
def has_permission(self):
return self.native.authorizationStatus in {
CLAuthorizationStatus.AuthorizedAlways.value,
CLAuthorizationStatus.AuthorizedWhenInUse.value,
CLAuthorizationStatus.AuthorizedAlways.value,
}
def has_background_permission(self):
@ -97,14 +99,14 @@ class Geolocation:
)
def request_permission(self, future):
self.permission_requests.append(future)
self.permission_requests.append((future, self.has_permission))
self.native.requestWhenInUseAuthorization()
def request_background_permission(self, future):
if NSBundle.mainBundle.objectForInfoDictionaryKey(
"NSLocationAlwaysAndWhenInUseUsageDescription"
):
self.permission_requests.append(future)
self.permission_requests.append((future, self.has_background_permission))
self.native.requestAlwaysAuthorization()
else: # pragma: no cover

View file

@ -16,8 +16,6 @@ NSError = ObjCClass("NSError")
class GeolocationProbe(AppProbe):
request_permission_on_first_use = False
def __init__(self, monkeypatch, app_probe):
super().__init__(app_probe.app)
@ -27,17 +25,21 @@ class GeolocationProbe(AppProbe):
# be granted if requested but, has not been granted *yet*. Unless primed,
# permissions will be denied.
self._mock_permission = None
self._mock_background_permission = None
# Mock CLLocationManager
self._mock_location_manager = Mock()
# Mock the CLLocationManager.authorizationStatus property
def _mock_auth_status():
return {
2: CLAuthorizationStatus.AuthorizedAlways.value,
1: CLAuthorizationStatus.AuthorizedWhenInUse.value,
0: CLAuthorizationStatus.Denied.value,
}.get(self._mock_permission, CLAuthorizationStatus.NotDetermined.value)
if self._mock_background_permission == 1:
return CLAuthorizationStatus.AuthorizedAlways.value
elif self._mock_permission == 1:
return CLAuthorizationStatus.AuthorizedWhenInUse.value
elif self._mock_permission == 0:
return CLAuthorizationStatus.Denied.value
else:
return CLAuthorizationStatus.NotDetermined.value
type(self._mock_location_manager).authorizationStatus = PropertyMock(
side_effect=_mock_auth_status
@ -56,8 +58,10 @@ class GeolocationProbe(AppProbe):
)
def _mock_request_always():
if self._mock_permission == -2:
self._mock_permission = abs(self._mock_permission)
if self._mock_background_permission is None:
self._mock_background_permission = 0
else:
self._mock_background_permission = abs(self._mock_background_permission)
# Trigger delegate handling for permission change
self.app.geolocation._impl.delegate.locationManagerDidChangeAuthorization(
@ -106,13 +110,13 @@ class GeolocationProbe(AppProbe):
self._mock_permission = -1
def grant_background_permission(self):
self._mock_permission = -2
self._mock_background_permission = -1
def allow_permission(self):
self._mock_permission = 1
def allow_background_permission(self):
self._mock_permission = 2
self._mock_background_permission = 1
def reject_permission(self):
self._mock_permission = 0

View file

@ -19,7 +19,7 @@ async def geolocation_probe(monkeypatch, app_probe):
async def test_grant_permission(app, geolocation_probe):
"""A user can grant permission to use geolocation."""
# Prime the permission system to approve permission requests
geolocation_probe.allow_permission()
geolocation_probe.grant_permission()
# Initiate the permission request. As permissions are primed, they will be approved.
assert await app.geolocation.request_permission()
@ -56,7 +56,21 @@ async def test_deny_permission(app, geolocation_probe):
async def test_grant_background_permission(app, geolocation_probe):
"""A user can grant background permission to use geolocation."""
# Prime the permission system to approve permission requests
geolocation_probe.allow_background_permission()
geolocation_probe.grant_background_permission()
# Foreground permissions haven't been approved, so requesting background permissions
# will raise an error
with pytest.raises(
PermissionError,
match=(
r"Cannot ask for background geolocation permission "
r"before confirming foreground geolocation permission\."
),
):
await app.geolocation.request_background_permission()
# Pre-approve foreground permissions
geolocation_probe.allow_permission()
# Initiate the permission request. As permissions are primed, they will be approved.
assert await app.geolocation.request_background_permission()
@ -75,18 +89,37 @@ async def test_grant_background_permission(app, geolocation_probe):
async def test_deny_background_permission(app, geolocation_probe):
"""A user can deny background permission to use geolocation."""
# Initiate the permission request. As permissions are not primed, they will be denied.
# Foreground permissions haven't been approved, so requesting background permissions
# will raise an error.
with pytest.raises(
PermissionError,
match=(
r"Cannot ask for background geolocation permission "
r"before confirming foreground geolocation permission\."
),
):
await app.geolocation.request_background_permission()
# Neither permission does not exist yet
assert not app.geolocation.has_permission
assert not app.geolocation.has_background_permission
# Pre-approve foreground permissions
geolocation_probe.allow_permission()
# Initiate the permission request. As background permissions are not primed, they
# will be denied.
assert not await app.geolocation.request_background_permission()
# Permission has been denied
assert not app.geolocation.has_permission
# Background permission has been denied, but foreground permission must exist
assert app.geolocation.has_permission
assert not app.geolocation.has_background_permission
# A second request to request permissions is a no-op
assert not await app.geolocation.request_background_permission()
# Permission is still denied
assert not app.geolocation.has_permission
# Background permission is still denied, but foreground permission must exist
assert app.geolocation.has_permission
assert not app.geolocation.has_background_permission