Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mapbox_maps_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
### main

* Add `MapboxMap.location.setExternalLocation`/`.clearExternalLocation` (Android and iOS; web throws `UnsupportedError`/no-ops), letting apps drive the location puck from a location source other than the platform's default GPS-based provider (e.g. an indoor-positioning SDK). Resolves [#1085](https://github.com/mapbox/mapbox-maps-flutter/issues/1085).

### 3.0.0

* [Web] Add style terrain support: `setStyleTerrain`, `getStyleTerrainProperty`, and `setStyleTerrainProperty`.
Expand Down
43 changes: 43 additions & 0 deletions mapbox_maps_flutter/lib/src/location_settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,49 @@ class LocationSettingsManager {
/// ```
Future<void> updateSettings(LocationComponentSettings settings) =>
_impl.updateSettings(settings);

/// Pushes an externally-sourced location into the native location-provider
/// override, replacing whatever Mapbox's default location provider (GPS)
/// would otherwise show. Registers the override on first call; the map
/// behaves exactly as stock `mapbox_maps_flutter` until this is called at
/// least once.
///
/// [timestamp] defaults to now if omitted. [floor] is only meaningful on
/// iOS (Mapbox's native `Location` type carries it; the Android
/// `LocationConsumer` API has no floor concept, so it is dropped there).
///
/// Amuse fork addition. Android and iOS only; throws [UnsupportedError] on
/// web.
///
/// ```dart
/// mapboxMap.location.setExternalLocation(
/// latitude: 37.775,
/// longitude: -122.418,
/// heading: 90.0,
/// accuracy: 5.0,
/// );
/// ```
Future<void> setExternalLocation({
required double latitude,
required double longitude,
double? accuracy,
double? heading,
double? headingAccuracy,
int? floor,
DateTime? timestamp,
}) => _impl.setExternalLocation(
latitude: latitude,
longitude: longitude,
accuracy: accuracy,
heading: heading,
headingAccuracy: headingAccuracy,
floor: floor,
timestamp: timestamp,
);

/// Clears the override and restores Mapbox's default location provider
/// (back to normal GPS). Amuse fork addition.
Future<void> clearExternalLocation() => _impl.clearExternalLocation();
}

/// Deprecated: Use [LocationSettingsManager] instead.
Expand Down
58 changes: 58 additions & 0 deletions mapbox_maps_flutter/test/location_settings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ class MockLocationSettingsPlatformInterface implements LocationSettingsPlatformI
LocationComponentSettings settingsToReturn = LocationComponentSettings();
int getSettingsCallCount = 0;
int updateSettingsCallCount = 0;
Map<String, Object?>? lastExternalLocation;
int clearExternalLocationCallCount = 0;

@override
Future<LocationComponentSettings> getSettings() async {
Expand All @@ -19,6 +21,32 @@ class MockLocationSettingsPlatformInterface implements LocationSettingsPlatformI
updateSettingsCallCount++;
lastUpdatedSettings = settings;
}

@override
Future<void> setExternalLocation({
required double latitude,
required double longitude,
double? accuracy,
double? heading,
double? headingAccuracy,
int? floor,
DateTime? timestamp,
}) async {
lastExternalLocation = {
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy,
'heading': heading,
'headingAccuracy': headingAccuracy,
'floor': floor,
'timestamp': timestamp,
};
}

@override
Future<void> clearExternalLocation() async {
clearExternalLocationCallCount++;
}
}

void main() {
Expand Down Expand Up @@ -78,5 +106,35 @@ void main() {
expect(updated.showAccuracyRing, true);
expect(updated.puckBearingEnabled, true);
});

test('setExternalLocation delegates every field to interface', () async {
final timestamp = DateTime.utc(2026, 1, 1, 12);

await locationSettings.setExternalLocation(
latitude: 1.5,
longitude: 2.5,
accuracy: 5.0,
heading: 90.0,
headingAccuracy: 3.0,
floor: 2,
timestamp: timestamp,
);

expect(mockImpl.lastExternalLocation, {
'latitude': 1.5,
'longitude': 2.5,
'accuracy': 5.0,
'heading': 90.0,
'headingAccuracy': 3.0,
'floor': 2,
'timestamp': timestamp,
});
});

