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 a geolocation service #2462

Merged
merged 24 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a136ec2
Ensure that rapid zoom/location changes are both honored.
freakboy3742 Mar 22, 2024
e20010d
Add initial core API and docs for geolocation.
freakboy3742 Mar 22, 2024
df35463
Add geolocation to the hardware example.
freakboy3742 Mar 22, 2024
92f9566
Initial cocoa implementation of geolocation service.
freakboy3742 Mar 22, 2024
306c30a
Add changenote.
freakboy3742 Mar 22, 2024
a8a71e8
Add iOS implementation of geolocation service.
freakboy3742 Mar 25, 2024
8e52172
Restore geolocation permission request on startup.
freakboy3742 Mar 26, 2024
90cf182
Simplify geolocation permission handling by dropping the ask-on-first…
freakboy3742 Mar 26, 2024
476c0bf
Core Geolocation API tested to 100% coverage.
freakboy3742 Mar 26, 2024
13e62a6
100% coverage for Cocoa geolocation.
freakboy3742 Mar 26, 2024
d0e498f
100% coverage for iOS geolocation.
freakboy3742 Mar 27, 2024
389075e
Refactor geolocation permission checks to the core layer.
freakboy3742 Mar 27, 2024
b36bad8
Modify background permissions to be an explicit second step.
freakboy3742 Mar 27, 2024
eba0d9c
Correct usage of grant/allow permissions in testbed.
freakboy3742 Mar 27, 2024
1b6525e
Add an Android implementation of geolocation with tests.
freakboy3742 Mar 28, 2024
a08bc3a
Add geolocation to the dictionary.
freakboy3742 Mar 28, 2024
0ffcb4e
Flush animations prior to an action that will add a new animation.
freakboy3742 Apr 2, 2024
aa10a3c
Ensure the sample image is a persistent resource to avoid potential d…
freakboy3742 Apr 5, 2024
0c49824
Merge branch 'main' into geolocation
freakboy3742 Apr 5, 2024
07c9299
Fix usage of Java List object
mhsmith Apr 11, 2024
bb3536b
Apply suggestions from code review
freakboy3742 Apr 12, 2024
a490963
Rename location->geolocation, and silence change notifications on cur…
freakboy3742 Apr 12, 2024
81a29f3
Fix typos
mhsmith Apr 22, 2024
dbf2b4e
Merge branch 'main' into geolocation
mhsmith Apr 22, 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
7 changes: 7 additions & 0 deletions android/src/toga_android/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from android.graphics.drawable import BitmapDrawable
from android.media import RingtoneManager
from android.view import Menu, MenuItem
from androidx.core.content import ContextCompat
from java import dynamic_proxy
from org.beeware.android import IPythonApp, MainActivity

Expand Down Expand Up @@ -346,6 +347,12 @@ def start_activity(self, activity, *options, on_complete=None):

self._native_startActivityForResult(activity, code, *options)

def _native_checkSelfPermission(self, permission): # pragma: no cover
# A wrapper around the native method so that it can be mocked during testing.
return ContextCompat.checkSelfPermission(
self.native.getApplicationContext(), permission
)

def _native_requestPermissions(self, permissions, code): # pragma: no cover
# A wrapper around the native method so that it can be mocked during testing.
self.native.requestPermissions(permissions, code)
Expand Down
2 changes: 2 additions & 0 deletions android/src/toga_android/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .command import Command
from .fonts import Font
from .hardware.camera import Camera
from .hardware.location import Location
from .icons import Icon
from .images import Image
from .paths import Paths
Expand Down Expand Up @@ -50,6 +51,7 @@ def not_implemented(feature):
"Paths",
# Hardware
"Camera",
"Location",
# Widgets
# ActivityIndicator
"Box",
Expand Down
10 changes: 3 additions & 7 deletions android/src/toga_android/hardware/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from android.content.pm import PackageManager
from android.hardware.camera2 import CameraCharacteristics
from android.provider import MediaStore
from androidx.core.content import ContextCompat, FileProvider
from androidx.core.content import FileProvider
from java.io import File

import toga
Expand Down Expand Up @@ -39,13 +39,9 @@ def __init__(self, interface):
PackageManager.FEATURE_CAMERA
)

def _native_checkSelfPermission(self, context, permission): # pragma: no cover
# A wrapper around the native call so it can be mocked.
return ContextCompat.checkSelfPermission(context, Camera.CAMERA_PERMISSION)

