Skip to content

Commit a9f9716

Browse files
authored
Merge pull request #2462 from freakboy3742/geolocation
Add a geolocation service
2 parents 26fa129 + dbf2b4e commit a9f9716

File tree

41 files changed

+2157
-114
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2157
-114
lines changed

android/src/toga_android/app.py

+7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from android.graphics.drawable import BitmapDrawable
77
from android.media import RingtoneManager
88
from android.view import Menu, MenuItem
9+
from androidx.core.content import ContextCompat
910
from java import dynamic_proxy
1011
from org.beeware.android import IPythonApp, MainActivity
1112

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

347348
self._native_startActivityForResult(activity, code, *options)
348349

350+
def _native_checkSelfPermission(self, permission): # pragma: no cover
351+
# A wrapper around the native method so that it can be mocked during testing.
352+
return ContextCompat.checkSelfPermission(
353+
self.native.getApplicationContext(), permission
354+
)
355+
349356
def _native_requestPermissions(self, permissions, code): # pragma: no cover
350357
# A wrapper around the native method so that it can be mocked during testing.
351358
self.native.requestPermissions(permissions, code)

android/src/toga_android/factory.py

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .command import Command
66
from .fonts import Font
77
from .hardware.camera import Camera
8+
from .hardware.location import Location
89
from .icons import Icon
910
from .images import Image
1011
from .paths import Paths
@@ -50,6 +51,7 @@ def not_implemented(feature):
5051
"Paths",
5152
# Hardware
5253
"Camera",
54+
"Location",
5355
# Widgets
5456
# ActivityIndicator
5557
"Box",

android/src/toga_android/hardware/camera.py

+3-7
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from android.content.pm import PackageManager
66
from android.hardware.camera2 import CameraCharacteristics
77
from android.provider import MediaStore
8-
from androidx.core.content import ContextCompat, FileProvider
8+
from androidx.core.content import FileProvider
99
from java.io import File
1010

1111
import toga
@@ -39,13 +39,9 @@ def __init__(self, interface):
3939
PackageManager.FEATURE_CAMERA
4040
)
4141