test('clearExternalLocation delegates to interface', () async {
await locationSettings.clearExternalLocation();

expect(mockImpl.clearExternalLocationCallCount, 1);
});
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.mapbox.maps.mapbox_maps

import com.mapbox.geojson.Point
import com.mapbox.maps.plugin.locationcomponent.LocationConsumer
import com.mapbox.maps.plugin.locationcomponent.LocationProvider
import java.util.concurrent.CopyOnWriteArrayList

/**
* A [LocationProvider] whose data comes from outside Mapbox's own location
* stack. Dart pushes updates into it via [LocationComponentController]'s
* `setExternalLocation`/`clearExternalLocation` platform-channel handlers,
* instead of Mapbox reading the device's location itself through
* [com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider].
*
* Registered with `mapView.location.setLocationProvider(...)` the first time
* `setExternalLocation` is called. Until then, the map behaves exactly as it
* does today (the default provider, unmodified).
*
* Note: unlike iOS's `Location`, this SDK's [LocationConsumer] has no floor
* concept at all — only [Point] and bearing/accuracy. Floor never flowed
* through Mapbox's location APIs on Android; callers that need floor-aware
* behavior (e.g. indoor-map puck opacity) handle it entirely separately,
* unaffected by this override.
*/
class ExternalLocationProvider : LocationProvider {
// Consumers come and go with puck visibility (same contract as any other
// LocationProvider) — a plain thread-safe list, since updates can arrive
// off the main thread depending on where the platform channel dispatches.
private val consumers = CopyOnWriteArrayList<LocationConsumer>()

override fun registerLocationConsumer(locationConsumer: LocationConsumer) {
consumers.add(locationConsumer)
}

override fun unRegisterLocationConsumer(locationConsumer: LocationConsumer) {
consumers.remove(locationConsumer)
}

/** Pushes a new position to every registered consumer. */
fun updateLocation(point: Point) {
consumers.forEach { it.onLocationUpdated(point) }
}

/** Pushes a new bearing/heading to every registered consumer. */
fun updateBearing(bearing: Double) {
consumers.forEach { it.onBearingUpdated(bearing) }
}

/** Pushes a new horizontal accuracy radius to every registered consumer. */
fun updateAccuracyRadius(radiusMeters: Double) {
consumers.forEach { it.onHorizontalAccuracyRadiusUpdated(radiusMeters) }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.mapbox.maps.mapbox_maps

import android.content.Context
import com.mapbox.geojson.Point
import com.mapbox.maps.MapView
import com.mapbox.maps.mapbox_maps.mapping.applyFromFLT
import com.mapbox.maps.mapbox_maps.mapping.toFLT
Expand All @@ -9,6 +10,9 @@ import com.mapbox.maps.plugin.LocationPuck2D
import com.mapbox.maps.plugin.LocationPuck3D
import com.mapbox.maps.plugin.locationcomponent.createDefault2DPuck
import com.mapbox.maps.plugin.locationcomponent.location
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

class LocationComponentController(
private val mapView: MapView,
Expand All @@ -29,4 +33,101 @@ class LocationComponentController(
(mapView.location.locationPuck as? LocationPuck2D)?.let { cachedPuck2D = it }
(mapView.location.locationPuck as? LocationPuck3D)?.let { cachedPuck3D = it }
}

// Native location-provider override, so the puck can be driven by an
// externally-supplied combined GPS+indoor location provider instead of
// Mapbox's default provider.
private val externalLocationProvider = ExternalLocationProvider()
private var isOverrideActive = false
private var externalLocationChannel: MethodChannel? = null

/**
* Sets up the plain [MethodChannel] for `setExternalLocation`/
* `clearExternalLocation`. Deliberately not Pigeon-generated — Mapbox
* doesn't ship the Pigeon input specs for this plugin publicly, only the
* generated output, so this is a small hand-written channel kept isolated
* from the generated code to stay easy to rebase. Mirrors
* `LocationController.setUpExternalLocationChannel` on iOS.
*/
fun setUpExternalLocationChannel(messenger: BinaryMessenger, channelSuffix: String) {
val channel = MethodChannel(
messenger,
"plugins.flutter.io.mapbox_maps_flutter.externalLocation.$channelSuffix"
)
channel.setMethodCallHandler { call, result -> handleExternalLocationCall(call, result) }
externalLocationChannel = channel
}

/** Detaches the platform-channel handler so it no longer retains this controller. */
fun disposeExternalLocationChannel() {
externalLocationChannel?.setMethodCallHandler(null)
externalLocationChannel = null
}

private fun handleExternalLocationCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"setExternalLocation" -> setExternalLocation(call, result)
"clearExternalLocation" -> clearExternalLocation(result)
else -> result.notImplemented()
}
}

private fun setExternalLocation(call: MethodCall, result: MethodChannel.Result) {
val latitude = call.argument<Double>("latitude")
val longitude = call.argument<Double>("longitude")
if (latitude == null || longitude == null) {
result.error("invalid_args", "setExternalLocation requires latitude and longitude", null)
return
}

activateOverrideIfNeeded()
externalLocationProvider.updateLocation(Point.fromLngLat(longitude, latitude))

call.argument<Double>("heading")?.let { externalLocationProvider.updateBearing(it) }
call.argument<Double>("accuracy")?.let { externalLocationProvider.updateAccuracyRadius(it) }
// `floor` is intentionally not forwarded — see ExternalLocationProvider's
// doc comment: this SDK's LocationConsumer has no floor concept, unlike
// iOS's Location.floor. Callers that need floor-aware behavior (e.g.
// indoor-map puck opacity) handle it entirely separately from this
// override.

result.success(null)
}

/**
* Restores Mapbox's default location provider — "clear" means "go back to
* normal GPS." Intended usage: call this on a location-stream error, where
* presenting a stale synthetic position would be worse than falling back
* to GPS.
*/
private fun clearExternalLocation(result: MethodChannel.Result) {
if (isOverrideActive) {
isOverrideActive = false
mapView.location.setLocationProvider(defaultLocationProvider)
}
result.success(null)
}

// Mapbox lazily creates its own DefaultLocationProvider the first time the
// location component is enabled with no provider set (see
// LocationComponentPluginImpl). Capture whatever is active *before* we ever
// swap in our own, so clearExternalLocation has something real to restore.
private val defaultLocationProvider by lazy {
mapView.location.getLocationProvider()
?: com.mapbox.maps.plugin.locationcomponent.DefaultLocationProvider(context)
}

/**
* Registers `externalLocationProvider` with Mapbox on first use only —
* until `setExternalLocation` is called at least once, the map behaves
* exactly as it does today (default provider, unmodified).
*/
private fun activateOverrideIfNeeded() {
if (isOverrideActive) return
// Force evaluation before swapping so it captures the real default, not
// our own override.
defaultLocationProvider
isOverrideActive = true
mapView.location.setLocationProvider(externalLocationProvider)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ class MapboxMapController(
_AnimationManager.setUp(messenger, animationController, this.channelSuffix)
annotationController.setup()
_LocationComponentSettingsInterface.setUp(messenger, locationComponentController, this.channelSuffix)
// Hand-written channel (not Pigeon-generated, see
// LocationComponentController.setUpExternalLocationChannel) for the
// native location-provider override.
locationComponentController.setUpExternalLocationChannel(messenger, this.channelSuffix)
LogoSettingsInterface.setUp(messenger, logoController, this.channelSuffix)
GesturesSettingsInterface.setUp(messenger, gestureController, this.channelSuffix)
AttributionSettingsInterface.setUp(messenger, attributionController, this.channelSuffix)
Expand Down Expand Up @@ -301,6 +305,7 @@ class MapboxMapController(
_AnimationManager.setUp(messenger, null, channelSuffix)
annotationController.dispose()
_LocationComponentSettingsInterface.setUp(messenger, null, channelSuffix)
locationComponentController.disposeExternalLocationChannel()
LogoSettingsInterface.setUp(messenger, null, channelSuffix)
GesturesSettingsInterface.setUp(messenger, null, channelSuffix)
CompassSettingsInterface.setUp(messenger, null, channelSuffix)
Expand Down
Loading