From d87c6b39db38c1421795c4ca044403655fbfb899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Mon, 19 Oct 2026 14:02:24 +0200 Subject: [PATCH 1/3] geolocator_watchos 0.1.2: accuracy, service status, workout settings getLocationAccuracy reports accuracyAuthorization. requestTemporaryFullAccuracy asks with a purpose key and waits for the answer; without NSLocationTemporaryUsageDescriptionDictionary it throws PermissionDefinitionsNotFoundException, as geolocator does on iOS. getServiceStatusStream starts with the current status, then each change CoreLocation signals. AppleSettings now reach CoreLocation, read through toJson() so the package does not depend on geolocator_apple: activityType (ActivityType.fitness for a run) and allowBackgroundLocationUpdates, which turns on only when the Info.plist has the location background mode, since CoreLocation treats it as a fatal error otherwise. Checked: 21 host tests (10 new), a Simulator build with every ffiSymbols entry exported, and the example on the watchOS 27.0 Simulator returning the simulated fix after the permission prompt. The temporary-accuracy prompt and background updates are not yet checked on a physical watch. Co-Authored-By: Claude Opus 5.5 --- packages/geolocator_watchos/CHANGELOG.md | 28 +++ packages/geolocator_watchos/PORTING_REPORT.md | 9 +- packages/geolocator_watchos/README.md | 34 +++- .../lib/geolocator_watchos.dart | 183 +++++++++++++++-- packages/geolocator_watchos/pubspec.yaml | 5 +- .../test/geolocator_watchos_test.dart | 188 +++++++++++++++++- .../watchos/Classes/geolocator_watchos_ffi.h | 36 +++- .../watchos/Classes/geolocator_watchos_ffi.m | 73 ++++++- 8 files changed, 514 insertions(+), 42 deletions(-) diff --git a/packages/geolocator_watchos/CHANGELOG.md b/packages/geolocator_watchos/CHANGELOG.md index 237eeee..e66f3fc 100644 --- a/packages/geolocator_watchos/CHANGELOG.md +++ b/packages/geolocator_watchos/CHANGELOG.md @@ -1,3 +1,31 @@ +## 0.1.2 + +**Added** + +* `getLocationAccuracy` reports `accuracyAuthorization`: precise or reduced. +* `requestTemporaryFullAccuracy` asks for full accuracy with a purpose key and + waits for the answer. Without `NSLocationTemporaryUsageDescriptionDictionary` + in the `Info.plist` it throws `PermissionDefinitionsNotFoundException`, as + geolocator does on iOS. +* `getServiceStatusStream` starts with the current status, then reports each + change CoreLocation signals. +* `AppleSettings` passed to `getPositionStream` or `getCurrentPosition` now + reach CoreLocation, read through `toJson()`: `activityType` (for example + `ActivityType.fitness` for a run) and `allowBackgroundLocationUpdates`. The + latter turns on only when the `Info.plist` has the `location` background + mode, because CoreLocation treats it as a fatal error otherwise. +* Three FFI exports: `geolocator_watchos_accuracy_authorization`, + `_request_temporary_full_accuracy` and `_accuracy_requests_done`. + `geolocator_watchos_start_updates` takes the activity type and the + background flag. + +**Docs** + +* README and PORTING_REPORT: the three members move out of "Not supported on + watchOS"; a section on workouts and background updates. +* Comments no longer say that Dart polls the cached fix: the delegate wakes + Dart. + ## 0.1.1 * README: the install snippet names `geolocator: ^14.1.1` and this version. diff --git a/packages/geolocator_watchos/PORTING_REPORT.md b/packages/geolocator_watchos/PORTING_REPORT.md index 9304232..1196795 100644 --- a/packages/geolocator_watchos/PORTING_REPORT.md +++ b/packages/geolocator_watchos/PORTING_REPORT.md @@ -5,7 +5,7 @@ | Aspect | Result | |---|---| | Implementation | ✅ Working (CoreLocation FFI) | -| watchOS capability | Full — query, permission, and position stream | +| watchOS capability | Full — query, permission, accuracy, service status, and position stream. Accuracy, service status and the `AppleSettings` fields are checked by host tests; the temporary full-accuracy prompt and background updates are not yet checked on a physical watch | | Host unit tests (`flutter-watchos test`) | ✅ pass | | Upstream integration test | ○ none upstream (Baseflow manual demo) — verified by building + running the example on-sim | | Internal unified demo | ✅ included | @@ -72,9 +72,10 @@ watchOS" table lists the ✗ rows. | `getLastKnownPosition` | ✅ the last cached fix | | `getCurrentPosition` / `getPositionStream` | ✅ pushed by the `CLLocationManager` delegate | | `distanceBetween` / `bearingBetween` | ✅ the interface's own Dart code | -| `getServiceStatusStream` | ✗ throws `UnimplementedError` when called: not implemented yet | -| `getLocationAccuracy` | ✗ fails with `UnimplementedError`: not implemented yet | -| `requestTemporaryFullAccuracy` | ✗ fails with `UnimplementedError`: not implemented | +| `getServiceStatusStream` | ✅ starts with the current status, then each change CoreLocation reports | +| `getLocationAccuracy` | ✅ `accuracyAuthorization`: precise or reduced | +| `requestTemporaryFullAccuracy` | ◐ `requestTemporaryFullAccuracyAuthorizationWithPurposeKey`; throws `PermissionDefinitionsNotFoundException` without `NSLocationTemporaryUsageDescriptionDictionary`, as on iOS | +| `AppleSettings` (via `toJson()`) | ◐ `activityType` and `allowBackgroundLocationUpdates` (only with the `location` background mode); `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS | | `openAppSettings` / `openLocationSettings` | ✗ fail with `UnimplementedError`: watchOS has no settings page an app can open | --- diff --git a/packages/geolocator_watchos/README.md b/packages/geolocator_watchos/README.md index c0d1e45..4792764 100644 --- a/packages/geolocator_watchos/README.md +++ b/packages/geolocator_watchos/README.md @@ -18,7 +18,7 @@ This is a federated plugin implementation. Apps that already depend on ```yaml dependencies: geolocator: ^14.1.1 - geolocator_watchos: ^0.1.1 + geolocator_watchos: ^0.1.2 ``` The plugin registers automatically via Flutter's federated registry — no @@ -39,10 +39,39 @@ Add a location usage description to the watch app's `Info.plist`: | `isLocationServiceEnabled` | supported | | `getCurrentPosition` / `getPositionStream` | supported | | `getLastKnownPosition` | supported (last cached fix) | +| `getLocationAccuracy` | supported (precise or reduced) | +| `requestTemporaryFullAccuracy` | supported; needs `NSLocationTemporaryUsageDescriptionDictionary` | +| `getServiceStatusStream` | supported; starts with the current status | The watch has no *Always* background-location entitlement flow that iOS has; authorization is when-in-use. +### Workouts and background updates + +`AppleSettings` from `geolocator` reaches CoreLocation here too: +`activityType` sets `CLLocationManager.activityType`, and +`allowBackgroundLocationUpdates` sets `allowsBackgroundLocationUpdates`. A +running or cycling app passes `ActivityType.fitness`: + +```dart +Geolocator.getPositionStream( + locationSettings: AppleSettings( + accuracy: LocationAccuracy.bestForNavigation, + activityType: ActivityType.fitness, + allowBackgroundLocationUpdates: true, + ), +); +``` + +Background updates turn on only when the `Info.plist`'s `UIBackgroundModes` +(or `WKBackgroundModes`) has `location`; without it the setting is ignored, +because CoreLocation treats turning it on without that as a fatal error. +`pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do +not exist on watchOS and are ignored. An app recording a workout keeps +running with the wrist down through its `HKWorkoutSession` +([`flutter_watch_workout`](../flutter_watch_workout)), not through this +setting. + ## Status | Platform | Implemented | @@ -62,9 +91,6 @@ These members of `geolocator_platform_interface` 4.4.0 throw or fail on watchOS. | Member | On watchOS | Why | |---|---|---| -| `getServiceStatusStream` | throws `UnimplementedError` when called | not implemented yet | -| `getLocationAccuracy` | fails with `UnimplementedError` | not implemented yet | -| `requestTemporaryFullAccuracy` | fails with `UnimplementedError` | not implemented | | `openAppSettings` / `openLocationSettings` | fail with `UnimplementedError` | watchOS has no settings page an app can open | ## License diff --git a/packages/geolocator_watchos/lib/geolocator_watchos.dart b/packages/geolocator_watchos/lib/geolocator_watchos.dart index 11553ab..0ca8184 100644 --- a/packages/geolocator_watchos/lib/geolocator_watchos.dart +++ b/packages/geolocator_watchos/lib/geolocator_watchos.dart @@ -6,9 +6,13 @@ // // Method-channel plugins are not supported on watchOS, so this package follows // the FFI plugin model: `watchos/Classes/geolocator_watchos_ffi.m` wraps -// CoreLocation (CLLocationManager) and caches the latest fix on a delegate; -// this class resolves the symbols via `DynamicLibrary.process()` and polls the -// cached fix (the same poll-based approach the other streaming plugins use). +// CoreLocation (CLLocationManager) and caches the latest fix on a delegate, +// which wakes Dart through a `NativeCallable.listener`; this class resolves +// the symbols via `DynamicLibrary.process()` and re-reads what it waits on. +// +// `AppleSettings` from geolocator_apple is read through `toJson()`, so its +// `activityType` and `allowBackgroundLocationUpdates` reach CoreLocation +// without this package depending on geolocator_apple. import 'dart:async'; import 'dart:ffi'; @@ -31,7 +35,17 @@ abstract class GeolocatorWatchosBackend { /// Starts continuous updates at [accuracyIndex] (the `LocationAccuracy` /// index) with [distanceFilter] in metres (0 or less means none). - void startUpdates(int accuracyIndex, double distanceFilter); + /// + /// [activityType] is a `CLActivityType` raw value, or 0 for CoreLocation's + /// default. [allowBackground] turns on background updates when the + /// `Info.plist` has the `location` background mode, and is ignored + /// otherwise. + void startUpdates( + int accuracyIndex, + double distanceFilter, { + int activityType = 0, + bool allowBackground = false, + }); /// Asks for a single fix. void requestLocation(); @@ -42,8 +56,21 @@ abstract class GeolocatorWatchosBackend { /// Stops continuous updates. void stopUpdates(); - /// Registers the function native calls when a new fix lands, or `nullptr` - /// to stop. + /// The `LocationAccuracyStatus` index the wearer allows: 0 reduced, + /// 1 precise. + int accuracyAuthorization(); + + /// Asks for temporary full accuracy, explained by [purposeKey]. Returns + /// false without asking when the `Info.plist` has no + /// `NSLocationTemporaryUsageDescriptionDictionary`. + bool requestTemporaryFullAccuracy(String purposeKey); + + /// How many temporary full-accuracy requests have finished. + int accuracyRequestsDone(); + + /// Registers the function native calls when a new fix lands, the + /// authorization or accuracy changes, or an accuracy request ends; or + /// `nullptr` to stop. void setCallback(Pointer> callback); } @@ -52,8 +79,7 @@ base class GeolocatorWatchos extends GeolocatorPlatform { /// Test hook: set before first use to replace the native backend with a fake. static GeolocatorWatchosBackend? backendOverride; - /// How often the Dart side polls the native fix while awaiting one. - /// How long to wait for the user to answer the system permission prompt. + /// How long to wait for the user to answer a system permission prompt. /// /// The prompt has no timeout of its own; this only bounds the future. static Duration permissionTimeout = const Duration(minutes: 2); @@ -154,12 +180,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { throw const PermissionDeniedException( 'Location permission is denied on this device.'); } - final LocationAccuracy accuracy = - locationSettings?.accuracy ?? LocationAccuracy.best; final Duration timeLimit = locationSettings?.timeLimit ?? const Duration(seconds: 30); - final double distanceFilter = - (locationSettings?.distanceFilter ?? 0).toDouble(); final Completer completer = Completer(); @@ -172,7 +194,7 @@ base class GeolocatorWatchos extends GeolocatorPlatform { final void Function() stopListening = _Notifier.listen(_b, check); _b.requestLocation(); - _b.startUpdates(accuracy.index, distanceFilter); + _start(locationSettings); // A cached fix may already satisfy this, in which case no delegate callback // is coming and waiting for one would burn the whole time limit. check(); @@ -193,10 +215,6 @@ base class GeolocatorWatchos extends GeolocatorPlatform { Stream getPositionStream({ LocationSettings? locationSettings, }) { - final LocationAccuracy accuracy = - locationSettings?.accuracy ?? LocationAccuracy.best; - final double distanceFilter = - (locationSettings?.distanceFilter ?? 0).toDouble(); late StreamController controller; List? last; void Function()? stopListening; @@ -212,7 +230,7 @@ base class GeolocatorWatchos extends GeolocatorPlatform { controller = StreamController.broadcast( onListen: () { stopListening = _Notifier.listen(_b, emitIfNew); - _b.startUpdates(accuracy.index, distanceFilter); + _start(locationSettings); // CLLocationManager delivers the last known fix immediately on start, // which can land before the callback is in place. emitIfNew(); @@ -228,6 +246,94 @@ base class GeolocatorWatchos extends GeolocatorPlatform { static bool _sameFix(List a, List? b) => b != null && a[0] == b[0] && a[1] == b[1] && a[9] == b[9]; + + /// The `CLActivityType` raw value for each geolocator `ActivityType` index: + /// automotiveNavigation, fitness, otherNavigation, airborne, other, + /// maritime. + static const List _clActivityTypes = [2, 3, 4, 5, 1, 6]; + + /// Starts updates with [settings], including what an `AppleSettings` + /// carries beyond the base class: its `toJson()` has `activityType` and + /// `allowBackgroundLocationUpdates`. + static void _start(LocationSettings? settings) { + final Map json = + settings?.toJson() ?? const {}; + final Object? activity = json['activityType']; + _b.startUpdates( + (settings?.accuracy ?? LocationAccuracy.best).index, + (settings?.distanceFilter ?? 0).toDouble(), + activityType: + activity is int && activity >= 0 && activity < _clActivityTypes.length + ? _clActivityTypes[activity] + : 0, + allowBackground: json['allowBackgroundLocationUpdates'] == true, + ); + } + + @override + Future getLocationAccuracy() async => + LocationAccuracyStatus.values[_b.accuracyAuthorization()]; + + /// Asks for full accuracy for this session. + /// + /// [purposeKey] names an entry of the `Info.plist`'s + /// `NSLocationTemporaryUsageDescriptionDictionary`, whose text the prompt + /// shows. Throws [PermissionDefinitionsNotFoundException] when the + /// `Info.plist` has no such dictionary, as geolocator does on iOS. + @override + Future requestTemporaryFullAccuracy({ + required String purposeKey, + }) async { + final int before = _b.accuracyRequestsDone(); + final Completer completer = Completer(); + + void check() { + if (_b.accuracyRequestsDone() > before && !completer.isCompleted) { + completer.complete(); + } + } + + // Registered before the prompt, which the wearer may answer at once. + final void Function() stopListening = _Notifier.listen(_b, check); + try { + if (!_b.requestTemporaryFullAccuracy(purposeKey)) { + throw const PermissionDefinitionsNotFoundException( + 'The temporary accuracy dictionary key is not set in the Info.plist', + ); + } + check(); + await completer.future.timeout(permissionTimeout, onTimeout: () {}); + return LocationAccuracyStatus.values[_b.accuracyAuthorization()]; + } finally { + stopListening(); + } + } + + /// Whether location services are on, starting with the current answer, + /// then each change. + /// + /// CoreLocation reports a change of the system switch as an authorization + /// change, which is when this re-reads it. + @override + Stream getServiceStatusStream() => + Stream.multi((MultiStreamController c) { + ServiceStatus? last; + void emit() { + final ServiceStatus now = _b.isServiceEnabled() + ? ServiceStatus.enabled + : ServiceStatus.disabled; + if (now != last) { + last = now; + c.add(now); + } + } + + final void Function() stopListening = _Notifier.listen(_b, emit); + // Creates the location manager, whose delegate reports the changes. + _b.checkPermission(); + emit(); + c.onCancel = stopListening; + }, isBroadcast: true); } /// Signature of the native→Dart new-fix signal. @@ -290,8 +396,10 @@ typedef _IntNative = Int32 Function(); typedef _IntDart = int Function(); typedef _VoidNative = Void Function(); typedef _VoidDart = void Function(); -typedef _StartNative = Void Function(Int32, Double); -typedef _StartDart = void Function(int, double); +typedef _StartNative = Void Function(Int32, Double, Int32, Int32); +typedef _StartDart = void Function(int, double, int, int); +typedef _Int64Native = Int64 Function(); +typedef _Int64Dart = int Function(); typedef _ReadNative = Int32 Function(Pointer); typedef _ReadDart = int Function(Pointer); @@ -339,8 +447,14 @@ class _FfiBackend implements GeolocatorWatchosBackend { void requestPermission() => _requestPermission(); @override - void startUpdates(int accuracyIndex, double distanceFilter) => - _startUpdates(accuracyIndex, distanceFilter); + void startUpdates( + int accuracyIndex, + double distanceFilter, { + int activityType = 0, + bool allowBackground = false, + }) => + _startUpdates( + accuracyIndex, distanceFilter, activityType, allowBackground ? 1 : 0); @override void requestLocation() => _requestLocation(); @@ -358,6 +472,31 @@ class _FfiBackend implements GeolocatorWatchosBackend { @override void stopUpdates() => _stopUpdates(); + late final _IntDart _accuracyAuthorization = + _lib.lookupFunction<_IntNative, _IntDart>( + 'geolocator_watchos_accuracy_authorization'); + late final int Function(Pointer) _requestTemporaryFullAccuracy = + _lib.lookupFunction), + int Function(Pointer)>( + 'geolocator_watchos_request_temporary_full_accuracy'); + late final _Int64Dart _accuracyRequestsDone = + _lib.lookupFunction<_Int64Native, _Int64Dart>( + 'geolocator_watchos_accuracy_requests_done'); + + @override + int accuracyAuthorization() => _accuracyAuthorization(); + + @override + bool requestTemporaryFullAccuracy(String purposeKey) => using( + (Arena arena) => + _requestTemporaryFullAccuracy( + purposeKey.toNativeUtf8(allocator: arena)) == + 1, + ); + + @override + int accuracyRequestsDone() => _accuracyRequestsDone(); + @override void setCallback(Pointer> callback) => _setCallback(callback); diff --git a/packages/geolocator_watchos/pubspec.yaml b/packages/geolocator_watchos/pubspec.yaml index bd2cb5e..d0a0df4 100644 --- a/packages/geolocator_watchos/pubspec.yaml +++ b/packages/geolocator_watchos/pubspec.yaml @@ -1,6 +1,6 @@ name: geolocator_watchos description: "watchOS implementation of the geolocator plugin, providing position and permission handling from CoreLocation (CLLocationManager) via dart:ffi." -version: 0.1.1 +version: 0.1.2 homepage: https://flutterwatch.dev repository: https://github.com/flutterwatch/plugins/tree/main/packages/geolocator_watchos issue_tracker: https://github.com/flutterwatch/plugins/issues @@ -53,4 +53,7 @@ flutter: - geolocator_watchos_request_location - geolocator_watchos_read_position - geolocator_watchos_stop_updates + - geolocator_watchos_accuracy_authorization + - geolocator_watchos_request_temporary_full_accuracy + - geolocator_watchos_accuracy_requests_done - geolocator_watchos_set_callback diff --git a/packages/geolocator_watchos/test/geolocator_watchos_test.dart b/packages/geolocator_watchos/test/geolocator_watchos_test.dart index dfd7a98..f1503f7 100644 --- a/packages/geolocator_watchos/test/geolocator_watchos_test.dart +++ b/packages/geolocator_watchos/test/geolocator_watchos_test.dart @@ -28,8 +28,23 @@ class _FakeBackend implements GeolocatorWatchosBackend { int checkPermission() => status; @override void requestPermission() => requestPermissionCount++; + int lastAccuracyIndex = -1; + int lastActivityType = -1; + bool? lastAllowBackground; + @override - void startUpdates(int accuracyIndex, double distanceFilter) => startCount++; + void startUpdates( + int accuracyIndex, + double distanceFilter, { + int activityType = 0, + bool allowBackground = false, + }) { + startCount++; + lastAccuracyIndex = accuracyIndex; + lastActivityType = activityType; + lastAllowBackground = allowBackground; + } + @override void requestLocation() {} @override @@ -37,6 +52,22 @@ class _FakeBackend implements GeolocatorWatchosBackend { @override void stopUpdates() => stopCount++; + int accuracy = 1; // precise + bool hasTemporaryUsageDictionary = true; + int accuracyDone = 0; + final List purposeKeys = []; + + @override + int accuracyAuthorization() => accuracy; + @override + bool requestTemporaryFullAccuracy(String purposeKey) { + purposeKeys.add(purposeKey); + return hasTemporaryUsageDictionary; + } + + @override + int accuracyRequestsDone() => accuracyDone; + Pointer>? _callback; /// Whether native would currently wake Dart. @@ -56,6 +87,27 @@ class _FakeBackend implements GeolocatorWatchosBackend { } } +/// What geolocator_apple's `AppleSettings.toJson()` adds to the base class, +/// without depending on geolocator_apple here. +class _AppleLikeSettings extends LocationSettings { + const _AppleLikeSettings({ + required this.activityTypeIndex, + required this.allowBackground, + }) : super(accuracy: LocationAccuracy.bestForNavigation); + + final int activityTypeIndex; + final bool allowBackground; + + @override + Map toJson() => super.toJson() + ..addAll({ + 'pauseLocationUpdatesAutomatically': false, + 'activityType': activityTypeIndex, + 'showBackgroundLocationIndicator': false, + 'allowBackgroundLocationUpdates': allowBackground, + }); +} + /// A representative fix: San Francisco, with distinct field values. List _fix() => [ 37.7749, // latitude @@ -193,4 +245,138 @@ void main() { expect(fake.isRegistered, isFalse); expect(fake.stopCount, greaterThanOrEqualTo(1)); }); + + group('accuracy', () { + test('getLocationAccuracy maps the authorization', () async { + fake.accuracy = 1; + expect(await geo.getLocationAccuracy(), LocationAccuracyStatus.precise); + fake.accuracy = 0; + expect(await geo.getLocationAccuracy(), LocationAccuracyStatus.reduced); + }); + + test('requestTemporaryFullAccuracy waits for the answer', () async { + fake.accuracy = 0; + bool done = false; + final Future pending = geo + .requestTemporaryFullAccuracy(purposeKey: 'Route') + .then((LocationAccuracyStatus s) { + done = true; + return s; + }); + await pumpEventQueue(); + expect(fake.purposeKeys, ['Route']); + expect(done, isFalse, reason: 'the wearer has not answered yet'); + + fake.accuracy = 1; + fake.accuracyDone++; + fake.signal(); + expect(await pending, LocationAccuracyStatus.precise); + expect(fake.isRegistered, isFalse); + }); + + test( + 'requestTemporaryFullAccuracy throws without the Info.plist ' + 'dictionary', () async { + fake.hasTemporaryUsageDictionary = false; + await expectLater( + geo.requestTemporaryFullAccuracy(purposeKey: 'Route'), + throwsA(isA()), + ); + expect(fake.isRegistered, isFalse); + }); + + test('requestTemporaryFullAccuracy returns the accuracy on timeout', + () async { + GeolocatorWatchos.permissionTimeout = const Duration(milliseconds: 20); + fake.accuracy = 0; + expect(await geo.requestTemporaryFullAccuracy(purposeKey: 'Route'), + LocationAccuracyStatus.reduced); + GeolocatorWatchos.permissionTimeout = const Duration(minutes: 2); + }); + }); + + group('service status', () { + test('starts with the current status, then each change', () async { + final List seen = []; + final StreamSubscription sub = + geo.getServiceStatusStream().listen(seen.add); + await pumpEventQueue(); + + // The trampoline runs asynchronously and re-reads the status, so each + // change is let through before the next. + fake.serviceEnabled = false; + fake.signal(); + await pumpEventQueue(); + fake.signal(); // no change: not repeated + await pumpEventQueue(); + fake.serviceEnabled = true; + fake.signal(); + await pumpEventQueue(); + + expect(seen, [ + ServiceStatus.enabled, + ServiceStatus.disabled, + ServiceStatus.enabled, + ]); + await sub.cancel(); + expect(fake.isRegistered, isFalse); + }); + + test('a second listener starts with the status now', () async { + final StreamSubscription first = + geo.getServiceStatusStream().listen((_) {}); + fake.serviceEnabled = false; + expect(await geo.getServiceStatusStream().first, ServiceStatus.disabled); + await first.cancel(); + }); + }); + + group('settings', () { + test('plain settings leave the activity type and background off', () async { + final StreamSubscription sub = geo + .getPositionStream( + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.medium)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.lastAccuracyIndex, LocationAccuracy.medium.index); + expect(fake.lastActivityType, 0); + expect(fake.lastAllowBackground, isFalse); + await sub.cancel(); + }); + + test('AppleSettings fitness and background reach CoreLocation', () async { + final StreamSubscription sub = geo + .getPositionStream( + locationSettings: const _AppleLikeSettings( + activityTypeIndex: 1, allowBackground: true)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.lastActivityType, 3, reason: 'CLActivityTypeFitness'); + expect(fake.lastAllowBackground, isTrue); + expect(fake.lastAccuracyIndex, LocationAccuracy.bestForNavigation.index); + await sub.cancel(); + }); + + test('every geolocator activity type maps to its CLActivityType', () async { + // automotiveNavigation, fitness, otherNavigation, airborne, other, + // maritime. + const List expected = [2, 3, 4, 5, 1, 6]; + for (int i = 0; i < expected.length; i++) { + fake.fix = _fix(); + await geo.getCurrentPosition( + locationSettings: _AppleLikeSettings( + activityTypeIndex: i, allowBackground: false)); + expect(fake.lastActivityType, expected[i]); + } + }); + + test('an unknown activity index leaves the default', () async { + fake.fix = _fix(); + await geo.getCurrentPosition( + locationSettings: const _AppleLikeSettings( + activityTypeIndex: 42, allowBackground: false)); + expect(fake.lastActivityType, 0); + }); + }); } diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h index 9edd98c..40d7144 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h @@ -6,8 +6,9 @@ // // Backed by CoreLocation (CLLocationManager), which is available on watchOS. // Location updates arrive asynchronously on a delegate, which caches the -// latest fix; the Dart side polls `read_position`. Permission requests and -// authorization status map to geolocator's LocationPermission. +// latest fix and wakes Dart through the callback below; Dart reads it back +// with `read_position`. Permission requests and authorization status map to +// geolocator's LocationPermission. #ifndef GEOLOCATOR_WATCHOS_FFI_H #define GEOLOCATOR_WATCHOS_FFI_H @@ -36,9 +37,14 @@ void geolocator_watchos_request_permission(void); // Begins continuous updates. `accuracy` is a geolocator LocationAccuracy index // (0 lowest … 4 best, 5 bestForNavigation, 6 reduced); `distance_filter` is in -// metres (<= 0 means none). +// metres (<= 0 means none). `activity_type` is a CLActivityType raw value, or +// 0 to leave CoreLocation's default. A nonzero `allow_background` turns on +// allowsBackgroundLocationUpdates, but only when the Info.plist's +// UIBackgroundModes or WKBackgroundModes has "location": CoreLocation treats +// turning it on without that as a fatal error. GEOLOCATOR_WATCHOS_EXPORT -void geolocator_watchos_start_updates(int accuracy, double distance_filter); +void geolocator_watchos_start_updates(int accuracy, double distance_filter, + int activity_type, int allow_background); // Requests a single location fix. GEOLOCATOR_WATCHOS_EXPORT @@ -54,10 +60,28 @@ int geolocator_watchos_read_position(double* out); GEOLOCATOR_WATCHOS_EXPORT void geolocator_watchos_stop_updates(void); -/// Called from the CLLocationManager delegate when a new fix lands. +// The accuracy the wearer allows, as a geolocator LocationAccuracyStatus +// index: 0 reduced, 1 precise. +GEOLOCATOR_WATCHOS_EXPORT +int geolocator_watchos_accuracy_authorization(void); + +// Asks for full accuracy for this session, explained by `purpose_key`, a key +// of the Info.plist's NSLocationTemporaryUsageDescriptionDictionary. Returns 0 +// without asking when the Info.plist has no such dictionary, 1 otherwise; the +// answer is read with `accuracy_authorization` once `accuracy_requests_done` +// has gone up. +GEOLOCATOR_WATCHOS_EXPORT +int geolocator_watchos_request_temporary_full_accuracy(const char* purpose_key); + +// How many temporary full-accuracy requests have finished. +GEOLOCATOR_WATCHOS_EXPORT +int64_t geolocator_watchos_accuracy_requests_done(void); + +/// Called from the CLLocationManager delegate when a new fix lands, the +/// authorization or accuracy changes, or a temporary accuracy request ends. /// /// Carries no value: the Dart end is a `NativeCallable.listener` and runs -/// asynchronously, so it re-reads `..._read_position` rather than trusting a +/// asynchronously, so it re-reads what it waits on rather than trusting a /// pointer captured at signal time. typedef void (*geolocator_watchos_cb)(int64_t unused); diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m index 9ff5207..a03dd7b 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m @@ -3,9 +3,9 @@ // found in the LICENSE file. // // CoreLocation-backed geolocation for watchOS. A shared CLLocationManager with -// a delegate caches the most recent fix; the FFI `read_position` copies it out -// for the Dart poller. Accuracy indices and the Position field order match -// geolocator. +// a delegate caches the most recent fix and wakes Dart; the FFI +// `read_position` copies it out. Accuracy indices and the Position field order +// match geolocator. #import "geolocator_watchos_ffi.h" @@ -22,6 +22,7 @@ static os_unfair_lock _lock = OS_UNFAIR_LOCK_INIT; static Fix _fix; static geolocator_watchos_cb _callback = NULL; +static int64_t _accuracyRequestsDone = 0; void geolocator_watchos_set_callback(geolocator_watchos_cb callback) { os_unfair_lock_lock(&_lock); @@ -133,12 +134,43 @@ void geolocator_watchos_request_permission(void) { }); } -void geolocator_watchos_start_updates(int accuracy, double distance_filter) { +// Whether the Info.plist allows background location updates. CoreLocation +// treats allowsBackgroundLocationUpdates = YES without it as a fatal error. +static BOOL _hasBackgroundLocationMode(void) { + for (NSString* key in @[ @"UIBackgroundModes", @"WKBackgroundModes" ]) { + id modes = [[NSBundle mainBundle] objectForInfoDictionaryKey:key]; + if ([modes isKindOfClass:[NSArray class]] && + [modes containsObject:@"location"]) { + return YES; + } + } + return NO; +} + +static CLActivityType _activityTypeFor(int raw) { + if (raw == 6) { + if (@available(watchOS 27.0, *)) { + return CLActivityTypeMaritime; + } + return CLActivityTypeOther; + } + return raw >= CLActivityTypeOther && raw <= CLActivityTypeAirborne + ? (CLActivityType)raw + : CLActivityTypeOther; +} + +void geolocator_watchos_start_updates(int accuracy, double distance_filter, + int activity_type, int allow_background) { CLLocationManager* m = _manager(); + BOOL background = allow_background != 0 && _hasBackgroundLocationMode(); dispatch_async(dispatch_get_main_queue(), ^{ m.desiredAccuracy = _accuracyFor(accuracy); m.distanceFilter = distance_filter > 0 ? distance_filter : kCLDistanceFilterNone; + if (activity_type != 0) { + m.activityType = _activityTypeFor(activity_type); + } + m.allowsBackgroundLocationUpdates = background; [m startUpdatingLocation]; }); } @@ -175,3 +207,36 @@ void geolocator_watchos_stop_updates(void) { [m stopUpdatingLocation]; }); } + +int geolocator_watchos_accuracy_authorization(void) { + return _manager().accuracyAuthorization == CLAccuracyAuthorizationFullAccuracy + ? 1 + : 0; +} + +int geolocator_watchos_request_temporary_full_accuracy(const char* purpose_key) { + if ([[NSBundle mainBundle] objectForInfoDictionaryKey: + @"NSLocationTemporaryUsageDescriptionDictionary"] == nil) { + return 0; + } + // Copied now: Dart releases the string as soon as this returns. + NSString* key = purpose_key == NULL ? @"" : [NSString stringWithUTF8String:purpose_key]; + CLLocationManager* m = _manager(); + dispatch_async(dispatch_get_main_queue(), ^{ + [m requestTemporaryFullAccuracyAuthorizationWithPurposeKey:key + completion:^(NSError* error) { + os_unfair_lock_lock(&_lock); + _accuracyRequestsDone++; + os_unfair_lock_unlock(&_lock); + _signal(); + }]; + }); + return 1; +} + +int64_t geolocator_watchos_accuracy_requests_done(void) { + os_unfair_lock_lock(&_lock); + int64_t done = _accuracyRequestsDone; + os_unfair_lock_unlock(&_lock); + return done; +} From 40d81f75d93d892b9c315e41c09b2b72b21bbd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Wed, 21 Oct 2026 11:06:44 +0200 Subject: [PATCH 2/3] geolocator_watchos: background flag from UIBackgroundModes only, a shared manager Review fixes on top of the 0.1.2 work, still unreleased: * allowsBackgroundLocationUpdates turns on only when UIBackgroundModes has "location". The check also accepted WKBackgroundModes, but CoreLocation reads only UIBackgroundModes on watchOS too: with "location" under WKBackgroundModes, setting the flag asserted and ended the app (seen on the watchOS 27.0 Simulator; with UIBackgroundModes it runs). * getCurrentPosition no longer stops a position stream that is running, and cancelling one of two streams no longer stops the other: they share the one CLLocationManager, which stops when the last of them ends, and a call during a stream waits for the stream's next fix. * A cached fix more than 5 seconds older than the request is not returned by getCurrentPosition, nor emitted first by a new stream. Checked: 25 host tests (4 new); on the Simulator, the background flag with each plist key, getLocationAccuracy and getServiceStatusStream, and the example's fix after the change. Co-Authored-By: Claude Opus 5.5 --- packages/geolocator_watchos/CHANGELOG.md | 17 +++++- packages/geolocator_watchos/PORTING_REPORT.md | 2 +- packages/geolocator_watchos/README.md | 13 ++++- .../lib/geolocator_watchos.dart | 46 ++++++++++++--- .../test/geolocator_watchos_test.dart | 58 ++++++++++++++++++- .../watchos/Classes/geolocator_watchos_ffi.h | 4 +- .../watchos/Classes/geolocator_watchos_ffi.m | 15 ++--- 7 files changed, 127 insertions(+), 28 deletions(-) diff --git a/packages/geolocator_watchos/CHANGELOG.md b/packages/geolocator_watchos/CHANGELOG.md index e66f3fc..fa84c1c 100644 --- a/packages/geolocator_watchos/CHANGELOG.md +++ b/packages/geolocator_watchos/CHANGELOG.md @@ -12,13 +12,26 @@ * `AppleSettings` passed to `getPositionStream` or `getCurrentPosition` now reach CoreLocation, read through `toJson()`: `activityType` (for example `ActivityType.fitness` for a run) and `allowBackgroundLocationUpdates`. The - latter turns on only when the `Info.plist` has the `location` background - mode, because CoreLocation treats it as a fatal error otherwise. + latter turns on only when the `Info.plist`'s `UIBackgroundModes` has + `location`, because CoreLocation asserts otherwise; `location` under + `WKBackgroundModes` does not count (checked on the watchOS 27.0 + Simulator). * Three FFI exports: `geolocator_watchos_accuracy_authorization`, `_request_temporary_full_accuracy` and `_accuracy_requests_done`. `geolocator_watchos_start_updates` takes the activity type and the background flag. +**Fixed** + +* `getCurrentPosition` no longer stops a position stream that is running: + streams and pending calls share the one `CLLocationManager`, which stops + only when the last of them ends, and a call during a stream waits for the + stream's next fix without changing its settings. Cancelling one of two + streams no longer stops the other. +* A cached fix more than 5 seconds older than the request is no longer + returned by `getCurrentPosition`, nor emitted first by a new stream: + CoreLocation hands over its last fix first, however old. + **Docs** * README and PORTING_REPORT: the three members move out of "Not supported on diff --git a/packages/geolocator_watchos/PORTING_REPORT.md b/packages/geolocator_watchos/PORTING_REPORT.md index 1196795..6e0d40f 100644 --- a/packages/geolocator_watchos/PORTING_REPORT.md +++ b/packages/geolocator_watchos/PORTING_REPORT.md @@ -75,7 +75,7 @@ watchOS" table lists the ✗ rows. | `getServiceStatusStream` | ✅ starts with the current status, then each change CoreLocation reports | | `getLocationAccuracy` | ✅ `accuracyAuthorization`: precise or reduced | | `requestTemporaryFullAccuracy` | ◐ `requestTemporaryFullAccuracyAuthorizationWithPurposeKey`; throws `PermissionDefinitionsNotFoundException` without `NSLocationTemporaryUsageDescriptionDictionary`, as on iOS | -| `AppleSettings` (via `toJson()`) | ◐ `activityType` and `allowBackgroundLocationUpdates` (only with the `location` background mode); `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS | +| `AppleSettings` (via `toJson()`) | ◐ `activityType` and `allowBackgroundLocationUpdates` (only with `location` in `UIBackgroundModes`); `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS | | `openAppSettings` / `openLocationSettings` | ✗ fail with `UnimplementedError`: watchOS has no settings page an app can open | --- diff --git a/packages/geolocator_watchos/README.md b/packages/geolocator_watchos/README.md index 4792764..96960f2 100644 --- a/packages/geolocator_watchos/README.md +++ b/packages/geolocator_watchos/README.md @@ -64,8 +64,17 @@ Geolocator.getPositionStream( ``` Background updates turn on only when the `Info.plist`'s `UIBackgroundModes` -(or `WKBackgroundModes`) has `location`; without it the setting is ignored, -because CoreLocation treats turning it on without that as a fatal error. +has `location`; without it the setting is ignored, because CoreLocation +asserts and the app ends otherwise. It has to be `UIBackgroundModes`, also on +the watch: `location` under `WKBackgroundModes` does not count and still +trips the assertion. + +```xml +UIBackgroundModes + + location + +``` `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS and are ignored. An app recording a workout keeps running with the wrist down through its `HKWorkoutSession` diff --git a/packages/geolocator_watchos/lib/geolocator_watchos.dart b/packages/geolocator_watchos/lib/geolocator_watchos.dart index 0ca8184..76b8a98 100644 --- a/packages/geolocator_watchos/lib/geolocator_watchos.dart +++ b/packages/geolocator_watchos/lib/geolocator_watchos.dart @@ -38,7 +38,7 @@ abstract class GeolocatorWatchosBackend { /// /// [activityType] is a `CLActivityType` raw value, or 0 for CoreLocation's /// default. [allowBackground] turns on background updates when the - /// `Info.plist` has the `location` background mode, and is ignored + /// `Info.plist`'s `UIBackgroundModes` has `location`, and is ignored /// otherwise. void startUpdates( int accuracyIndex, @@ -86,6 +86,25 @@ base class GeolocatorWatchos extends GeolocatorPlatform { static GeolocatorWatchosBackend? _backend; + /// A cached fix older than this, from before a request, is not an answer + /// to it: CoreLocation hands over its last fix first, however old. + static const Duration _freshness = Duration(seconds: 5); + + /// Position streams and pending `getCurrentPosition` calls. They share the + /// one CLLocationManager, which stops only when the last of them ends. + static int _users = 0; + + static void _release() { + _users--; + if (_users == 0) { + _b.stopUpdates(); + } + } + + static double _notBefore() => + (DateTime.now().millisecondsSinceEpoch - _freshness.inMilliseconds) + .toDouble(); + static GeolocatorWatchosBackend get _b => backendOverride ?? (_backend ??= _FfiBackend()); @@ -184,19 +203,25 @@ base class GeolocatorWatchos extends GeolocatorPlatform { locationSettings?.timeLimit ?? const Duration(seconds: 30); final Completer completer = Completer(); + final double notBefore = _notBefore(); void check() { final List? fix = _b.readPosition(); - if (fix != null && !completer.isCompleted) { + if (fix != null && fix[9] >= notBefore && !completer.isCompleted) { completer.complete(_toPosition(fix)); } } final void Function() stopListening = _Notifier.listen(_b, check); - _b.requestLocation(); - _start(locationSettings); - // A cached fix may already satisfy this, in which case no delegate callback - // is coming and waiting for one would burn the whole time limit. + // A position stream already running keeps its own settings; this only + // waits for its next fix. + if (_users == 0) { + _b.requestLocation(); + _start(locationSettings); + } + _users++; + // A recent cached fix may already satisfy this, in which case no delegate + // callback is coming and waiting for one would burn the whole time limit. check(); try { @@ -207,7 +232,7 @@ base class GeolocatorWatchos extends GeolocatorPlatform { ); } finally { stopListening(); - _b.stopUpdates(); + _release(); } } @@ -217,11 +242,12 @@ base class GeolocatorWatchos extends GeolocatorPlatform { }) { late StreamController controller; List? last; + double notBefore = 0; void Function()? stopListening; void emitIfNew() { final List? fix = _b.readPosition(); - if (fix != null && !_sameFix(fix, last)) { + if (fix != null && fix[9] >= notBefore && !_sameFix(fix, last)) { last = fix; controller.add(_toPosition(fix)); } @@ -229,8 +255,10 @@ base class GeolocatorWatchos extends GeolocatorPlatform { controller = StreamController.broadcast( onListen: () { + notBefore = _notBefore(); stopListening = _Notifier.listen(_b, emitIfNew); _start(locationSettings); + _users++; // CLLocationManager delivers the last known fix immediately on start, // which can land before the callback is in place. emitIfNew(); @@ -238,7 +266,7 @@ base class GeolocatorWatchos extends GeolocatorPlatform { onCancel: () { stopListening?.call(); stopListening = null; - _b.stopUpdates(); + _release(); }, ); return controller.stream; diff --git a/packages/geolocator_watchos/test/geolocator_watchos_test.dart b/packages/geolocator_watchos/test/geolocator_watchos_test.dart index f1503f7..9fc48ad 100644 --- a/packages/geolocator_watchos/test/geolocator_watchos_test.dart +++ b/packages/geolocator_watchos/test/geolocator_watchos_test.dart @@ -119,7 +119,7 @@ List _fix() => [ 1.0, // headingAccuracy 0.0, // speed 0.5, // speedAccuracy - 1700000000000.0, // timestamp millis + DateTime.now().millisecondsSinceEpoch.toDouble(), // timestamp millis ]; void main() { @@ -193,7 +193,7 @@ void main() { test('getCurrentPosition throws when permission is denied', () async { fake.status = 2; // denied -> deniedForever - expect( + await expectLater( geo.getCurrentPosition(), throwsA(isA()), ); @@ -201,7 +201,7 @@ void main() { test('getCurrentPosition times out when no fix arrives', () async { fake.fix = null; - expect( + await expectLater( geo.getCurrentPosition( locationSettings: const LocationSettings(timeLimit: Duration(milliseconds: 10))), @@ -379,4 +379,56 @@ void main() { expect(fake.lastActivityType, 0); }); }); + + group('sharing the location manager', () { + test('a one-shot during a stream leaves the stream running', () async { + final StreamSubscription sub = + geo.getPositionStream().listen((_) {}); + await pumpEventQueue(); + expect(fake.startCount, 1); + + fake.fix = _fix(); + await geo.getCurrentPosition(); + expect(fake.stopCount, 0, reason: 'the stream still needs updates'); + expect(fake.startCount, 1, reason: 'the stream keeps its settings'); + + await sub.cancel(); + expect(fake.stopCount, 1); + }); + + test('two streams: the first cancel does not stop the second', () async { + final StreamSubscription a = + geo.getPositionStream().listen((_) {}); + final StreamSubscription b = + geo.getPositionStream().listen((_) {}); + await pumpEventQueue(); + await a.cancel(); + expect(fake.stopCount, 0); + await b.cancel(); + expect(fake.stopCount, 1); + }); + + test('a stale cached fix is not the current position', () async { + fake.fix = _fix()..[9] = 1700000000000.0; + final Future pending = geo.getCurrentPosition( + locationSettings: + const LocationSettings(timeLimit: Duration(seconds: 2))); + await pumpEventQueue(); + fake.deliver(_fix()..[0] = 10.0); + expect((await pending).latitude, 10.0); + }); + + test('a stream does not start with a stale cached fix', () async { + fake.fix = _fix()..[9] = 1700000000000.0; + final List seen = []; + final StreamSubscription sub = + geo.getPositionStream().listen(seen.add); + await pumpEventQueue(); + expect(seen, isEmpty); + fake.deliver(_fix()..[0] = 11.0); + await pumpEventQueue(); + expect(seen.single.latitude, 11.0); + await sub.cancel(); + }); + }); } diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h index 40d7144..e8841fb 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h @@ -40,8 +40,8 @@ void geolocator_watchos_request_permission(void); // metres (<= 0 means none). `activity_type` is a CLActivityType raw value, or // 0 to leave CoreLocation's default. A nonzero `allow_background` turns on // allowsBackgroundLocationUpdates, but only when the Info.plist's -// UIBackgroundModes or WKBackgroundModes has "location": CoreLocation treats -// turning it on without that as a fatal error. +// UIBackgroundModes has "location": CoreLocation asserts otherwise, and does +// so even when WKBackgroundModes has it. GEOLOCATOR_WATCHOS_EXPORT void geolocator_watchos_start_updates(int accuracy, double distance_filter, int activity_type, int allow_background); diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m index a03dd7b..7953f0a 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m @@ -135,16 +135,13 @@ void geolocator_watchos_request_permission(void) { } // Whether the Info.plist allows background location updates. CoreLocation -// treats allowsBackgroundLocationUpdates = YES without it as a fatal error. +// asserts when allowsBackgroundLocationUpdates turns on without "location" in +// UIBackgroundModes, and on watchOS too it reads only that key: "location" +// under WKBackgroundModes still trips the assertion (checked on the watchOS +// 27.0 Simulator). static BOOL _hasBackgroundLocationMode(void) { - for (NSString* key in @[ @"UIBackgroundModes", @"WKBackgroundModes" ]) { - id modes = [[NSBundle mainBundle] objectForInfoDictionaryKey:key]; - if ([modes isKindOfClass:[NSArray class]] && - [modes containsObject:@"location"]) { - return YES; - } - } - return NO; + id modes = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIBackgroundModes"]; + return [modes isKindOfClass:[NSArray class]] && [modes containsObject:@"location"]; } static CLActivityType _activityTypeFor(int raw) { From 2536ff292dd01bb9498ca430073bd65a4201b5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Arslanta=C5=9F?= Date: Sat, 24 Oct 2026 00:10:22 +0200 Subject: [PATCH 3/3] geolocator_watchos 0.2.0: the review's fixes From the review of PR #12: * CLActivityTypeMaritime is declared only from the watchOS 27 SDK on, so its use is behind #if __WATCH_OS_VERSION_MAX_ALLOWED >= 270000, and the fallback is CLActivityTypeOtherNavigation, as in geolocator_apple. The package builds with Xcode 26 again. * getCurrentPosition has a CLLocationManager of its own, as in geolocator_apple: native starts it at the call's accuracy, skips cached fixes more than 5 seconds old, and stops it after the first fix it accepts. A call during a distance-filtered stream no longer times out while the wearer stands still, nor changes the stream's settings. * getPositionStream called while a stream runs returns that stream, with the settings it started with, as geolocator_apple does; a second stream overwrote the first one's accuracy and background settings. * The 5-second age filter applies only to single fixes, as upstream: a stream emits every fix native delivers, counted by a new geolocator_watchos_position_count, so a fix at the same place is one too. * A stream without AppleSettings gets CLActivityTypeOther instead of the previous stream's type. * The native callback carries a kind (position, authorization, accuracy), so the service status stream asks the blocking locationServicesEnabled only on an authorization change, not on every fix. * 0.2.0 with a Breaking section: GeolocatorWatchosBackend, which is public, changed shape. * The host tests pass geolocator_apple's real AppleSettings, a dev dependency, instead of a copy of its toJson(). * README: no link to flutter_watch_workout, which is not in this repo; AppleSettings turns background updates on by default; App Review Guideline 2.5.4; what is not yet checked on a physical watch. Checked: 26 host tests; the example built for the watchOS 27.0 Simulator with all 13 ffiSymbols exported, and on the Simulator getCurrentPosition answered a simulated location, then a moved one. The .m compiles with the SDK ceiling set to 26 and to 27; it was not built with Xcode 26 itself. Co-Authored-By: Claude Opus 5.5 --- packages/geolocator_watchos/CHANGELOG.md | 62 ++- packages/geolocator_watchos/PORTING_REPORT.md | 5 +- packages/geolocator_watchos/README.md | 31 +- .../lib/geolocator_watchos.dart | 201 ++++++---- packages/geolocator_watchos/pubspec.yaml | 6 +- .../test/geolocator_watchos_test.dart | 378 +++++++++++------- .../watchos/Classes/geolocator_watchos_ffi.h | 49 ++- .../watchos/Classes/geolocator_watchos_ffi.m | 120 ++++-- 8 files changed, 552 insertions(+), 300 deletions(-) diff --git a/packages/geolocator_watchos/CHANGELOG.md b/packages/geolocator_watchos/CHANGELOG.md index fa84c1c..22d9875 100644 --- a/packages/geolocator_watchos/CHANGELOG.md +++ b/packages/geolocator_watchos/CHANGELOG.md @@ -1,4 +1,16 @@ -## 0.1.2 +## 0.2.0 + +**Breaking** + +* `GeolocatorWatchosBackend`, the public seam tests fake, changed shape: + `requestLocation` takes the accuracy index; `stopLocationRequest`, + `positionCount`, `accuracyAuthorization`, `requestTemporaryFullAccuracy` + and `accuracyRequestsDone` are new; `startUpdates` takes `activityType` and + `allowBackground`. The native callback now carries a + `GeolocatorWatchosSignal` kind. +* `getPositionStream` called while a stream runs returns that stream, with + the settings it started with, as geolocator_apple does. A second stream + with other settings used to replace the first one's. **Added** @@ -8,34 +20,42 @@ in the `Info.plist` it throws `PermissionDefinitionsNotFoundException`, as geolocator does on iOS. * `getServiceStatusStream` starts with the current status, then reports each - change CoreLocation signals. -* `AppleSettings` passed to `getPositionStream` or `getCurrentPosition` now - reach CoreLocation, read through `toJson()`: `activityType` (for example - `ActivityType.fitness` for a run) and `allowBackgroundLocationUpdates`. The - latter turns on only when the `Info.plist`'s `UIBackgroundModes` has - `location`, because CoreLocation asserts otherwise; `location` under - `WKBackgroundModes` does not count (checked on the watchOS 27.0 - Simulator). -* Three FFI exports: `geolocator_watchos_accuracy_authorization`, - `_request_temporary_full_accuracy` and `_accuracy_requests_done`. + change CoreLocation signals. It reads the services switch only when + CoreLocation reports an authorization change, not on every fix. +* `AppleSettings` passed to `getPositionStream` now reach CoreLocation, read + through `toJson()`: `activityType` (for example `ActivityType.fitness` for + a run) and `allowBackgroundLocationUpdates`, which `AppleSettings` turns on + by default. Background updates turn on only when the `Info.plist`'s + `UIBackgroundModes` has `location`, because CoreLocation asserts otherwise; + `location` under `WKBackgroundModes` does not count (checked on the + watchOS 27.0 Simulator). A stream without `AppleSettings` gets + `CLActivityTypeOther`, not the previous stream's type. + `ActivityType.maritime` is `CLActivityTypeMaritime` when built with the + watchOS 27 SDK and run on watchOS 27, and `CLActivityTypeOtherNavigation` + otherwise, as in geolocator_apple; the package still builds with Xcode 26. +* Five FFI exports: `geolocator_watchos_accuracy_authorization`, + `_request_temporary_full_accuracy`, `_accuracy_requests_done`, + `_stop_location_request` and `_position_count`. `geolocator_watchos_start_updates` takes the activity type and the - background flag. + background flag, and `geolocator_watchos_request_location` the accuracy. **Fixed** -* `getCurrentPosition` no longer stops a position stream that is running: - streams and pending calls share the one `CLLocationManager`, which stops - only when the last of them ends, and a call during a stream waits for the - stream's next fix without changing its settings. Cancelling one of two - streams no longer stops the other. -* A cached fix more than 5 seconds older than the request is no longer - returned by `getCurrentPosition`, nor emitted first by a new stream: - CoreLocation hands over its last fix first, however old. +* `getCurrentPosition` has a `CLLocationManager` of its own, as in + geolocator_apple, so it no longer waits on a running stream's distance + filter, which timed it out when the wearer stood still, nor stops or + reconfigures the stream. It skips cached fixes more than 5 seconds old, + which CoreLocation hands over first; a running stream's next fix answers + it too. +* A position stream emits every fix CoreLocation delivers, with no age + filter, as geolocator_apple's does: a reduced-accuracy fix can + legitimately be old. **Docs** * README and PORTING_REPORT: the three members move out of "Not supported on - watchOS"; a section on workouts and background updates. + watchOS"; a section on workouts and background updates, with App Review + guideline 2.5.4, and what is not yet checked on a physical watch. * Comments no longer say that Dart polls the cached fix: the delegate wakes Dart. diff --git a/packages/geolocator_watchos/PORTING_REPORT.md b/packages/geolocator_watchos/PORTING_REPORT.md index 6e0d40f..33f1c61 100644 --- a/packages/geolocator_watchos/PORTING_REPORT.md +++ b/packages/geolocator_watchos/PORTING_REPORT.md @@ -70,12 +70,13 @@ watchOS" table lists the ✗ rows. | `checkPermission` / `requestPermission` | ✅ when-in-use authorization | | `isLocationServiceEnabled` | ✅ | | `getLastKnownPosition` | ✅ the last cached fix | -| `getCurrentPosition` / `getPositionStream` | ✅ pushed by the `CLLocationManager` delegate | +| `getCurrentPosition` | ✅ a `CLLocationManager` of its own, as in geolocator_apple; skips cached fixes more than 5 seconds old | +| `getPositionStream` | ✅ pushed by the `CLLocationManager` delegate, every fix; a second call while one runs gets that stream, as from geolocator_apple | | `distanceBetween` / `bearingBetween` | ✅ the interface's own Dart code | | `getServiceStatusStream` | ✅ starts with the current status, then each change CoreLocation reports | | `getLocationAccuracy` | ✅ `accuracyAuthorization`: precise or reduced | | `requestTemporaryFullAccuracy` | ◐ `requestTemporaryFullAccuracyAuthorizationWithPurposeKey`; throws `PermissionDefinitionsNotFoundException` without `NSLocationTemporaryUsageDescriptionDictionary`, as on iOS | -| `AppleSettings` (via `toJson()`) | ◐ `activityType` and `allowBackgroundLocationUpdates` (only with `location` in `UIBackgroundModes`); `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS | +| `AppleSettings` (via `toJson()`) | ◐ `activityType` (maritime from the watchOS 27 SDK and watchOS 27 on, `otherNavigation` before, as in geolocator_apple) and `allowBackgroundLocationUpdates` (only with `location` in `UIBackgroundModes`); `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do not exist on watchOS | | `openAppSettings` / `openLocationSettings` | ✗ fail with `UnimplementedError`: watchOS has no settings page an app can open | --- diff --git a/packages/geolocator_watchos/README.md b/packages/geolocator_watchos/README.md index 96960f2..3efd347 100644 --- a/packages/geolocator_watchos/README.md +++ b/packages/geolocator_watchos/README.md @@ -5,7 +5,8 @@ The watchOS implementation of [`geolocator`](https://pub.dev/packages/geolocator Location comes from **CoreLocation** (`CLLocationManager`) over dart:ffi: the delegate caches the latest fix and **pushes** a signal into Dart. The position stream, `getCurrentPosition` and `requestPermission` all wait on it — nothing -polls. +polls. As in geolocator_apple, the position stream and `getCurrentPosition` +each have a location manager of their own. > Scaffolded by [`flutter-watchos plugin port`](https://github.com/flutterwatch/flutter-watchos) > from `geolocator_apple`, then implemented and verified by hand. @@ -18,7 +19,7 @@ This is a federated plugin implementation. Apps that already depend on ```yaml dependencies: geolocator: ^14.1.1 - geolocator_watchos: ^0.1.2 + geolocator_watchos: ^0.2.0 ``` The plugin registers automatically via Flutter's federated registry — no @@ -37,7 +38,8 @@ Add a location usage description to the watch app's `Info.plist`: |---|---| | `checkPermission` / `requestPermission` | supported (when-in-use) | | `isLocationServiceEnabled` | supported | -| `getCurrentPosition` / `getPositionStream` | supported | +| `getCurrentPosition` | supported; skips cached fixes more than 5 seconds old, and does not wait on a running stream | +| `getPositionStream` | supported; while a stream runs, a second call gets it, with its settings, as from geolocator_apple | | `getLastKnownPosition` | supported (last cached fix) | | `getLocationAccuracy` | supported (precise or reduced) | | `requestTemporaryFullAccuracy` | supported; needs `NSLocationTemporaryUsageDescriptionDictionary` | @@ -48,10 +50,11 @@ authorization is when-in-use. ### Workouts and background updates -`AppleSettings` from `geolocator` reaches CoreLocation here too: -`activityType` sets `CLLocationManager.activityType`, and -`allowBackgroundLocationUpdates` sets `allowsBackgroundLocationUpdates`. A -running or cycling app passes `ActivityType.fitness`: +`AppleSettings` from `geolocator`, passed to `getPositionStream`, reaches +CoreLocation here too: `activityType` sets `CLLocationManager.activityType`, +and `allowBackgroundLocationUpdates`, which `AppleSettings` turns on by +default, sets `allowsBackgroundLocationUpdates`. A running or cycling app +passes `ActivityType.fitness`: ```dart Geolocator.getPositionStream( @@ -75,11 +78,17 @@ trips the assertion. location ``` +App Review accepts background location only for a feature that needs it +(App Review Guideline 2.5.4); say what it is for in the usage description. + `pauseLocationUpdatesAutomatically` and `showBackgroundLocationIndicator` do -not exist on watchOS and are ignored. An app recording a workout keeps -running with the wrist down through its `HKWorkoutSession` -([`flutter_watch_workout`](../flutter_watch_workout)), not through this -setting. +not exist on watchOS and are ignored. `ActivityType.maritime` needs the +watchOS 27 SDK and watchOS 27; otherwise it is `otherNavigation`, as in +geolocator_apple. An app recording a workout keeps running with the wrist +down through its `HKWorkoutSession`, not through this setting. + +Not yet checked on a physical watch: the temporary full-accuracy prompt, and +updates with the wrist down. ## Status diff --git a/packages/geolocator_watchos/lib/geolocator_watchos.dart b/packages/geolocator_watchos/lib/geolocator_watchos.dart index 76b8a98..ba9a519 100644 --- a/packages/geolocator_watchos/lib/geolocator_watchos.dart +++ b/packages/geolocator_watchos/lib/geolocator_watchos.dart @@ -6,9 +6,11 @@ // // Method-channel plugins are not supported on watchOS, so this package follows // the FFI plugin model: `watchos/Classes/geolocator_watchos_ffi.m` wraps -// CoreLocation (CLLocationManager) and caches the latest fix on a delegate, -// which wakes Dart through a `NativeCallable.listener`; this class resolves -// the symbols via `DynamicLibrary.process()` and re-reads what it waits on. +// CoreLocation and caches the latest fix on a delegate, which wakes Dart +// through a `NativeCallable.listener` with the kind of change; this class +// resolves the symbols via `DynamicLibrary.process()` and re-reads what it +// waits on. As in geolocator_apple, the position stream and single position +// requests each have a CLLocationManager of their own. // // `AppleSettings` from geolocator_apple is read through `toJson()`, so its // `activityType` and `allowBackgroundLocationUpdates` reach CoreLocation @@ -33,13 +35,14 @@ abstract class GeolocatorWatchosBackend { /// with [checkPermission]. void requestPermission(); - /// Starts continuous updates at [accuracyIndex] (the `LocationAccuracy` - /// index) with [distanceFilter] in metres (0 or less means none). + /// Starts the stream's continuous updates at [accuracyIndex] (the + /// `LocationAccuracy` index) with [distanceFilter] in metres (0 or less + /// means none). /// - /// [activityType] is a `CLActivityType` raw value, or 0 for CoreLocation's - /// default. [allowBackground] turns on background updates when the - /// `Info.plist`'s `UIBackgroundModes` has `location`, and is ignored - /// otherwise. + /// [activityType] is a `CLActivityType` raw value, or 6 for maritime; + /// anything else, 0 included, means `CLActivityTypeOther`. + /// [allowBackground] turns on background updates when the `Info.plist`'s + /// `UIBackgroundModes` has `location`, and is ignored otherwise. void startUpdates( int accuracyIndex, double distanceFilter, { @@ -47,13 +50,20 @@ abstract class GeolocatorWatchosBackend { bool allowBackground = false, }); - /// Asks for a single fix. - void requestLocation(); + /// Starts the single-request manager at [accuracyIndex]. It skips cached + /// fixes more than 5 seconds old and stops after the first one it accepts. + void requestLocation(int accuracyIndex); + + /// Stops the single-request manager, for a request that gave up. + void stopLocationRequest(); /// The latest fix as the 10 doubles documented in the C header, or null. List? readPosition(); - /// Stops continuous updates. + /// How many fixes native has delivered; it goes up with each new one. + int positionCount(); + + /// Stops the stream's continuous updates. void stopUpdates(); /// The `LocationAccuracyStatus` index the wearer allows: 0 reduced, @@ -68,12 +78,25 @@ abstract class GeolocatorWatchosBackend { /// How many temporary full-accuracy requests have finished. int accuracyRequestsDone(); - /// Registers the function native calls when a new fix lands, the - /// authorization or accuracy changes, or an accuracy request ends; or - /// `nullptr` to stop. + /// Registers the function native calls, with a [GeolocatorWatchosSignal] + /// kind, when a new fix lands, the authorization or accuracy changes, or an + /// accuracy request ends; or `nullptr` to stop. void setCallback(Pointer> callback); } +/// The kinds of change native signals, as `GEOLOCATOR_WATCHOS_SIGNAL_*` in +/// the C header. +abstract final class GeolocatorWatchosSignal { + /// A new fix, or a failed update. + static const int position = 0; + + /// The authorization, or the location services switch, changed. + static const int authorization = 1; + + /// A temporary full-accuracy request ended. + static const int accuracy = 2; +} + /// watchOS implementation of [GeolocatorPlatform]. base class GeolocatorWatchos extends GeolocatorPlatform { /// Test hook: set before first use to replace the native backend with a fake. @@ -86,24 +109,13 @@ base class GeolocatorWatchos extends GeolocatorPlatform { static GeolocatorWatchosBackend? _backend; - /// A cached fix older than this, from before a request, is not an answer - /// to it: CoreLocation hands over its last fix first, however old. - static const Duration _freshness = Duration(seconds: 5); + /// Pending `getCurrentPosition` calls. They share the single-request + /// manager, which stops when the last of them ends. + static int _singleRequests = 0; - /// Position streams and pending `getCurrentPosition` calls. They share the - /// one CLLocationManager, which stops only when the last of them ends. - static int _users = 0; - - static void _release() { - _users--; - if (_users == 0) { - _b.stopUpdates(); - } - } - - static double _notBefore() => - (DateTime.now().millisecondsSinceEpoch - _freshness.inMilliseconds) - .toDouble(); + /// The running position stream, which a second `getPositionStream` call + /// gets too, with the first call's settings, as from geolocator_apple. + Stream? _positionStream; static GeolocatorWatchosBackend get _b => backendOverride ?? (_backend ??= _FfiBackend()); @@ -166,7 +178,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { // Registered before the prompt: the user can answer faster than the call // to request it returns. - final void Function() stopListening = _Notifier.listen(_b, check); + final void Function() stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.authorization, check); _b.requestPermission(); check(); @@ -203,26 +216,27 @@ base class GeolocatorWatchos extends GeolocatorPlatform { locationSettings?.timeLimit ?? const Duration(seconds: 30); final Completer completer = Completer(); - final double notBefore = _notBefore(); + // The fix cached now is not an answer: only one delivered after this + // request is. Native skips the stale fixes the single-request manager + // hands over first; a running stream's next fix answers too, as in + // geolocator_apple. + final int before = _b.positionCount(); void check() { + if (_b.positionCount() == before || completer.isCompleted) { + return; + } final List? fix = _b.readPosition(); - if (fix != null && fix[9] >= notBefore && !completer.isCompleted) { + if (fix != null) { completer.complete(_toPosition(fix)); } } - final void Function() stopListening = _Notifier.listen(_b, check); - // A position stream already running keeps its own settings; this only - // waits for its next fix. - if (_users == 0) { - _b.requestLocation(); - _start(locationSettings); - } - _users++; - // A recent cached fix may already satisfy this, in which case no delegate - // callback is coming and waiting for one would burn the whole time limit. - check(); + final void Function() stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.position, check); + _singleRequests++; + _b.requestLocation( + (locationSettings?.accuracy ?? LocationAccuracy.best).index); try { return await completer.future.timeout( @@ -232,49 +246,61 @@ base class GeolocatorWatchos extends GeolocatorPlatform { ); } finally { stopListening(); - _release(); + _singleRequests--; + if (_singleRequests == 0) { + _b.stopLocationRequest(); + } } } + /// The position stream. While one runs, a second call gets that stream, + /// with the settings it started with, as from geolocator_apple; it starts + /// with the settings of this call again once every listener has left. + /// + /// Each fix native delivers is emitted, with no age filter: CoreLocation + /// hands over its last fix as updates start, and a reduced-accuracy fix can + /// legitimately be old. @override Stream getPositionStream({ LocationSettings? locationSettings, }) { + final Stream? running = _positionStream; + if (running != null) { + return running; + } late StreamController controller; - List? last; - double notBefore = 0; + int seen = 0; void Function()? stopListening; void emitIfNew() { + final int count = _b.positionCount(); + if (count == seen) { + return; + } + seen = count; final List? fix = _b.readPosition(); - if (fix != null && fix[9] >= notBefore && !_sameFix(fix, last)) { - last = fix; + if (fix != null) { controller.add(_toPosition(fix)); } } controller = StreamController.broadcast( onListen: () { - notBefore = _notBefore(); - stopListening = _Notifier.listen(_b, emitIfNew); + seen = _b.positionCount(); + stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.position, emitIfNew); _start(locationSettings); - _users++; - // CLLocationManager delivers the last known fix immediately on start, - // which can land before the callback is in place. - emitIfNew(); }, onCancel: () { stopListening?.call(); stopListening = null; - _release(); + _positionStream = null; + _b.stopUpdates(); }, ); - return controller.stream; + return _positionStream = controller.stream; } - static bool _sameFix(List a, List? b) => - b != null && a[0] == b[0] && a[1] == b[1] && a[9] == b[9]; - /// The `CLActivityType` raw value for each geolocator `ActivityType` index: /// automotiveNavigation, fitness, otherNavigation, airborne, other, /// maritime. @@ -322,7 +348,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { } // Registered before the prompt, which the wearer may answer at once. - final void Function() stopListening = _Notifier.listen(_b, check); + final void Function() stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.accuracy, check); try { if (!_b.requestTemporaryFullAccuracy(purposeKey)) { throw const PermissionDefinitionsNotFoundException( @@ -341,7 +368,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { /// then each change. /// /// CoreLocation reports a change of the system switch as an authorization - /// change, which is when this re-reads it. + /// change, which is when this re-reads it: `locationServicesEnabled` + /// blocks, so it is not asked on every fix. @override Stream getServiceStatusStream() => Stream.multi((MultiStreamController c) { @@ -356,7 +384,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { } } - final void Function() stopListening = _Notifier.listen(_b, emit); + final void Function() stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.authorization, emit); // Creates the location manager, whose delegate reports the changes. _b.checkPermission(); emit(); @@ -364,7 +393,8 @@ base class GeolocatorWatchos extends GeolocatorPlatform { }, isBroadcast: true); } -/// Signature of the native→Dart new-fix signal. +/// Signature of the native→Dart signal, which carries a +/// [GeolocatorWatchosSignal] kind. typedef GeolocatorFixNative = Void Function(Int64); /// Fan-out for the single native callback slot. @@ -375,7 +405,7 @@ typedef GeolocatorFixNative = Void Function(Int64); /// whichever finished last unregister the others, so they share one trampoline /// and every waiter is notified. class _Notifier { - static final Set _listeners = {}; + static final Map _listeners = {}; static NativeCallable? _callable; /// Which backend the trampoline is currently registered with. @@ -384,18 +414,21 @@ class _Notifier { /// once and never again would leave a replaced backend permanently silent. static GeolocatorWatchosBackend? _registeredWith; - /// Calls [listener] whenever CoreLocation reports anything, and returns a - /// function that stops it. + /// Calls [listener] whenever CoreLocation reports a change of [kind], a + /// [GeolocatorWatchosSignal], and returns a function that stops it. static void Function() listen( - GeolocatorWatchosBackend backend, void Function() listener) { - _listeners.add(listener); + GeolocatorWatchosBackend backend, int kind, void Function() listener) { + _listeners[listener] = kind; if (!identical(_registeredWith, backend)) { final NativeCallable c = _callable ??= () { final NativeCallable created = - NativeCallable.listener((int _) { + NativeCallable.listener((int signalled) { // Copied: a listener may remove itself while being notified. - for (final void Function() l in _listeners.toList()) { - l(); + for (final MapEntry l + in _listeners.entries.toList()) { + if (l.value == signalled) { + l.key(); + } } }); // Waiting on a fix should not, by itself, keep the isolate alive. @@ -430,6 +463,8 @@ typedef _Int64Native = Int64 Function(); typedef _Int64Dart = int Function(); typedef _ReadNative = Int32 Function(Pointer); typedef _ReadDart = int Function(Pointer); +typedef _AccuracyNative = Void Function(Int32); +typedef _AccuracyDart = void Function(int); /// Resolves the CoreLocation C symbols and marshals the fix buffer. class _FfiBackend implements GeolocatorWatchosBackend { @@ -450,9 +485,15 @@ class _FfiBackend implements GeolocatorWatchosBackend { late final _StartDart _startUpdates = _lib.lookupFunction<_StartNative, _StartDart>( 'geolocator_watchos_start_updates'); - late final _VoidDart _requestLocation = - _lib.lookupFunction<_VoidNative, _VoidDart>( + late final _AccuracyDart _requestLocation = + _lib.lookupFunction<_AccuracyNative, _AccuracyDart>( 'geolocator_watchos_request_location'); + late final _VoidDart _stopLocationRequest = + _lib.lookupFunction<_VoidNative, _VoidDart>( + 'geolocator_watchos_stop_location_request'); + late final _Int64Dart _positionCount = + _lib.lookupFunction<_Int64Native, _Int64Dart>( + 'geolocator_watchos_position_count'); late final _ReadDart _readPosition = _lib.lookupFunction<_ReadNative, _ReadDart>( 'geolocator_watchos_read_position'); @@ -485,7 +526,13 @@ class _FfiBackend implements GeolocatorWatchosBackend { accuracyIndex, distanceFilter, activityType, allowBackground ? 1 : 0); @override - void requestLocation() => _requestLocation(); + void requestLocation(int accuracyIndex) => _requestLocation(accuracyIndex); + + @override + void stopLocationRequest() => _stopLocationRequest(); + + @override + int positionCount() => _positionCount(); @override List? readPosition() { diff --git a/packages/geolocator_watchos/pubspec.yaml b/packages/geolocator_watchos/pubspec.yaml index d0a0df4..7a4a05e 100644 --- a/packages/geolocator_watchos/pubspec.yaml +++ b/packages/geolocator_watchos/pubspec.yaml @@ -1,6 +1,6 @@ name: geolocator_watchos description: "watchOS implementation of the geolocator plugin, providing position and permission handling from CoreLocation (CLLocationManager) via dart:ffi." -version: 0.1.2 +version: 0.2.0 homepage: https://flutterwatch.dev repository: https://github.com/flutterwatch/plugins/tree/main/packages/geolocator_watchos issue_tracker: https://github.com/flutterwatch/plugins/issues @@ -28,6 +28,8 @@ dev_dependencies: flutter_lints: ^4.0.0 flutter_test: sdk: flutter + # Only for the host tests, which pass its AppleSettings as an app does. + geolocator_apple: ^2.4.2 flutter: plugin: @@ -51,7 +53,9 @@ flutter: - geolocator_watchos_request_permission - geolocator_watchos_start_updates - geolocator_watchos_request_location + - geolocator_watchos_stop_location_request - geolocator_watchos_read_position + - geolocator_watchos_position_count - geolocator_watchos_stop_updates - geolocator_watchos_accuracy_authorization - geolocator_watchos_request_temporary_full_accuracy diff --git a/packages/geolocator_watchos/test/geolocator_watchos_test.dart b/packages/geolocator_watchos/test/geolocator_watchos_test.dart index 9fc48ad..9fb563e 100644 --- a/packages/geolocator_watchos/test/geolocator_watchos_test.dart +++ b/packages/geolocator_watchos/test/geolocator_watchos_test.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. // // Host-side unit tests. The native CoreLocation backend is replaced with a -// fake so permission mapping and the fix-polling flow are verified off-device; +// fake so permission mapping and the fix-delivery flow are verified off-device; // the real CLLocationManager path is exercised on a physical watch. import 'dart:async'; @@ -11,24 +11,43 @@ import 'dart:async'; import 'dart:ffi'; import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator_apple/geolocator_apple.dart' + show ActivityType, AppleSettings; import 'package:geolocator_platform_interface/geolocator_platform_interface.dart'; import 'package:geolocator_watchos/geolocator_watchos.dart'; class _FakeBackend implements GeolocatorWatchosBackend { bool serviceEnabled = true; + int serviceChecks = 0; int status = 4; // authorizedWhenInUse List? fix; + int count = 0; int startCount = 0; int stopCount = 0; int requestPermissionCount = 0; + /// What the single-request manager delivers once asked, or null for + /// nothing. + List? singleFix; + int requestLocationCount = 0; + int stopRequestCount = 0; + int? lastRequestAccuracy; + + /// What CoreLocation hands over as the stream starts, or null for nothing. + List? fixOnStart; + @override - bool isServiceEnabled() => serviceEnabled; + bool isServiceEnabled() { + serviceChecks++; + return serviceEnabled; + } + @override int checkPermission() => status; @override void requestPermission() => requestPermissionCount++; int lastAccuracyIndex = -1; + double lastDistanceFilter = -1; int lastActivityType = -1; bool? lastAllowBackground; @@ -41,15 +60,32 @@ class _FakeBackend implements GeolocatorWatchosBackend { }) { startCount++; lastAccuracyIndex = accuracyIndex; + lastDistanceFilter = distanceFilter; lastActivityType = activityType; lastAllowBackground = allowBackground; + final List? first = fixOnStart; + if (first != null) { + deliver(first); + } } @override - void requestLocation() {} + void requestLocation(int accuracyIndex) { + requestLocationCount++; + lastRequestAccuracy = accuracyIndex; + final List? next = singleFix; + if (next != null) { + deliver(next); + } + } + + @override + void stopLocationRequest() => stopRequestCount++; @override List? readPosition() => fix; @override + int positionCount() => count; + @override void stopUpdates() => stopCount++; int accuracy = 1; // precise @@ -77,35 +113,26 @@ class _FakeBackend implements GeolocatorWatchosBackend { void setCallback(Pointer> cb) => _callback = cb == nullptr ? null : cb; - /// Fires the native signal, as any CLLocationManager delegate callback does. - void signal() => _callback?.asFunction()(0); + /// Fires the native signal of [kind], as a CLLocationManager delegate + /// callback does. + void signal(int kind) => _callback?.asFunction()(kind); /// Delivers [next] the way a location update would. void deliver(List next) { fix = next; - signal(); + count++; + signal(GeolocatorWatchosSignal.position); } } -/// What geolocator_apple's `AppleSettings.toJson()` adds to the base class, -/// without depending on geolocator_apple here. -class _AppleLikeSettings extends LocationSettings { - const _AppleLikeSettings({ - required this.activityTypeIndex, - required this.allowBackground, - }) : super(accuracy: LocationAccuracy.bestForNavigation); - - final int activityTypeIndex; - final bool allowBackground; +/// Settings whose `toJson()` names an activity type no geolocator version +/// has. +class _UnknownActivitySettings extends LocationSettings { + const _UnknownActivitySettings(); @override - Map toJson() => super.toJson() - ..addAll({ - 'pauseLocationUpdatesAutomatically': false, - 'activityType': activityTypeIndex, - 'showBackgroundLocationIndicator': false, - 'allowBackgroundLocationUpdates': allowBackground, - }); + Map toJson() => + super.toJson()..addAll({'activityType': 42}); } /// A representative fix: San Francisco, with distinct field values. @@ -162,7 +189,7 @@ void main() { // The user answers the prompt; CoreLocation reports it through // locationManagerDidChangeAuthorization, not through any pollable value. fake.status = 4; - fake.signal(); + fake.signal(GeolocatorWatchosSignal.authorization); expect(await pending, LocationPermission.whileInUse); }); @@ -180,33 +207,62 @@ void main() { expect(await geo.isLocationServiceEnabled(), isFalse); }); - test('getCurrentPosition returns a mapped Position and stops updates', - () async { - fake.fix = _fix(); - final Position pos = await geo.getCurrentPosition(); - expect(pos.latitude, 37.7749); - expect(pos.longitude, -122.4194); - expect(pos.accuracy, 5.0); - expect(fake.startCount, 1); - expect(fake.stopCount, 1); - }); + group('getCurrentPosition', () { + test('asks the single-request manager and maps its fix', () async { + fake.singleFix = _fix(); + final Position pos = await geo.getCurrentPosition( + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.low)); + expect(pos.latitude, 37.7749); + expect(pos.longitude, -122.4194); + expect(pos.accuracy, 5.0); + expect(fake.requestLocationCount, 1); + expect(fake.lastRequestAccuracy, LocationAccuracy.low.index); + expect(fake.stopRequestCount, 1); + expect(fake.startCount, 0, reason: "the stream's manager is not used"); + }); - test('getCurrentPosition throws when permission is denied', () async { - fake.status = 2; // denied -> deniedForever - await expectLater( - geo.getCurrentPosition(), - throwsA(isA()), - ); - }); + test('throws when permission is denied', () async { + fake.status = 2; // denied -> deniedForever + await expectLater( + geo.getCurrentPosition(), + throwsA(isA()), + ); + }); + + test('times out when no fix arrives, and stops asking', () async { + await expectLater( + geo.getCurrentPosition( + locationSettings: + const LocationSettings(timeLimit: Duration(milliseconds: 10))), + throwsA(isA()), + ); + expect(fake.stopRequestCount, 1); + }); - test('getCurrentPosition times out when no fix arrives', () async { - fake.fix = null; - await expectLater( - geo.getCurrentPosition( + test('the fix cached before the request is not the answer', () async { + fake.fix = _fix()..[0] = 1.0; + final Future pending = geo.getCurrentPosition( locationSettings: - const LocationSettings(timeLimit: Duration(milliseconds: 10))), - throwsA(isA()), - ); + const LocationSettings(timeLimit: Duration(seconds: 2))); + await pumpEventQueue(); + // A signal without a new fix, as a failed update sends. + fake.signal(GeolocatorWatchosSignal.position); + await pumpEventQueue(); + fake.deliver(_fix()..[0] = 10.0); + expect((await pending).latitude, 10.0); + }); + + test('two at once share the single-request manager', () async { + final Future a = geo.getCurrentPosition(); + final Future b = geo.getCurrentPosition(); + await pumpEventQueue(); + expect(fake.requestLocationCount, 2); + fake.deliver(_fix()..[0] = 12.0); + expect((await a).latitude, 12.0); + expect((await b).latitude, 12.0); + expect(fake.stopRequestCount, 1, reason: 'stopped once both are done'); + }); }); test('getLastKnownPosition returns null when there is no fix', () async { @@ -215,35 +271,98 @@ void main() { expect((await geo.getLastKnownPosition())!.latitude, 37.7749); }); - test('getPositionStream emits the fix already available on listen', () async { - // CLLocationManager hands over its last known fix as soon as updates - // start, which can land before the callback is registered. - fake.fix = _fix(); - final Position first = await geo.getPositionStream().first; - expect(first.latitude, 37.7749); - expect(fake.stopCount, greaterThanOrEqualTo(1)); - }); + group('getPositionStream', () { + test('emits the fix CoreLocation hands over at start, however old', + () async { + fake.fixOnStart = _fix()..[9] = 1700000000000.0; + final Position first = await geo.getPositionStream().first; + expect(first.timestamp.millisecondsSinceEpoch, 1700000000000); + expect(fake.stopCount, 1); + }); - test('getPositionStream emits each new fix native signals', () async { - final List seen = []; - final StreamSubscription sub = - geo.getPositionStream().listen(seen.add); - await pumpEventQueue(); - expect(fake.isRegistered, isTrue); + test('emits each fix native delivers, and nothing else', () async { + final List seen = []; + final StreamSubscription sub = + geo.getPositionStream().listen(seen.add); + await pumpEventQueue(); + expect(fake.isRegistered, isTrue); - final List moved = _fix()..[0] = 40.7128; // New York - fake.deliver(moved); - await pumpEventQueue(); + final List moved = _fix()..[0] = 40.7128; // New York + fake.deliver(moved); + await pumpEventQueue(); + // A second fix at the same place is a fix too. + fake.deliver(moved); + await pumpEventQueue(); + // Signals without a new fix are not. + fake.signal(GeolocatorWatchosSignal.position); + fake.signal(GeolocatorWatchosSignal.authorization); + await pumpEventQueue(); - // The same fix again must not emit — a repeated signal is not movement. - fake.deliver(moved); - await pumpEventQueue(); + expect(seen.map((Position p) => p.latitude), [40.7128, 40.7128]); - expect(seen.map((Position p) => p.latitude), [40.7128]); + await sub.cancel(); + expect(fake.isRegistered, isFalse); + expect(fake.stopCount, 1); + }); - await sub.cancel(); - expect(fake.isRegistered, isFalse); - expect(fake.stopCount, greaterThanOrEqualTo(1)); + test('a second call gets the running stream, with its settings', () async { + final Stream first = geo.getPositionStream( + locationSettings: AppleSettings( + accuracy: LocationAccuracy.bestForNavigation, + distanceFilter: 25)); + final StreamSubscription a = first.listen((_) {}); + final Stream second = geo.getPositionStream( + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.low)); + expect(identical(first, second), isTrue); + final StreamSubscription b = second.listen((_) {}); + await pumpEventQueue(); + expect(fake.startCount, 1); + expect(fake.lastAccuracyIndex, LocationAccuracy.bestForNavigation.index); + expect(fake.lastDistanceFilter, 25); + expect(fake.lastAllowBackground, isTrue); + + await a.cancel(); + expect(fake.stopCount, 0, reason: 'the second listener remains'); + await b.cancel(); + expect(fake.stopCount, 1); + + // Once it has ended, a new stream starts with its own settings. + final StreamSubscription c = geo + .getPositionStream( + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.low)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.startCount, 2); + expect(fake.lastAccuracyIndex, LocationAccuracy.low.index); + await c.cancel(); + }); + + test( + 'a single fix during a distance-filtered stream does not wait for ' + 'the stream', () async { + final StreamSubscription sub = geo + .getPositionStream( + locationSettings: const LocationSettings(distanceFilter: 100)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.startCount, 1); + + // The wearer stands still: the stream delivers nothing, the + // single-request manager does. + fake.singleFix = _fix()..[0] = 20.0; + final Position pos = await geo.getCurrentPosition( + locationSettings: + const LocationSettings(timeLimit: Duration(seconds: 2))); + expect(pos.latitude, 20.0); + expect(fake.requestLocationCount, 1); + expect(fake.startCount, 1, reason: 'the stream keeps its settings'); + expect(fake.stopCount, 0, reason: 'the stream still runs'); + + await sub.cancel(); + expect(fake.stopCount, 1); + }); }); group('accuracy', () { @@ -269,7 +388,7 @@ void main() { fake.accuracy = 1; fake.accuracyDone++; - fake.signal(); + fake.signal(GeolocatorWatchosSignal.accuracy); expect(await pending, LocationAccuracyStatus.precise); expect(fake.isRegistered, isFalse); }); @@ -305,12 +424,13 @@ void main() { // The trampoline runs asynchronously and re-reads the status, so each // change is let through before the next. fake.serviceEnabled = false; - fake.signal(); + fake.signal(GeolocatorWatchosSignal.authorization); await pumpEventQueue(); - fake.signal(); // no change: not repeated + // no change: not repeated + fake.signal(GeolocatorWatchosSignal.authorization); await pumpEventQueue(); fake.serviceEnabled = true; - fake.signal(); + fake.signal(GeolocatorWatchosSignal.authorization); await pumpEventQueue(); expect(seen, [ @@ -322,6 +442,17 @@ void main() { expect(fake.isRegistered, isFalse); }); + test('a fix does not re-read the services switch', () async { + final StreamSubscription sub = + geo.getServiceStatusStream().listen((_) {}); + await pumpEventQueue(); + final int checks = fake.serviceChecks; + fake.deliver(_fix()); + await pumpEventQueue(); + expect(fake.serviceChecks, checks); + await sub.cancel(); + }); + test('a second listener starts with the status now', () async { final StreamSubscription first = geo.getServiceStatusStream().listen((_) {}); @@ -348,86 +479,45 @@ void main() { test('AppleSettings fitness and background reach CoreLocation', () async { final StreamSubscription sub = geo .getPositionStream( - locationSettings: const _AppleLikeSettings( - activityTypeIndex: 1, allowBackground: true)) + locationSettings: AppleSettings( + accuracy: LocationAccuracy.bestForNavigation, + activityType: ActivityType.fitness)) .listen((_) {}); await pumpEventQueue(); expect(fake.lastActivityType, 3, reason: 'CLActivityTypeFitness'); - expect(fake.lastAllowBackground, isTrue); + expect(fake.lastAllowBackground, isTrue, + reason: "AppleSettings' default"); expect(fake.lastAccuracyIndex, LocationAccuracy.bestForNavigation.index); await sub.cancel(); }); - test('every geolocator activity type maps to its CLActivityType', () async { - // automotiveNavigation, fitness, otherNavigation, airborne, other, - // maritime. - const List expected = [2, 3, 4, 5, 1, 6]; - for (int i = 0; i < expected.length; i++) { - fake.fix = _fix(); - await geo.getCurrentPosition( - locationSettings: _AppleLikeSettings( - activityTypeIndex: i, allowBackground: false)); - expect(fake.lastActivityType, expected[i]); + test('every geolocator activity type reaches its CLActivityType', () async { + // CLActivityType raw values; 6 is the maritime value native maps. + const Map expected = { + ActivityType.other: 1, + ActivityType.automotiveNavigation: 2, + ActivityType.fitness: 3, + ActivityType.otherNavigation: 4, + ActivityType.airborne: 5, + ActivityType.maritime: 6, + }; + for (final ActivityType type in ActivityType.values) { + final StreamSubscription sub = geo + .getPositionStream( + locationSettings: AppleSettings(activityType: type)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.lastActivityType, expected[type], reason: type.name); + await sub.cancel(); } }); test('an unknown activity index leaves the default', () async { - fake.fix = _fix(); - await geo.getCurrentPosition( - locationSettings: const _AppleLikeSettings( - activityTypeIndex: 42, allowBackground: false)); - expect(fake.lastActivityType, 0); - }); - }); - - group('sharing the location manager', () { - test('a one-shot during a stream leaves the stream running', () async { - final StreamSubscription sub = - geo.getPositionStream().listen((_) {}); - await pumpEventQueue(); - expect(fake.startCount, 1); - - fake.fix = _fix(); - await geo.getCurrentPosition(); - expect(fake.stopCount, 0, reason: 'the stream still needs updates'); - expect(fake.startCount, 1, reason: 'the stream keeps its settings'); - - await sub.cancel(); - expect(fake.stopCount, 1); - }); - - test('two streams: the first cancel does not stop the second', () async { - final StreamSubscription a = - geo.getPositionStream().listen((_) {}); - final StreamSubscription b = - geo.getPositionStream().listen((_) {}); - await pumpEventQueue(); - await a.cancel(); - expect(fake.stopCount, 0); - await b.cancel(); - expect(fake.stopCount, 1); - }); - - test('a stale cached fix is not the current position', () async { - fake.fix = _fix()..[9] = 1700000000000.0; - final Future pending = geo.getCurrentPosition( - locationSettings: - const LocationSettings(timeLimit: Duration(seconds: 2))); - await pumpEventQueue(); - fake.deliver(_fix()..[0] = 10.0); - expect((await pending).latitude, 10.0); - }); - - test('a stream does not start with a stale cached fix', () async { - fake.fix = _fix()..[9] = 1700000000000.0; - final List seen = []; - final StreamSubscription sub = - geo.getPositionStream().listen(seen.add); - await pumpEventQueue(); - expect(seen, isEmpty); - fake.deliver(_fix()..[0] = 11.0); + final StreamSubscription sub = geo + .getPositionStream(locationSettings: const _UnknownActivitySettings()) + .listen((_) {}); await pumpEventQueue(); - expect(seen.single.latitude, 11.0); + expect(fake.lastActivityType, 0); await sub.cancel(); }); }); diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h index e8841fb..1edb44e 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.h @@ -4,10 +4,12 @@ // // watchOS implementation of `geolocator`, over dart:ffi. // -// Backed by CoreLocation (CLLocationManager), which is available on watchOS. -// Location updates arrive asynchronously on a delegate, which caches the -// latest fix and wakes Dart through the callback below; Dart reads it back -// with `read_position`. Permission requests and authorization status map to +// Backed by CoreLocation, which is available on watchOS. As in +// geolocator_apple, two CLLocationManagers share one delegate: one runs the +// position stream with its settings, the other answers single position +// requests. The delegate caches the latest fix, counts the fixes it accepts, +// and wakes Dart through the callback below; Dart reads the fix back with +// `read_position`. Permission requests and authorization status map to // geolocator's LocationPermission. #ifndef GEOLOCATOR_WATCHOS_FFI_H @@ -35,10 +37,11 @@ int geolocator_watchos_check_permission(void); GEOLOCATOR_WATCHOS_EXPORT void geolocator_watchos_request_permission(void); -// Begins continuous updates. `accuracy` is a geolocator LocationAccuracy index -// (0 lowest … 4 best, 5 bestForNavigation, 6 reduced); `distance_filter` is in -// metres (<= 0 means none). `activity_type` is a CLActivityType raw value, or -// 0 to leave CoreLocation's default. A nonzero `allow_background` turns on +// Begins the stream's continuous updates. `accuracy` is a geolocator +// LocationAccuracy index (0 lowest … 4 best, 5 bestForNavigation, 6 reduced); +// `distance_filter` is in metres (<= 0 means none). `activity_type` is a +// CLActivityType raw value, or 6 for maritime; any other value, 0 included, +// means CLActivityTypeOther. A nonzero `allow_background` turns on // allowsBackgroundLocationUpdates, but only when the Info.plist's // UIBackgroundModes has "location": CoreLocation asserts otherwise, and does // so even when WKBackgroundModes has it. @@ -46,9 +49,15 @@ GEOLOCATOR_WATCHOS_EXPORT void geolocator_watchos_start_updates(int accuracy, double distance_filter, int activity_type, int allow_background); -// Requests a single location fix. +// Starts the single-request manager at `accuracy` (as above), with no +// distance filter. It skips cached fixes more than 5 seconds old and stops +// itself after the first fix it accepts. GEOLOCATOR_WATCHOS_EXPORT -void geolocator_watchos_request_location(void); +void geolocator_watchos_request_location(int accuracy); + +// Stops the single-request manager, for a request that gave up. +GEOLOCATOR_WATCHOS_EXPORT +void geolocator_watchos_stop_location_request(void); // Fills out[0..9] with latitude, longitude, accuracy, altitude, // altitudeAccuracy, heading, headingAccuracy, speed, speedAccuracy, and the @@ -56,7 +65,12 @@ void geolocator_watchos_request_location(void); GEOLOCATOR_WATCHOS_EXPORT int geolocator_watchos_read_position(double* out); -// Stops continuous updates. +// How many fixes either manager has delivered: it goes up with each one, so +// Dart can tell a new fix from the cached one, even at the same place. +GEOLOCATOR_WATCHOS_EXPORT +int64_t geolocator_watchos_position_count(void); + +// Stops the stream's continuous updates. GEOLOCATOR_WATCHOS_EXPORT void geolocator_watchos_stop_updates(void); @@ -77,13 +91,18 @@ int geolocator_watchos_request_temporary_full_accuracy(const char* purpose_key); GEOLOCATOR_WATCHOS_EXPORT int64_t geolocator_watchos_accuracy_requests_done(void); +// What a signal reports, so Dart re-reads only what changed. +#define GEOLOCATOR_WATCHOS_SIGNAL_POSITION 0 // a fix, or a failed update +#define GEOLOCATOR_WATCHOS_SIGNAL_AUTHORIZATION 1 // authorization or service +#define GEOLOCATOR_WATCHOS_SIGNAL_ACCURACY 2 // an accuracy request ended + /// Called from the CLLocationManager delegate when a new fix lands, the /// authorization or accuracy changes, or a temporary accuracy request ends. /// -/// Carries no value: the Dart end is a `NativeCallable.listener` and runs -/// asynchronously, so it re-reads what it waits on rather than trusting a -/// pointer captured at signal time. -typedef void (*geolocator_watchos_cb)(int64_t unused); +/// Carries one of the kinds above and no value: the Dart end is a +/// `NativeCallable.listener` and runs asynchronously, so it re-reads what it +/// waits on rather than trusting a pointer captured at signal time. +typedef void (*geolocator_watchos_cb)(int64_t kind); /// Registers the function to wake Dart on a new fix, or NULL to stop. GEOLOCATOR_WATCHOS_EXPORT diff --git a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m index 7953f0a..35f3bf3 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m @@ -2,10 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // -// CoreLocation-backed geolocation for watchOS. A shared CLLocationManager with -// a delegate caches the most recent fix and wakes Dart; the FFI -// `read_position` copies it out. Accuracy indices and the Position field order -// match geolocator. +// CoreLocation-backed geolocation for watchOS. As in geolocator_apple, the +// position stream and single position requests each have a CLLocationManager, +// so a single request neither waits on the stream's distance filter nor +// changes its settings. One delegate serves both: it caches the most recent +// fix, counts the fixes it accepts, and wakes Dart; the FFI `read_position` +// copies the fix out. Accuracy indices and the Position field order match +// geolocator. #import "geolocator_watchos_ffi.h" @@ -19,25 +22,34 @@ int has; } Fix; +// A single request skips fixes older than this, as geolocator_apple does: +// CoreLocation hands over its last fix first, however old. +static const NSTimeInterval kMaxFixAgeSeconds = 5.0; + static os_unfair_lock _lock = OS_UNFAIR_LOCK_INIT; static Fix _fix; +static int64_t _fixCount = 0; static geolocator_watchos_cb _callback = NULL; static int64_t _accuracyRequestsDone = 0; +// Created once, on the main queue; see _createManagers. +static CLLocationManager* _streamManager; +static CLLocationManager* _singleManager; + void geolocator_watchos_set_callback(geolocator_watchos_cb callback) { os_unfair_lock_lock(&_lock); _callback = callback; os_unfair_lock_unlock(&_lock); } -// Wakes Dart. The callback pointer is read under the lock but invoked outside -// it, because Dart may call straight back into this file. -static void _signal(void) { +// Wakes Dart with what changed. The callback pointer is read under the lock +// but invoked outside it, because Dart may call straight back into this file. +static void _signal(int64_t kind) { os_unfair_lock_lock(&_lock); geolocator_watchos_cb callback = _callback; os_unfair_lock_unlock(&_lock); if (callback != NULL) { - callback(0); + callback(kind); } } @@ -52,6 +64,13 @@ - (void)locationManager:(CLLocationManager*)manager if (location == nil) { return; } + BOOL oneTime = manager == _singleManager; + if (oneTime && -[location.timestamp timeIntervalSinceNow] > kMaxFixAgeSeconds) { + return; + } + if (oneTime) { + [manager stopUpdatingLocation]; + } os_unfair_lock_lock(&_lock); _fix.latitude = location.coordinate.latitude; _fix.longitude = location.coordinate.longitude; @@ -64,39 +83,61 @@ - (void)locationManager:(CLLocationManager*)manager _fix.speedAccuracy = location.speedAccuracy; _fix.timestampMillis = location.timestamp.timeIntervalSince1970 * 1000.0; _fix.has = 1; + _fixCount++; os_unfair_lock_unlock(&_lock); - _signal(); + _signal(GEOLOCATOR_WATCHOS_SIGNAL_POSITION); } - (void)locationManager:(CLLocationManager*)manager didFailWithError:(NSError*)error { // Leave the last cached fix in place; the caller's deadline decides. // Still wake Dart: a waiter should re-check rather than sit until timeout. - _signal(); + _signal(GEOLOCATOR_WATCHOS_SIGNAL_POSITION); } // Authorization changes are the other thing Dart waits on — the answer to the -// system permission prompt arrives here, not on any value it could poll. +// system permission prompt arrives here, not on any value it could poll. A +// change of the location services switch arrives here too. Both managers +// report it, so the stream's alone wakes Dart. - (void)locationManagerDidChangeAuthorization:(CLLocationManager*)manager { - _signal(); + if (manager != _singleManager) { + _signal(GEOLOCATOR_WATCHOS_SIGNAL_AUTHORIZATION); + } } @end -static CLLocationManager* _manager(void) { - static CLLocationManager* manager; - static GeolocatorWatchosDelegate* delegate; +static GeolocatorWatchosDelegate* _delegate; + +static void _createManagers(void) { static dispatch_once_t once; dispatch_once(&once, ^{ // CLLocationManager must be created on a thread with a run loop; the // main queue is guaranteed to have one. - dispatch_sync(dispatch_get_main_queue(), ^{ - delegate = [[GeolocatorWatchosDelegate alloc] init]; - manager = [[CLLocationManager alloc] init]; - manager.delegate = delegate; - }); + void (^create)(void) = ^{ + _delegate = [[GeolocatorWatchosDelegate alloc] init]; + _streamManager = [[CLLocationManager alloc] init]; + _streamManager.delegate = _delegate; + _singleManager = [[CLLocationManager alloc] init]; + _singleManager.delegate = _delegate; + }; + if ([NSThread isMainThread]) { + create(); + } else { + dispatch_sync(dispatch_get_main_queue(), create); + } }); - return manager; +} + +// The stream's manager, which also answers permission and accuracy queries. +static CLLocationManager* _manager(void) { + _createManagers(); + return _streamManager; +} + +static CLLocationManager* _oneTimeManager(void) { + _createManagers(); + return _singleManager; } static CLLocationAccuracy _accuracyFor(int index) { @@ -146,10 +187,15 @@ static BOOL _hasBackgroundLocationMode(void) { static CLActivityType _activityTypeFor(int raw) { if (raw == 6) { + // CLActivityTypeMaritime is declared only from the watchOS 27 SDK on; + // Xcode 26 builds, and older watches, get what geolocator_apple + // falls back to. +#if __WATCH_OS_VERSION_MAX_ALLOWED >= 270000 if (@available(watchOS 27.0, *)) { return CLActivityTypeMaritime; } - return CLActivityTypeOther; +#endif + return CLActivityTypeOtherNavigation; } return raw >= CLActivityTypeOther && raw <= CLActivityTypeAirborne ? (CLActivityType)raw @@ -164,18 +210,27 @@ void geolocator_watchos_start_updates(int accuracy, double distance_filter, m.desiredAccuracy = _accuracyFor(accuracy); m.distanceFilter = distance_filter > 0 ? distance_filter : kCLDistanceFilterNone; - if (activity_type != 0) { - m.activityType = _activityTypeFor(activity_type); - } + // Set every time, so a stream without AppleSettings does not keep the + // previous stream's type. + m.activityType = _activityTypeFor(activity_type); m.allowsBackgroundLocationUpdates = background; [m startUpdatingLocation]; }); } -void geolocator_watchos_request_location(void) { - CLLocationManager* m = _manager(); +void geolocator_watchos_request_location(int accuracy) { + CLLocationManager* m = _oneTimeManager(); dispatch_async(dispatch_get_main_queue(), ^{ - [m requestLocation]; + m.desiredAccuracy = _accuracyFor(accuracy); + m.distanceFilter = kCLDistanceFilterNone; + [m startUpdatingLocation]; + }); +} + +void geolocator_watchos_stop_location_request(void) { + CLLocationManager* m = _oneTimeManager(); + dispatch_async(dispatch_get_main_queue(), ^{ + [m stopUpdatingLocation]; }); } @@ -198,6 +253,13 @@ int geolocator_watchos_read_position(double* out) { return has; } +int64_t geolocator_watchos_position_count(void) { + os_unfair_lock_lock(&_lock); + int64_t count = _fixCount; + os_unfair_lock_unlock(&_lock); + return count; +} + void geolocator_watchos_stop_updates(void) { CLLocationManager* m = _manager(); dispatch_async(dispatch_get_main_queue(), ^{ @@ -225,7 +287,7 @@ int geolocator_watchos_request_temporary_full_accuracy(const char* purpose_key) os_unfair_lock_lock(&_lock); _accuracyRequestsDone++; os_unfair_lock_unlock(&_lock); - _signal(); + _signal(GEOLOCATOR_WATCHOS_SIGNAL_ACCURACY); }]; }); return 1;