From 9f4d5ced36f5ee9252a57c4c843e6af39e716544 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 9 Feb 2025 21:36:33 +0100 Subject: [PATCH 1/8] Adressing core changes --- core/src/toga/constants/__init__.py | 30 +++++++++++++++++++++++++++++ core/src/toga/hardware/location.py | 16 +++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 6ace32d11b..d9595f4725 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -103,3 +103,33 @@ class WindowState(Enum): A good example is a slideshow app in presentation mode - the only visible content is the slide. """ + + +class LocationMode(Enum): + """The possible options to start tracking.""" + + CONTINUOUS = 0 + """ + The "CONTINUOUS" tracking mode provides real-time, ongoing location updates. + This mode uses standard (continuous) location services and is generally + the most resource-intensive but provides the highest frequency and accuracy + of updates. + """ + + SIGNIFICANT = 1 + """ + The "SIGNIFICANT" tracking mode uses the significant-change location service + to trigger updates only when the device has moved a significant distance + (such as 500 meters) or has switched cell towers. This mode is less + resource-intensive than continuous tracking but offers lower granularity. + """ + + VISITS = 2 + """ + The "VISITS" tracking mode provides location updates based on significant + 'visit' events. The system automatically determines when the user arrives at + or departs from a place of interest and delivers location updates only at + those transition points. + NOTE: Currently only supported on iOS and MacOS + + """ diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index 56f9801c0e..a7cf1a5b02 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, Protocol import toga +from toga.constants import LocationMode from toga.handlers import AsyncResult, PermissionResult, wrapped_handler from toga.platform import get_platform_factory @@ -140,7 +141,7 @@ def on_change(self) -> OnLocationChangeHandler: def on_change(self, handler: OnLocationChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) - def start_tracking(self) -> None: + def start_tracking(self, location_mode) -> None: """Start monitoring the user's location for changes. An :any:`on_change` callback will be generated when the user's location @@ -150,7 +151,18 @@ def start_tracking(self) -> None: use location services. """ if self.has_permission: - self._impl.start_tracking() + if location_mode == LocationMode.CONTINUOUS: + self._impl.start_tracking() + elif location_mode == LocationMode.SIGNIFICANT: + self._impl.start_significant_tracking() + elif location_mode == LocationMode.VISITS: + self._impl.start_visits_tracking() + else: + raise ValueError( + f"Invalid mode: {location_mode}. Must be one of CONTINUOUS, " + f"VISITS, SIGNIFICANT." + ) + else: raise PermissionError( "App does not have permission to use location services" From 12ddfc2e395aaf311468c448ec1d4120b7edcfff Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 9 Feb 2025 21:48:12 +0100 Subject: [PATCH 2/8] making the changes for ios --- core/src/toga/constants/__init__.py | 1 - iOS/src/toga_iOS/hardware/location.py | 59 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index d9595f4725..560bc86799 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -131,5 +131,4 @@ class LocationMode(Enum): or departs from a place of interest and delivers location updates only at those transition points. NOTE: Currently only supported on iOS and MacOS - """ diff --git a/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index bdf94500de..c6eadba33f 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -3,6 +3,7 @@ from rubicon.objc import NSObject, objc_method, objc_property from toga import LatLng +from toga.constants import LocationMode # for classes that need to be monkeypatched for testing from toga_iOS import libs as iOS @@ -56,6 +57,33 @@ def locationManager_didUpdateLocations_(self, manager, locations) -> None: if self.impl._is_tracking: self.interface.on_change(**toga_loc) + @objc_method + def locationManager_didVisit_(self, manager, visit) -> None: + """ + Handles visit events and sends detailed data to the API. + """ + latitude = visit.coordinate().latitude + longitude = visit.coordinate().longitude + arrival_time = visit.arrivalDate().timeIntervalSince1970 + departure_time = ( + visit.departureDate().timeIntervalSince1970 + if visit.departureDate() + else None + ) + accuracy = visit.horizontalAccuracy # Accuracy of visit detection + + loc = LatLng(latitude, longitude) + + if self.interface.on_change: + self.interface.on_change( + location=loc, + altitude=None, + type="visit", + arrival_time=arrival_time, + departure_time=departure_time, + accuracy=accuracy, + ) + @objc_method def locationManager_didFailWithError_(self, manager, error) -> None: # Cancel all outstanding location requests. @@ -76,6 +104,7 @@ def __init__(self, interface): self.delegate.interface = interface self.delegate.impl = self self._is_tracking = False + self.tracking_mode = None else: # pragma: no cover # The app doesn't have the NSLocationWhenInUseUsageDescription key (e.g., @@ -139,8 +168,36 @@ def start_tracking(self): self.native.pausesLocationUpdatesAutomatically = False self._is_tracking = True + self.tracking_mode = LocationMode.CONTINUOUS self.native.startUpdatingLocation() + def start_significant_tracking(self) -> None: + """Start monitoring significant location changes.""" + # Ensure that background processing will occur + self.native.allowsBackgroundLocationUpdates = True + self.native.pausesLocationUpdatesAutomatically = False + + self._is_tracking = True + self.tracking_mode = LocationMode.SIGNIFICANT + self.native.startMonitoringSignificantLocationChanges() + + def start_visit_tracking(self) -> None: + """Start monitoring visits (CLVisit events).""" + # Ensure that background processing will occur + self.native.allowsBackgroundLocationUpdates = True + self.native.pausesLocationUpdatesAutomatically = False + + self._is_tracking = True + self.tracking_mode = LocationMode.VISITS + self.native.startMonitoringVisits() + def stop_tracking(self): - self.native.stopUpdatingLocation() self._is_tracking = False + if self.tracking_mode == LocationMode.CONTINUOUS: + self.native.stopUpdatingLocation() + elif self.tracking_mode == LocationMode.SIGNIFICANT: + self.native.stopMonitoringSignificantLocationChanges() + elif self.tracking_mode == LocationMode.VISITS: + self.native.stopMonitoringVisits() + else: + return From 8eadeea498367533ce2c5b86bae45c09290d953f Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 9 Feb 2025 21:56:13 +0100 Subject: [PATCH 3/8] adding change notes --- changes/3085.feature.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changes/3085.feature.rst diff --git a/changes/3085.feature.rst b/changes/3085.feature.rst new file mode 100644 index 0000000000..3de1369eaf --- /dev/null +++ b/changes/3085.feature.rst @@ -0,0 +1,2 @@ +Extending location services with the more battery balanced modes like significant location change and reporting of +visits. From 57be476337d19434b6f1ba44ea18ab86690c4ad3 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Sun, 9 Feb 2025 22:00:43 +0100 Subject: [PATCH 4/8] Setting continuous as standard mode for tracking --- core/src/toga/hardware/location.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index a7cf1a5b02..95924c2523 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -141,7 +141,7 @@ def on_change(self) -> OnLocationChangeHandler: def on_change(self, handler: OnLocationChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) - def start_tracking(self, location_mode) -> None: + def start_tracking(self, location_mode=LocationMode.CONTINUOUS) -> None: """Start monitoring the user's location for changes. An :any:`on_change` callback will be generated when the user's location From 96c20ef280a1aae2e4d8d6875cbaad2cd31c3661 Mon Sep 17 00:00:00 2001 From: "robin.kolk" Date: Wed, 12 Mar 2025 12:37:46 +0100 Subject: [PATCH 5/8] Added visit tracking and significant tracking according to discussed principle. For now iOS only. --- core/src/toga/constants/__init__.py | 31 +-------------- core/src/toga/hardware/location.py | 37 +++++++++++------- iOS/src/toga_iOS/hardware/location.py | 56 +++++++++++++-------------- 3 files changed, 52 insertions(+), 72 deletions(-) diff --git a/core/src/toga/constants/__init__.py b/core/src/toga/constants/__init__.py index 560bc86799..c899bcffbb 100644 --- a/core/src/toga/constants/__init__.py +++ b/core/src/toga/constants/__init__.py @@ -102,33 +102,4 @@ class WindowState(Enum): A good example is a slideshow app in presentation mode - the only visible content is the slide. - """ - - -class LocationMode(Enum): - """The possible options to start tracking.""" - - CONTINUOUS = 0 - """ - The "CONTINUOUS" tracking mode provides real-time, ongoing location updates. - This mode uses standard (continuous) location services and is generally - the most resource-intensive but provides the highest frequency and accuracy - of updates. - """ - - SIGNIFICANT = 1 - """ - The "SIGNIFICANT" tracking mode uses the significant-change location service - to trigger updates only when the device has moved a significant distance - (such as 500 meters) or has switched cell towers. This mode is less - resource-intensive than continuous tracking but offers lower granularity. - """ - - VISITS = 2 - """ - The "VISITS" tracking mode provides location updates based on significant - 'visit' events. The system automatically determines when the user arrives at - or departs from a place of interest and delivers location updates only at - those transition points. - NOTE: Currently only supported on iOS and MacOS - """ + """ \ No newline at end of file diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index 95924c2523..95e57ea351 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, Any, Protocol import toga -from toga.constants import LocationMode from toga.handlers import AsyncResult, PermissionResult, wrapped_handler from toga.platform import get_platform_factory @@ -141,27 +140,26 @@ def on_change(self) -> OnLocationChangeHandler: def on_change(self, handler: OnLocationChangeHandler) -> None: self._on_change = wrapped_handler(self, handler) - def start_tracking(self, location_mode=LocationMode.CONTINUOUS) -> 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: - if location_mode == LocationMode.CONTINUOUS: + if not significant: self._impl.start_tracking() - elif location_mode == LocationMode.SIGNIFICANT: + elif significant: self._impl.start_significant_tracking() - elif location_mode == LocationMode.VISITS: - self._impl.start_visits_tracking() - else: - raise ValueError( - f"Invalid mode: {location_mode}. Must be one of CONTINUOUS, " - f"VISITS, SIGNIFICANT." - ) else: raise PermissionError( @@ -181,6 +179,19 @@ 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 c6eadba33f..b9afa2714c 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -3,7 +3,6 @@ from rubicon.objc import NSObject, objc_method, objc_property from toga import LatLng -from toga.constants import LocationMode # for classes that need to be monkeypatched for testing from toga_iOS import libs as iOS @@ -31,6 +30,19 @@ def toga_location(location): "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, + "arrivalDate": visit.arrivalDate, + "departureDate": visit.departureDate if visit.departureDate else None, + "accuracy": visit.horizontalAccuracy, + } class TogaLocationDelegate(NSObject): interface = objc_property(object, weak=True) @@ -59,29 +71,18 @@ def locationManager_didUpdateLocations_(self, manager, locations) -> None: @objc_method def locationManager_didVisit_(self, manager, visit) -> None: - """ - Handles visit events and sends detailed data to the API. - """ - latitude = visit.coordinate().latitude - longitude = visit.coordinate().longitude - arrival_time = visit.arrivalDate().timeIntervalSince1970 - departure_time = ( - visit.departureDate().timeIntervalSince1970 - if visit.departureDate() - else None - ) - accuracy = visit.horizontalAccuracy # Accuracy of visit detection - - loc = LatLng(latitude, longitude) + """Handles visit events and sends detailed data to the API.""" + toga_visit_data = toga_visit(visit) - if self.interface.on_change: - self.interface.on_change( - location=loc, + if self.interface.on_visit: + self.interface.on_visit( + location=toga_visit_data["location"], altitude=None, type="visit", - arrival_time=arrival_time, - departure_time=departure_time, - accuracy=accuracy, + arrival_time=toga_visit_data["arrivalDate"].timeIntervalSince1970(), + departure_time=toga_visit_data["departureDate"].timeIntervalSince1970() if toga_visit_data[ + "departureDate"] else None, + accuracy=toga_visit_data["accuracy"] ) @objc_method @@ -104,7 +105,7 @@ def __init__(self, interface): self.delegate.interface = interface self.delegate.impl = self self._is_tracking = False - self.tracking_mode = None + self.significant = None else: # pragma: no cover # The app doesn't have the NSLocationWhenInUseUsageDescription key (e.g., @@ -168,7 +169,7 @@ def start_tracking(self): self.native.pausesLocationUpdatesAutomatically = False self._is_tracking = True - self.tracking_mode = LocationMode.CONTINUOUS + self.significant = False self.native.startUpdatingLocation() def start_significant_tracking(self) -> None: @@ -178,7 +179,7 @@ def start_significant_tracking(self) -> None: self.native.pausesLocationUpdatesAutomatically = False self._is_tracking = True - self.tracking_mode = LocationMode.SIGNIFICANT + self.significant = True self.native.startMonitoringSignificantLocationChanges() def start_visit_tracking(self) -> None: @@ -188,16 +189,13 @@ def start_visit_tracking(self) -> None: self.native.pausesLocationUpdatesAutomatically = False self._is_tracking = True - self.tracking_mode = LocationMode.VISITS self.native.startMonitoringVisits() def stop_tracking(self): self._is_tracking = False - if self.tracking_mode == LocationMode.CONTINUOUS: + if not self.significant: self.native.stopUpdatingLocation() - elif self.tracking_mode == LocationMode.SIGNIFICANT: + elif self.significant: self.native.stopMonitoringSignificantLocationChanges() - elif self.tracking_mode == LocationMode.VISITS: - self.native.stopMonitoringVisits() else: return From ad87133faa2fbbeb1afb01ede4b1437681976136 Mon Sep 17 00:00:00 2001 From: "robin.kolk" Date: Wed, 12 Mar 2025 12:40:58 +0100 Subject: [PATCH 6/8] Solving earlier raised feedback --- changes/3085.feature.rst | 3 +-- iOS/src/toga_iOS/hardware/location.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/changes/3085.feature.rst b/changes/3085.feature.rst index 3de1369eaf..37d45bffb1 100644 --- a/changes/3085.feature.rst +++ b/changes/3085.feature.rst @@ -1,2 +1 @@ -Extending location services with the more battery balanced modes like significant location change and reporting of -visits. +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/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index b9afa2714c..2ee7263f28 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -195,7 +195,5 @@ def stop_tracking(self): self._is_tracking = False if not self.significant: self.native.stopUpdatingLocation() - elif self.significant: - self.native.stopMonitoringSignificantLocationChanges() else: - return + self.native.stopMonitoringSignificantLocationChanges() From 2ff0b8d88697967c93009a5a155fa629f4042d8e Mon Sep 17 00:00:00 2001 From: "robin.kolk" Date: Wed, 12 Mar 2025 13:20:37 +0100 Subject: [PATCH 7/8] solving pre-commit issues --- core/src/toga/hardware/location.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/src/toga/hardware/location.py b/core/src/toga/hardware/location.py index 95e57ea351..ae9d1010e7 100644 --- a/core/src/toga/hardware/location.py +++ b/core/src/toga/hardware/location.py @@ -183,14 +183,17 @@ 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.") + 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.") - + 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. From 046d13b42749e2d8601672a3b31081e7d58df326 Mon Sep 17 00:00:00 2001 From: Robin Kolk Date: Mon, 26 May 2025 22:13:15 +0200 Subject: [PATCH 8/8] Update location.py --- iOS/src/toga_iOS/hardware/location.py | 170 ++++++++++++++++---------- 1 file changed, 106 insertions(+), 64 deletions(-) diff --git a/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index 2ee7263f28..e257fa36c7 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -19,16 +19,9 @@ 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} - return { - "location": latlng, - "altitude": altitude, - } def toga_visit(visit): """Convert a Cocoa visit into a Toga LatLng and structured data.""" @@ -40,86 +33,109 @@ def toga_visit(visit): return { "location": latlng, "arrivalDate": visit.arrivalDate, - "departureDate": visit.departureDate if visit.departureDate else None, + "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: - """Handles visit events and sends detailed data to the API.""" - toga_visit_data = toga_visit(visit) - + visit_data = toga_visit(visit) if self.interface.on_visit: self.interface.on_visit( - location=toga_visit_data["location"], + location=visit_data["location"], altitude=None, type="visit", - arrival_time=toga_visit_data["arrivalDate"].timeIntervalSince1970(), - departure_time=toga_visit_data["departureDate"].timeIntervalSince1970() if toga_visit_data[ - "departureDate"] else None, - accuracy=toga_visit_data["accuracy"] + 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 - self.significant = None - - 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, @@ -141,30 +157,29 @@ 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 @@ -172,9 +187,17 @@ def start_tracking(self): self.significant = False self.native.startUpdatingLocation() - def start_significant_tracking(self) -> None: - """Start monitoring significant location changes.""" - # Ensure that background processing will occur + def stop_tracking(self): + 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 @@ -182,18 +205,37 @@ def start_significant_tracking(self) -> None: self.significant = True self.native.startMonitoringSignificantLocationChanges() - def start_visit_tracking(self) -> None: - """Start monitoring visits (CLVisit events).""" - # Ensure that background processing will occur + def start_visit_tracking(self): self.native.allowsBackgroundLocationUpdates = True self.native.pausesLocationUpdatesAutomatically = False self._is_tracking = True self.native.startMonitoringVisits() - def stop_tracking(self): - self._is_tracking = False - if not self.significant: - self.native.stopUpdatingLocation() - else: - self.native.stopMonitoringSignificantLocationChanges() + # ------------------------------------------------------------------ + # 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.")