diff --git a/packages/geolocator_watchos/CHANGELOG.md b/packages/geolocator_watchos/CHANGELOG.md index 237eeee..22d9875 100644 --- a/packages/geolocator_watchos/CHANGELOG.md +++ b/packages/geolocator_watchos/CHANGELOG.md @@ -1,3 +1,64 @@ +## 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** + +* `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. 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, and `geolocator_watchos_request_location` the accuracy. + +**Fixed** + +* `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, 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. + ## 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..33f1c61 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 | @@ -70,11 +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` | ✗ 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` (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 c0d1e45..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.1 + geolocator_watchos: ^0.2.0 ``` The plugin registers automatically via Flutter's federated registry — no @@ -37,12 +38,58 @@ 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` | +| `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`, 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( + locationSettings: AppleSettings( + accuracy: LocationAccuracy.bestForNavigation, + activityType: ActivityType.fitness, + allowBackgroundLocationUpdates: true, + ), +); +``` + +Background updates turn on only when the `Info.plist`'s `UIBackgroundModes` +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 + +``` +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. `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 | Platform | Implemented | @@ -62,9 +109,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..ba9a519 100644 --- a/packages/geolocator_watchos/lib/geolocator_watchos.dart +++ b/packages/geolocator_watchos/lib/geolocator_watchos.dart @@ -6,9 +6,15 @@ // // 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 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 +// without this package depending on geolocator_apple. import 'dart:async'; import 'dart:ffi'; @@ -29,37 +35,88 @@ abstract class GeolocatorWatchosBackend { /// with [checkPermission]. void requestPermission(); - /// Starts continuous updates at [accuracyIndex] (the `LocationAccuracy` - /// index) with [distanceFilter] in metres (0 or less means none). - void startUpdates(int accuracyIndex, double distanceFilter); - - /// Asks for a single fix. - void requestLocation(); + /// 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 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, { + int activityType = 0, + bool allowBackground = false, + }); + + /// 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(); - /// 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, 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. 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); static GeolocatorWatchosBackend? _backend; + /// Pending `getCurrentPosition` calls. They share the single-request + /// manager, which stops when the last of them ends. + static int _singleRequests = 0; + + /// 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()); @@ -121,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(); @@ -154,28 +212,31 @@ 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(); + // 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 && !completer.isCompleted) { + if (fix != null) { completer.complete(_toPosition(fix)); } } - final void Function() stopListening = _Notifier.listen(_b, check); - _b.requestLocation(); - _b.startUpdates(accuracy.index, distanceFilter); - // 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(); + final void Function() stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.position, check); + _singleRequests++; + _b.requestLocation( + (locationSettings?.accuracy ?? LocationAccuracy.best).index); try { return await completer.future.timeout( @@ -185,52 +246,155 @@ base class GeolocatorWatchos extends GeolocatorPlatform { ); } finally { stopListening(); - _b.stopUpdates(); + _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 LocationAccuracy accuracy = - locationSettings?.accuracy ?? LocationAccuracy.best; - final double distanceFilter = - (locationSettings?.distanceFilter ?? 0).toDouble(); + final Stream? running = _positionStream; + if (running != null) { + return running; + } late StreamController controller; - List? last; + 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 && !_sameFix(fix, last)) { - last = fix; + if (fix != null) { controller.add(_toPosition(fix)); } } controller = StreamController.broadcast( onListen: () { - stopListening = _Notifier.listen(_b, emitIfNew); - _b.startUpdates(accuracy.index, distanceFilter); - // CLLocationManager delivers the last known fix immediately on start, - // which can land before the callback is in place. - emitIfNew(); + seen = _b.positionCount(); + stopListening = + _Notifier.listen(_b, GeolocatorWatchosSignal.position, emitIfNew); + _start(locationSettings); }, onCancel: () { stopListening?.call(); stopListening = null; + _positionStream = null; _b.stopUpdates(); }, ); - return controller.stream; + return _positionStream = controller.stream; + } + + /// 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, + ); } - static bool _sameFix(List a, List? b) => - b != null && a[0] == b[0] && a[1] == b[1] && a[9] == b[9]; + @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, GeolocatorWatchosSignal.accuracy, 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: `locationServicesEnabled` + /// blocks, so it is not asked on every fix. + @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, GeolocatorWatchosSignal.authorization, 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. +/// Signature of the native→Dart signal, which carries a +/// [GeolocatorWatchosSignal] kind. typedef GeolocatorFixNative = Void Function(Int64); /// Fan-out for the single native callback slot. @@ -241,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. @@ -250,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. @@ -290,10 +457,14 @@ 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); +typedef _AccuracyNative = Void Function(Int32); +typedef _AccuracyDart = void Function(int); /// Resolves the CoreLocation C symbols and marshals the fix buffer. class _FfiBackend implements GeolocatorWatchosBackend { @@ -314,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'); @@ -339,11 +516,23 @@ 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(int accuracyIndex) => _requestLocation(accuracyIndex); @override - void requestLocation() => _requestLocation(); + void stopLocationRequest() => _stopLocationRequest(); + + @override + int positionCount() => _positionCount(); @override List? readPosition() { @@ -358,6 +547,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..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.1 +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,6 +53,11 @@ 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 + - 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..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,32 +11,99 @@ 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; + + @override + void startUpdates( + int accuracyIndex, + double distanceFilter, { + int activityType = 0, + bool allowBackground = false, + }) { + startCount++; + lastAccuracyIndex = accuracyIndex; + lastDistanceFilter = distanceFilter; + lastActivityType = activityType; + lastAllowBackground = allowBackground; + final List? first = fixOnStart; + if (first != null) { + deliver(first); + } + } + @override - void startUpdates(int accuracyIndex, double distanceFilter) => startCount++; + void requestLocation(int accuracyIndex) { + requestLocationCount++; + lastRequestAccuracy = accuracyIndex; + final List? next = singleFix; + if (next != null) { + deliver(next); + } + } + @override - void requestLocation() {} + void stopLocationRequest() => stopRequestCount++; @override List? readPosition() => fix; @override + int positionCount() => count; + @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. @@ -46,16 +113,28 @@ 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); } } +/// Settings whose `toJson()` names an activity type no geolocator version +/// has. +class _UnknownActivitySettings extends LocationSettings { + const _UnknownActivitySettings(); + + @override + Map toJson() => + super.toJson()..addAll({'activityType': 42}); +} + /// A representative fix: San Francisco, with distinct field values. List _fix() => [ 37.7749, // latitude @@ -67,7 +146,7 @@ List _fix() => [ 1.0, // headingAccuracy 0.0, // speed 0.5, // speedAccuracy - 1700000000000.0, // timestamp millis + DateTime.now().millisecondsSinceEpoch.toDouble(), // timestamp millis ]; void main() { @@ -110,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); }); @@ -128,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 - expect( - 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; - expect( - 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 { @@ -163,34 +271,254 @@ 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('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(); + // 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(); + + expect(seen.map((Position p) => p.latitude), [40.7128, 40.7128]); + + await sub.cancel(); + expect(fake.isRegistered, isFalse); + expect(fake.stopCount, 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); + }); }); - 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); + 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); + }); - final List moved = _fix()..[0] = 40.7128; // New York - fake.deliver(moved); - await pumpEventQueue(); + 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'); - // The same fix again must not emit — a repeated signal is not movement. - fake.deliver(moved); - await pumpEventQueue(); + fake.accuracy = 1; + fake.accuracyDone++; + fake.signal(GeolocatorWatchosSignal.accuracy); + 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(GeolocatorWatchosSignal.authorization); + await pumpEventQueue(); + // no change: not repeated + fake.signal(GeolocatorWatchosSignal.authorization); + await pumpEventQueue(); + fake.serviceEnabled = true; + fake.signal(GeolocatorWatchosSignal.authorization); + await pumpEventQueue(); + + expect(seen, [ + ServiceStatus.enabled, + ServiceStatus.disabled, + ServiceStatus.enabled, + ]); + await sub.cancel(); + 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((_) {}); + 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: AppleSettings( + accuracy: LocationAccuracy.bestForNavigation, + activityType: ActivityType.fitness)) + .listen((_) {}); + await pumpEventQueue(); + expect(fake.lastActivityType, 3, reason: 'CLActivityTypeFitness'); + expect(fake.lastAllowBackground, isTrue, + reason: "AppleSettings' default"); + expect(fake.lastAccuracyIndex, LocationAccuracy.bestForNavigation.index); + await sub.cancel(); + }); - expect(seen.map((Position p) => p.latitude), [40.7128]); + 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(); + } + }); - await sub.cancel(); - expect(fake.isRegistered, isFalse); - expect(fake.stopCount, greaterThanOrEqualTo(1)); + test('an unknown activity index leaves the default', () async { + final StreamSubscription sub = geo + .getPositionStream(locationSettings: const _UnknownActivitySettings()) + .listen((_) {}); + await pumpEventQueue(); + 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 9edd98c..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,13 @@ // // 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; the Dart side polls `read_position`. Permission requests and -// authorization status map to geolocator's LocationPermission. +// 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 #define GEOLOCATOR_WATCHOS_FFI_H @@ -34,15 +37,27 @@ 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). +// 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. 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. +// 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 @@ -50,16 +65,44 @@ 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); -/// 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); + +// 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 `..._read_position` 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 9ff5207..35f3bf3 100644 --- a/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m +++ b/packages/geolocator_watchos/watchos/Classes/geolocator_watchos_ffi.m @@ -2,9 +2,12 @@ // 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; the FFI `read_position` copies it out -// for the Dart poller. Accuracy indices and the Position field order match +// 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,9 +22,19 @@ 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); @@ -29,14 +42,14 @@ void geolocator_watchos_set_callback(geolocator_watchos_cb 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); } } @@ -51,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; @@ -63,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) { @@ -133,20 +175,62 @@ 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 +// 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) { + id modes = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIBackgroundModes"]; + return [modes isKindOfClass:[NSArray class]] && [modes containsObject:@"location"]; +} + +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; + } +#endif + return CLActivityTypeOtherNavigation; + } + 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; + // 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.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 requestLocation]; + [m stopUpdatingLocation]; }); } @@ -169,9 +253,49 @@ 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(), ^{ [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(GEOLOCATOR_WATCHOS_SIGNAL_ACCURACY); + }]; + }); + 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; +}