diff --git a/changes/3085.feature.rst b/changes/3085.feature.rst new file mode 100644 index 0000000000..37d45bffb1 --- /dev/null +++ b/changes/3085.feature.rst @@ -0,0 +1 @@ +Extending location services with the more battery balanced modes like significant location change and reporting of visits. \ No newline at end of file diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 6ace32d11b..c899bcffbb 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -102,4 +102,4 @@ class WindowState(Enum): A good example is a slideshow app in presentation mode - the only visible content is the slide. - """ + """ \ No newline at end of file diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index 56f9801c0e..ae9d1010e7 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -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" @@ -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. diff --git a/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index bdf94500de..e257fa36c7 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -19,15 +19,22 @@ 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, } @@ -35,61 +42,100 @@ 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, @@ -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.")