Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changes/3085.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Extending location services with the more battery balanced modes like significant location change and reporting of visits.
2 changes: 1 addition & 1 deletion core/src/toga/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,4 @@ class WindowState(Enum):

A good example is a slideshow app in presentation mode - the only visible content
is the slide.
"""
"""
34 changes: 30 additions & 4 deletions core/src/toga/hardware/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,17 +140,27 @@ def on_change(self) -> OnLocationChangeHandler:
def on_change(self, handler: OnLocationChangeHandler) -> None:
self._on_change = wrapped_handler(self, handler)

def start_tracking(self) -> None:
@property
def on_visit(self):
return self._on_visit

@on_visit.setter
def on_visit(self, handler):
self._on_visit = wrapped_handler(self, handler)

def start_tracking(self, significant=False) -> None:
"""Start monitoring the user's location for changes.

An :any:`on_change` callback will be generated when the user's location
changes.

:raises PermissionError: If the app has not requested and received permission to
use location services.
"""
if self.has_permission:
self._impl.start_tracking()
if not significant:
self._impl.start_tracking()
elif significant:
self._impl.start_significant_tracking()

else:
raise PermissionError(
"App does not have permission to use location services"
Expand All @@ -169,6 +179,22 @@ def stop_tracking(self) -> None:
"App does not have permission to use location services"
)

def start_visit_tracking(self):
if hasattr(self._impl, "start_visit_tracking"):
self._impl.start_visit_tracking()
else:
raise NotImplementedError(
"Visit tracking is not available on this platform."
)

def stop_visit_tracking(self):
if hasattr(self._impl, "stop_visit_tracking"):
self._impl.stop_visit_tracking()
else:
raise NotImplementedError(
"Visit tracking is not available on this platform."
)