def has_permission(self):
result = self._native_checkSelfPermission(
self.context, Camera.CAMERA_PERMISSION
result = self.interface.app._impl._native_checkSelfPermission(
Camera.CAMERA_PERMISSION
)
return result == PackageManager.PERMISSION_GRANTED

Expand Down
167 changes: 167 additions & 0 deletions android/src/toga_android/hardware/location.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from __future__ import annotations

from android import Manifest
from android.content import Context
from android.content.pm import PackageManager
from android.location import LocationListener, LocationManager
from android.os import Build
from java import dynamic_proxy
from java.util import List
from java.util.function import Consumer

from toga import LatLng


def toga_location(location):
"""Convert an Android location into a Toga LatLng and altitude."""
latlng = LatLng(location.getLatitude(), location.getLongitude())

# MSL altitude was added in API 34. We can't test this at runtime
if Build.VERSION.SDK_INT >= 34 and location.hasMslAltitude(): # pragma: no cover
altitude = location.getMslAltitudeMeters()
elif location.hasAltitude():
altitude = location.getAltitude()
else:
altitude = None

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


class TogaLocationConsumer(dynamic_proxy(Consumer)):
def __init__(self, impl, result):
super().__init__()
self.impl = impl
self.interface = impl.interface
self.result = result

def accept(self, location):
loc = toga_location(location)
self.result.set_result(loc["location"])


class TogaLocationListener(dynamic_proxy(LocationListener)):
def __init__(self, impl):
super().__init__()
self.impl = impl
self.interface = impl.interface

def onLocationChanged(self, location):
if isinstance(location, List):
location = location.get(location.size() - 1)

self.interface.on_change(**toga_location(location))


class Location:
def __init__(self, interface):
self.interface = interface
self.context = self.interface.app._impl.native.getApplicationContext()
if not any(
self.context.getPackageManager().hasSystemFeature(feature)
for feature in [
PackageManager.FEATURE_LOCATION,
PackageManager.FEATURE_LOCATION_GPS,
PackageManager.FEATURE_LOCATION_NETWORK,
]
): # pragma: no cover
# The app doesn't have a feature supporting location services. No-cover
# because we can't manufacture this condition in testing.
raise RuntimeError("Location services are not available on this device.")

self.native = self.context.getSystemService(Context.LOCATION_SERVICE)
self.listener = TogaLocationListener(self)

def has_permission(self):
return (
self.interface.app._impl._native_checkSelfPermission(
Manifest.permission.ACCESS_COARSE_LOCATION
)
== PackageManager.PERMISSION_GRANTED
) or (
self.interface.app._impl._native_checkSelfPermission(
Manifest.permission.ACCESS_FINE_LOCATION
)
== PackageManager.PERMISSION_GRANTED
)

def has_background_permission(self):
return (
self.interface.app._impl._native_checkSelfPermission(
Manifest.permission.ACCESS_BACKGROUND_LOCATION
)
== PackageManager.PERMISSION_GRANTED
)

def request_permission(self, future):
def request_complete(permissions, results):
# Map the permissions to their result
perms = dict(zip(permissions, results))
try:
result = (
perms[Manifest.permission.ACCESS_COARSE_LOCATION]
== PackageManager.PERMISSION_GRANTED
) or (
perms[Manifest.permission.ACCESS_FINE_LOCATION]
== PackageManager.PERMISSION_GRANTED
)
except KeyError: # pragma: no cover
# This shouldn't ever happen - we shouldn't get a completion of a
# location permission request that doesn't include location permissions
# - but just in case, we'll assume if it's not there, it failed.
result = False
future.set_result(result)

self.interface.app._impl.request_permissions(
[
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
],
on_complete=request_complete,
)

def request_background_permission(self, future):
def request_complete(permissions, results):
# Map the permissions to their result
perms = dict(zip(permissions, results))
try:
result = (
perms[Manifest.permission.ACCESS_BACKGROUND_LOCATION]
== PackageManager.PERMISSION_GRANTED
)
except KeyError: # pragma: no cover
# This shouldn't ever happen - we shouldn't get a completion of a
# location permission request that doesn't include location permissions
# - but just in case, we'll assume if it's not there, it failed.
result = False
future.set_result(result)

self.interface.app._impl.request_permissions(
[
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
],
on_complete=request_complete,
)

def current_location(self, result):
consumer = TogaLocationConsumer(self, result)
self.native.getCurrentLocation(
LocationManager.FUSED_PROVIDER,
None,
self.context.getMainExecutor(),
consumer,
)

def start_tracking(self):
# Start updates, with pings no more often than every 5 seconds, or 10 meters.
self.native.requestLocationUpdates(
LocationManager.FUSED_PROVIDER,
5000,
10,
self.listener,
)

def stop_tracking(self):
self.native.removeUpdates(self.listener)
6 changes: 6 additions & 0 deletions android/src/toga_android/widgets/mapview.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,18 @@ def get_location(self):
return LatLng(location.getLatitude(), location.getLongitude())

def set_location(self, position):
# If there are any outstanding animations, stop them, and force the view to the
# end state.
self.native.getController().stopAnimation(True)
self.native.getController().animateTo(GeoPoint(*position))

def get_zoom(self):
return self.native.getZoomLevelDouble()

def set_zoom(self, zoom):
# If there are any outstanding animations, stop them, and force the view to the
# end state.
self.native.getController().stopAnimation(True)
self.native.getController().zoomTo(zoom, None)

def add_pin(self, pin):
Expand Down
56 changes: 2 additions & 54 deletions android/tests_backend/hardware/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,17 @@
from unittest.mock import Mock

import pytest
from android.content.pm import PackageManager
from android.provider import MediaStore

from toga_android.app import App
from toga_android.hardware.camera import Camera

from ..app import AppProbe
from .hardware import HardwareProbe


class CameraProbe(AppProbe):
class CameraProbe(HardwareProbe):
allow_no_camera = False
request_permission_on_first_use = False

def __init__(self, monkeypatch, app_probe):
super().__init__(app_probe.app)

self.monkeypatch = monkeypatch

# A mocked permissions table. The key is the media type; the value is True
# if permission has been granted, False if it has be denied. A missing value
# will be turned into a grant if permission is requested.
self._mock_permissions = {}

# Mock App.startActivityForResult
self._mock_startActivityForResult = Mock()
monkeypatch.setattr(
App, "_native_startActivityForResult", self._mock_startActivityForResult
)

# Mock App.requestPermissions
def request_permissions(permissions, code):
grants = []
for permission in permissions:
status = self._mock_permissions.get(permission, 0)
self._mock_permissions[permission] = abs(status)
grants.append(
PackageManager.PERMISSION_GRANTED
if status
else PackageManager.PERMISSION_DENIED
)

app_probe.app._impl._listener.onRequestPermissionsResult(
code, permissions, grants
)

self._mock_requestPermissions = Mock(side_effect=request_permissions)
monkeypatch.setattr(
App, "_native_requestPermissions", self._mock_requestPermissions
)

# Mock ContextCompat.checkSelfPermission
def has_permission(context, permission):
return (
PackageManager.PERMISSION_GRANTED
if self._mock_permissions.get(permission, 0) == 1
else PackageManager.PERMISSION_DENIED
)

self._mock_checkSelfPermission = Mock(side_effect=has_permission)
monkeypatch.setattr(
Camera, "_native_checkSelfPermission", self._mock_checkSelfPermission
)

def cleanup(self):
# Ensure that after a test runs, there's no shared files.
shutil.rmtree(self.app.paths.cache / "shared", ignore_errors=True)
Expand Down
65 changes: 65 additions & 0 deletions android/tests_backend/hardware/hardware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from unittest.mock import Mock

from android.content.pm import PackageManager

from toga_android.app import App

from ..app import AppProbe


class HardwareProbe(AppProbe):

def __init__(self, monkeypatch, app_probe):
super().__init__(app_probe.app)

self.monkeypatch = monkeypatch

# A mocked permissions table. The key is the media type; the value is True
# if permission has been granted, False if it has be denied. A missing value
# will be turned into a grant if permission is requested.
self._mock_permissions = {}

# Mock App.startActivityForResult
self._mock_startActivityForResult = Mock()
monkeypatch.setattr(
App, "_native_startActivityForResult", self._mock_startActivityForResult
)

# Mock App.requestPermissions
def request_permissions(permissions, code):
grants = []
for permission in permissions:
status = self._mock_permissions.get(permission, 0)
self._mock_permissions[permission] = abs(status)
grants.append(
PackageManager.PERMISSION_GRANTED
if status
else PackageManager.PERMISSION_DENIED
)

app_probe.app._impl._listener.onRequestPermissionsResult(
code, permissions, grants
)

self._mock_requestPermissions = Mock(side_effect=request_permissions)
monkeypatch.setattr(
App, "_native_requestPermissions", self._mock_requestPermissions
)

# Mock ContextCompat.checkSelfPermission
def has_permission(permission):
return (
PackageManager.PERMISSION_GRANTED
if self._mock_permissions.get(permission, 0) == 1
else PackageManager.PERMISSION_DENIED
)

self._mock_checkSelfPermission = Mock(side_effect=has_permission)
monkeypatch.setattr(
app_probe.app._impl,
"_native_checkSelfPermission",
self._mock_checkSelfPermission,
)

def cleanup(self):
pass
Loading
Loading