42-
def _native_checkSelfPermission(self, context, permission): # pragma: no cover
43-
# A wrapper around the native call so it can be mocked.
44-
return ContextCompat.checkSelfPermission(context, Camera.CAMERA_PERMISSION)
45-
4642
def has_permission(self):
47-
result = self._native_checkSelfPermission(
48-
self.context, Camera.CAMERA_PERMISSION
43+
result = self.interface.app._impl._native_checkSelfPermission(
44+
Camera.CAMERA_PERMISSION
4945
)
5046
return result == PackageManager.PERMISSION_GRANTED
5147

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
from __future__ import annotations
2+
3+
from android import Manifest
4+
from android.content import Context
5+
from android.content.pm import PackageManager
6+
from android.location import LocationListener, LocationManager
7+
from android.os import Build
8+
from java import dynamic_proxy
9+
from java.util import List
10+
from java.util.function import Consumer
11+
12+
from toga import LatLng
13+
14+
15+
def toga_location(location):
16+
"""Convert an Android location into a Toga LatLng and altitude."""
17+
latlng = LatLng(location.getLatitude(), location.getLongitude())
18+
19+
# MSL altitude was added in API 34. We can't test this at runtime
20+
if Build.VERSION.SDK_INT >= 34 and location.hasMslAltitude(): # pragma: no cover
21+
altitude = location.getMslAltitudeMeters()
22+
elif location.hasAltitude():
23+
altitude = location.getAltitude()
24+
else:
25+
altitude = None
26+
27+
return {
28+
"location": latlng,
29+
"altitude": altitude,
30+
}
31+
32+
33+
class TogaLocationConsumer(dynamic_proxy(Consumer)):
34+
def __init__(self, impl, result):
35+
super().__init__()
36+
self.impl = impl
37+
self.interface = impl.interface
38+
self.result = result
39+
40+
def accept(self, location):
41+
loc = toga_location(location)
42+
self.result.set_result(loc["location"])
43+
44+
45+
class TogaLocationListener(dynamic_proxy(LocationListener)):
46+
def __init__(self, impl):
47+
super().__init__()
48+
self.impl = impl
49+
self.interface = impl.interface
50+
51+
def onLocationChanged(self, location):
52+
if isinstance(location, List):
53+
location = location.get(location.size() - 1)
54+
55+
self.interface.on_change(**toga_location(location))
56+
57+
58+
class Location:
59+
def __init__(self, interface):
60+
self.interface = interface
61+
self.context = self.interface.app._impl.native.getApplicationContext()
62+
if not any(
63+
self.context.getPackageManager().hasSystemFeature(feature)
64+
for feature in [
65+
PackageManager.FEATURE_LOCATION,
66+
PackageManager.FEATURE_LOCATION_GPS,
67+
PackageManager.FEATURE_LOCATION_NETWORK,
68+
]
69+
): # pragma: no cover
70+
# The app doesn't have a feature supporting location services. No-cover
71+
# because we can't manufacture this condition in testing.
72+
raise RuntimeError("Location services are not available on this device.")
73+
74+
self.native = self.context.getSystemService(Context.LOCATION_SERVICE)
75+
self.listener = TogaLocationListener(self)
76+
77+
def has_permission(self):
78+
return (
79+
self.interface.app._impl._native_checkSelfPermission(
80+
Manifest.permission.ACCESS_COARSE_LOCATION
81+
)
82+
== PackageManager.PERMISSION_GRANTED
83+
) or (
84+
self.interface.app._impl._native_checkSelfPermission(
85+
Manifest.permission.ACCESS_FINE_LOCATION
86+
)
87+
== PackageManager.PERMISSION_GRANTED
88+
)
89+
90+
def has_background_permission(self):
91+
return (
92+
self.interface.app._impl._native_checkSelfPermission(
93+
Manifest.permission.ACCESS_BACKGROUND_LOCATION
94+
)
95+
== PackageManager.PERMISSION_GRANTED
96+
)
97+
98+
def request_permission(self, future):
99+
def request_complete(permissions, results):
100+
# Map the permissions to their result
101+
perms = dict(zip(permissions, results))
102+
try:
103+
result = (
104+
perms[Manifest.permission.ACCESS_COARSE_LOCATION]
105+
== PackageManager.PERMISSION_GRANTED
106+
) or (
107+
perms[Manifest.permission.ACCESS_FINE_LOCATION]
108+
== PackageManager.PERMISSION_GRANTED
109+
)
110+
except KeyError: # pragma: no cover
111+
# This shouldn't ever happen - we shouldn't get a completion of a
112+
# location permission request that doesn't include location permissions
113+
# - but just in case, we'll assume if it's not there, it failed.
114+
result = False
115+
future.set_result(result)
116+
117+
self.interface.app._impl.request_permissions(
118+
[
119+
Manifest.permission.ACCESS_COARSE_LOCATION,
120+
Manifest.permission.ACCESS_FINE_LOCATION,
121+
],
122+
on_complete=request_complete,
123+
)
124+
125+
def request_background_permission(self, future):
126+
def request_complete(permissions, results):
127+
# Map the permissions to their result
128+
perms = dict(zip(permissions, results))
129+
try:
130+
result = (
131+
perms[Manifest.permission.ACCESS_BACKGROUND_LOCATION]
132+
== PackageManager.PERMISSION_GRANTED
133+
)
134+
except KeyError: # pragma: no cover
135+
# This shouldn't ever happen - we shouldn't get a completion of a
136+
# location permission request that doesn't include location permissions
137+
# - but just in case, we'll assume if it's not there, it failed.
138+
result = False
139+
future.set_result(result)
140+
141+
self.interface.app._impl.request_permissions(
142+
[
143+
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
144+
],
145+
on_complete=request_complete,
146+
)
147+
148+
def current_location(self, result):
149+
consumer = TogaLocationConsumer(self, result)
150+
self.native.getCurrentLocation(
151+
LocationManager.FUSED_PROVIDER,
152+
None,
153+
self.context.getMainExecutor(),
154+
consumer,
155+
)
156+
157+
def start_tracking(self):
158+
# Start updates, with pings no more often than every 5 seconds, or 10 meters.
159+
self.native.requestLocationUpdates(
160+
LocationManager.FUSED_PROVIDER,
161+
5000,
162+
10,
163+
self.listener,
164+
)
165+
166+
def stop_tracking(self):
167+
self.native.removeUpdates(self.listener)

android/src/toga_android/widgets/mapview.py

+6
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,18 @@ def get_location(self):
7979
return LatLng(location.getLatitude(), location.getLongitude())
8080

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

8487
def get_zoom(self):
8588
return self.native.getZoomLevelDouble()
8689

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

9096
def add_pin(self, pin):

android/tests_backend/hardware/camera.py

+2-54
Original file line numberDiff line numberDiff line change
@@ -2,69 +2,17 @@
22
from unittest.mock import Mock
33

44
import pytest
5-
from android.content.pm import PackageManager
65
from android.provider import MediaStore
76

8-
from toga_android.app import App
97
from toga_android.hardware.camera import Camera
108

11-
from ..app import AppProbe
9+
from .hardware import HardwareProbe
1210

1311

14-
class CameraProbe(AppProbe):
12+
class CameraProbe(HardwareProbe):
1513
allow_no_camera = False
1614
request_permission_on_first_use = False
1715

18-
def __init__(self, monkeypatch, app_probe):
19-
super().__init__(app_probe.app)
20-
21-
self.monkeypatch = monkeypatch
22-
23-
# A mocked permissions table. The key is the media type; the value is True
24-
# if permission has been granted, False if it has be denied. A missing value
25-
# will be turned into a grant if permission is requested.
26-
self._mock_permissions = {}
27-
28-
# Mock App.startActivityForResult
29-
self._mock_startActivityForResult = Mock()
30-
monkeypatch.setattr(
31-
App, "_native_startActivityForResult", self._mock_startActivityForResult
32-
)
33-
34-
# Mock App.requestPermissions
35-
def request_permissions(permissions, code):
36-
grants = []
37-
for permission in permissions:
38-
status = self._mock_permissions.get(permission, 0)
39-
self._mock_permissions[permission] = abs(status)
40-
grants.append(
41-
PackageManager.PERMISSION_GRANTED
42-
if status
43-
else PackageManager.PERMISSION_DENIED
44-
)
45-
46-
app_probe.app._impl._listener.onRequestPermissionsResult(
47-
code, permissions, grants
48-
)
49-
50-
self._mock_requestPermissions = Mock(side_effect=request_permissions)
51-
monkeypatch.setattr(
52-
App, "_native_requestPermissions", self._mock_requestPermissions
53-
)
54-
55-
# Mock ContextCompat.checkSelfPermission
56-
def has_permission(context, permission):
57-
return (
58-
PackageManager.PERMISSION_GRANTED
59-
if self._mock_permissions.get(permission, 0) == 1
60-
else PackageManager.PERMISSION_DENIED
61-
)
62-
63-
self._mock_checkSelfPermission = Mock(side_effect=has_permission)
64-
monkeypatch.setattr(
65-
Camera, "_native_checkSelfPermission", self._mock_checkSelfPermission
66-
)
67-
6816
def cleanup(self):
6917
# Ensure that after a test runs, there's no shared files.
7018
shutil.rmtree(self.app.paths.cache / "shared", ignore_errors=True)
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from unittest.mock import Mock
2+
3+
from android.content.pm import PackageManager
4+
5+
from toga_android.app import App
6+
7+
from ..app import AppProbe
8+
9+
10+
class HardwareProbe(AppProbe):
11+
12+
def __init__(self, monkeypatch, app_probe):
13+
super().__init__(app_probe.app)
14+
15+
self.monkeypatch = monkeypatch
16+
17+
# A mocked permissions table. The key is the media type; the value is True
18+
# if permission has been granted, False if it has be denied. A missing value
19+
# will be turned into a grant if permission is requested.
20+
self._mock_permissions = {}
21+
22+
# Mock App.startActivityForResult
23+
self._mock_startActivityForResult = Mock()
24+
monkeypatch.setattr(
25+
App, "_native_startActivityForResult", self._mock_startActivityForResult
26+
)
27+
28+
# Mock App.requestPermissions
29+
def request_permissions(permissions, code):
30+
grants = []
31+
for permission in permissions:
32+
status = self._mock_permissions.get(permission, 0)
33+
self._mock_permissions[permission] = abs(status)
34+
grants.append(
35+
PackageManager.PERMISSION_GRANTED
36+
if status
37+
else PackageManager.PERMISSION_DENIED
38+
)
39+
40+
app_probe.app._impl._listener.onRequestPermissionsResult(
41+
code, permissions, grants
42+
)
43+
44+
self._mock_requestPermissions = Mock(side_effect=request_permissions)
45+
monkeypatch.setattr(
46+
App, "_native_requestPermissions", self._mock_requestPermissions
47+
)
48+
49+
# Mock ContextCompat.checkSelfPermission
50+
def has_permission(permission):
51+
return (
52+
PackageManager.PERMISSION_GRANTED
53+
if self._mock_permissions.get(permission, 0) == 1
54+
else PackageManager.PERMISSION_DENIED
55+
)
56+
57+
self._mock_checkSelfPermission = Mock(side_effect=has_permission)
58+
monkeypatch.setattr(
59+
app_probe.app._impl,
60+
"_native_checkSelfPermission",
61+
self._mock_checkSelfPermission,
62+
)
63+
64+
def cleanup(self):
65+
pass

0 commit comments

Comments
 (0)