Skip to content

Commit

Permalink
Merge pull request #2462 from freakboy3742/geolocation
Browse files Browse the repository at this point in the history
Add a geolocation service
  • Loading branch information
mhsmith authored Apr 22, 2024
2 parents 26fa129 + dbf2b4e commit a9f9716
Show file tree
Hide file tree
Showing 41 changed files with 2,157 additions and 114 deletions.
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

0 comments on commit a9f9716

Please sign in to comment.