def current_location(self) -> LocationResult:
"""Obtain the user's current location using the location service.

Expand Down
175 changes: 135 additions & 40 deletions iOS/src/toga_iOS/hardware/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,77 +19,123 @@ def toga_location(location):
location.coordinate.longitude,
)

# A vertical accuracy that non-positive indicates altitude is invalid.
if location.verticalAccuracy > 0.0:
altitude = location.altitude
else:
altitude = None
altitude = location.altitude if location.verticalAccuracy > 0 else None
return {"location": latlng, "altitude": altitude}


def toga_visit(visit):
"""Convert a Cocoa visit into a Toga LatLng and structured data."""
latlng = LatLng(
visit.coordinate.latitude,
visit.coordinate.longitude,
)

return {
"location": latlng,
"altitude": altitude,
"arrivalDate": visit.arrivalDate,
"departureDate": visit.departureDate or None,
"accuracy": visit.horizontalAccuracy,
}


class TogaLocationDelegate(NSObject):
interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)

# ------------------------------------------------------------------
# Permission changes
# ------------------------------------------------------------------
@objc_method
def locationManagerDidChangeAuthorization_(self, manager) -> None:
while self.impl.permission_requests:
future, permission = self.impl.permission_requests.pop()
future.set_result(permission())

# ------------------------------------------------------------------
# Location updates (standard *and* opportunistic)
# ------------------------------------------------------------------
@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
# Resolve any pending one‑shot requests
while self.impl.current_location_requests:
future = self.impl.current_location_requests.pop()
future.set_result(toga_loc["location"])

# If we're tracking, notify the change listener of the last location reported
# Forward to app callback if tracking flag is set
if self.impl._is_tracking:
self.interface.on_change(**toga_loc)

# ------------------------------------------------------------------
# Visit updates
# ------------------------------------------------------------------
@objc_method
def locationManager_didVisit_(self, manager, visit) -> None:
visit_data = toga_visit(visit)
if self.interface.on_visit:
self.interface.on_visit(
location=visit_data["location"],
altitude=None,
type="visit",
arrival_time=visit_data["arrivalDate"].timeIntervalSince1970(),
departure_time=(
visit_data["departureDate"].timeIntervalSince1970()
if visit_data["departureDate"]
else None
),
accuracy=visit_data["accuracy"],
)

# ------------------------------------------------------------------
# Error handler
# ------------------------------------------------------------------
@objc_method
def locationManager_didFailWithError_(self, manager, error) -> None:
# Cancel all outstanding location requests.
while self.impl.current_location_requests:
future = self.impl.current_location_requests.pop()
future.set_exception(RuntimeError(f"Unable to obtain a location ({error})"))
future.set_exception(
RuntimeError(f"Unable to obtain location ({error})")
)


# ======================================================================
# Location backend (iOS)
# ======================================================================

class Location:
"""Original Toga iOS Location, plus *opportunistic* pig‑back listener."""

def __init__(self, interface):
self.interface = interface
if NSBundle.mainBundle.objectForInfoDictionaryKey(

if not NSBundle.mainBundle.objectForInfoDictionaryKey(
"NSLocationWhenInUseUsageDescription"
):
self.native = iOS.CLLocationManager.alloc().init()
self.delegate = TogaLocationDelegate.alloc().init()
self.native.delegate = self.delegate
self.delegate.interface = interface
self.delegate.impl = self
self._is_tracking = False

else: # pragma: no cover
# The app doesn't have the NSLocationWhenInUseUsageDescription key (e.g.,
# via `permission.*_location` in Briefcase). No-cover because we can't
# manufacture this condition in testing.
raise RuntimeError(
"Application metadata does not declare that "
"the app will use the camera."
"Application metadata lacks NSLocationWhenInUseUsageDescription key."
)

# Tracking of futures associated with specific requests.
# Primary manager (standard, SLC, visits)
self.native = iOS.CLLocationManager.alloc().init()
self.delegate = TogaLocationDelegate.alloc().init()
self.native.delegate = self.delegate
self.delegate.interface = interface
self.delegate.impl = self

# NEW: holder for ultra‑low‑power listener
self._passive_mgr = None

self._is_tracking = False
self.significant = False

# Futures tracking
self.permission_requests = []
self.current_location_requests = []

# ------------------------------------------------------------------
# Permission helpers
# ------------------------------------------------------------------
def has_permission(self):
return self.native.authorizationStatus in {
CLAuthorizationStatus.AuthorizedWhenInUse.value,
Expand All @@ -111,36 +157,85 @@ def request_background_permission(self, future):
"NSLocationAlwaysAndWhenInUseUsageDescription"
):
self.permission_requests.append((future, self.has_background_permission))

self.native.requestAlwaysAuthorization()
else: # pragma: no cover
# The app doesn't have the NSLocationAlwaysAndWhenInUseUsageDescription key
# (e.g., via `permission.background_location` in Briefcase). No-cover
# because we can't manufacture this condition in testing.
else:
future.set_exception(
RuntimeError(
"Application metadata does not declare that "
"the app will use the camera."
"Info.plist missing NSLocationAlwaysAndWhenInUseUsageDescription"
)
)

# ------------------------------------------------------------------
# One‑shot current location
# ------------------------------------------------------------------
def current_location(self, result):
location = self.native.location
if location is None:
loc = self.native.location
if loc is None:
self.current_location_requests.append(result)
self.native.requestLocation()
else:
toga_loc = toga_location(location)
result.set_result(toga_loc["location"])
result.set_result(toga_location(loc)["location"])

# ------------------------------------------------------------------
# High‑accuracy continuous tracking
# ------------------------------------------------------------------
def start_tracking(self):
# Ensure that background processing will occur
self.native.allowsBackgroundLocationUpdates = True
self.native.pausesLocationUpdatesAutomatically = False

self._is_tracking = True
self.significant = False
self.native.startUpdatingLocation()

def stop_tracking(self):
self.native.stopUpdatingLocation()
self._is_tracking = False
if not self.significant:
self.native.stopUpdatingLocation()
else:
self.native.stopMonitoringSignificantLocationChanges()

# ------------------------------------------------------------------
# Significant‑change + Visit monitoring
# ------------------------------------------------------------------
def start_significant_tracking(self):
self.native.allowsBackgroundLocationUpdates = True
self.native.pausesLocationUpdatesAutomatically = False

self._is_tracking = True
self.significant = True
self.native.startMonitoringSignificantLocationChanges()

def start_visit_tracking(self):
self.native.allowsBackgroundLocationUpdates = True
self.native.pausesLocationUpdatesAutomatically = False

self._is_tracking = True
self.native.startMonitoringVisits()

# ------------------------------------------------------------------
# NEW – Opportunistic listener (zero‑cost pig‑back)
# ------------------------------------------------------------------
def start_opportunistic_tracking(self):
"""Receive *every* fix Core Location produces for any app without
powering GPS ourselves (desiredAccuracy = 3 km). Call once after
"Always" permission is granted."""
if self._passive_mgr is not None:
return # already running

mgr = iOS.CLLocationManager.alloc().init()
mgr.delegate = self.delegate # share same delegate
mgr.desiredAccuracy = 3000.0 # kCLLocationAccuracyThreeKilometers
mgr.distanceFilter = 0 # kCLDistanceFilterNone – deliver all fixes
mgr.activityType = 6 # CLActivityTypeOtherNavigation
mgr.allowsBackgroundLocationUpdates = True
mgr.pausesLocationUpdatesAutomatically = True
mgr.startUpdatingLocation()

self._passive_mgr = mgr
print("[iOS] Opportunistic listener started.")

def stop_opportunistic_tracking(self):
if self._passive_mgr is not None:
self._passive_mgr.stopUpdatingLocation()
self._passive_mgr = None
print("[iOS] Opportunistic listener stopped.")
Loading