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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@
* Introduce new `LineLayer.lineElevationGroundScale` property to scale elevated lines with terrain exaggeration.
* Promote elevated lines properties to stable: `LineLayer.lineZOffset` and `LineLayer.lineElevationReference`.
* Add experimental `ModelLayer.modelAllowDensityReduction` property to disable density reduction in model layers.
* Add HTTP request/response interceptor API for dynamic header injection and request monitoring.
* `MapboxMapsOptions.setHttpRequestInterceptor()` - Intercept and modify HTTP requests before they are sent. Supports modifying URL, headers, and body.
* `MapboxMapsOptions.setHttpResponseInterceptor()` - Inspect HTTP responses after they are received.
* `MapboxMapsOptions.setCustomHeaders()` - Set static headers that are applied to all requests.
* `HttpInterceptorRequest` - Represents an intercepted request with `url`, `method`, `headers`, and `body` properties. Use `copyWith()` to create modified requests.
* `HttpInterceptorResponse` - Represents an intercepted response with `url`, `statusCode`, `headers`, `data`, and `requestHeaders` properties. The `requestHeaders` field contains the original request headers, useful for correlating requests with responses using custom headers like `X-Request-Id`.

**Important**: These are static methods on `MapboxMapsOptions`, not instance methods on `MapboxMap`. This ensures ALL HTTP requests are intercepted, including the initial style and tile requests made during map initialization. Set up interceptors before creating any `MapWidget`.

Example usage:
```dart
// In initState() or before creating MapWidget:

// Set static headers (applied to all requests)
MapboxMapsOptions.setCustomHeaders({'X-App-Version': '1.0.0'});

// Add dynamic custom headers to requests
MapboxMapsOptions.setHttpRequestInterceptor((request) async {
if (request.url.contains('api.mapbox.com')) {
return request.copyWith(
headers: {...request.headers, 'Authorization': 'Bearer token'},
);
}
return null; // Use original request
});

// Monitor responses
MapboxMapsOptions.setHttpResponseInterceptor((response) async {
print('Response: ${response.statusCode} for ${response.url}');
});
```

### 2.19.0-rc.1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,19 +347,6 @@ class MapboxMapController(
"mapView#submitViewSizeHint" -> {
result.success(null) // no-op on this platform
}
"map#setCustomHeaders" -> {
try {
val headers = call.argument<Map<String, String>>("headers")
headers?.let {
CustomHttpServiceInterceptor.getInstance().setCustomHeaders(headers)
result.success(null)
} ?: run {
result.error("INVALID_ARGUMENTS", "Headers cannot be null", null)
}
} catch (e: Exception) {
result.error("HEADER_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.mapbox.maps.mapbox_maps

import android.content.Context
import androidx.lifecycle.Lifecycle
import com.mapbox.maps.mapbox_maps.http.CustomHttpServiceInterceptor
import com.mapbox.maps.mapbox_maps.offline.OfflineMapInstanceManager
import com.mapbox.maps.mapbox_maps.offline.OfflineSwitch
import com.mapbox.maps.mapbox_maps.pigeons._MapboxMapsOptions
Expand All @@ -17,10 +18,12 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel

/** MapboxMapsPlugin */
class MapboxMapsPlugin : FlutterPlugin, ActivityAware {
private var lifecycle: Lifecycle? = null
private var httpInterceptorChannel: MethodChannel? = null

override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterPluginBinding
Expand Down Expand Up @@ -54,6 +57,48 @@ class MapboxMapsPlugin : FlutterPlugin, ActivityAware {
_TileStoreInstanceManager.setUp(binaryMessenger, offlineMapInstanceManager)
_OfflineSwitch.setUp(binaryMessenger, offlineSwitch)
LoggingController.setup(binaryMessenger)

// Setup static HTTP interceptor channel - available before any map is created
setupHttpInterceptorChannel(binaryMessenger)
}

private fun setupHttpInterceptorChannel(binaryMessenger: BinaryMessenger) {
httpInterceptorChannel = MethodChannel(binaryMessenger, "com.mapbox.maps.flutter/http_interceptor")
val interceptor = CustomHttpServiceInterceptor.getInstance()
interceptor.setFlutterChannel(httpInterceptorChannel)

httpInterceptorChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"setCustomHeaders" -> {
try {
val headers = call.argument<Map<String, String>>("headers")
headers?.let {
interceptor.setCustomHeaders(headers)
result.success(null)
} ?: run {
result.error("INVALID_ARGUMENTS", "Headers cannot be null", null)
}
} catch (e: Exception) {
result.error("HEADER_ERROR", e.message, null)
}
}
"setHttpInterceptorEnabled" -> {
try {
val enabled = call.argument<Boolean>("enabled") ?: false
val interceptRequests = call.argument<Boolean>("interceptRequests") ?: false
val interceptResponses = call.argument<Boolean>("interceptResponses") ?: false
val includeResponseBody = call.argument<Boolean>("includeResponseBody") ?: false
interceptor.setInterceptorEnabled(enabled, interceptRequests, interceptResponses, includeResponseBody)
result.success(null)
} catch (e: Exception) {
result.error("INTERCEPTOR_ERROR", e.message, null)
}
}
else -> {
result.notImplemented()
}
}
}
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,207 @@
package com.mapbox.maps.mapbox_maps.http

import android.os.Handler
import android.os.Looper
import com.mapbox.common.HttpRequest
import com.mapbox.common.HttpRequestOrResponse
import com.mapbox.common.HttpResponse
import com.mapbox.common.HttpServiceFactory
import com.mapbox.common.HttpServiceInterceptorInterface
import com.mapbox.common.HttpServiceInterceptorRequestContinuation
import com.mapbox.common.HttpServiceInterceptorResponseContinuation
import io.flutter.plugin.common.MethodChannel

class CustomHttpServiceInterceptor : HttpServiceInterceptorInterface {
// Thread synchronization lock for protecting mutable state
private val lock = Any()

private var customHeaders: MutableMap<String, String> = mutableMapOf()
private var flutterChannel: MethodChannel? = null
private var interceptRequests: Boolean = false
private var interceptResponses: Boolean = false
private var includeResponseBody: Boolean = false
private val mainHandler = Handler(Looper.getMainLooper())

override fun onRequest(request: HttpRequest, continuation: HttpServiceInterceptorRequestContinuation) {
val currentHeaders = HashMap(request.headers)

currentHeaders.putAll(customHeaders)
val modifiedRequest = request.toBuilder()
.headers(currentHeaders)
.build()
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
continuation.run(requestOrResponse)
// Thread-safe access to mutable state
val (headers, shouldIntercept, channel) = synchronized(lock) {
Triple(HashMap(customHeaders), interceptRequests, flutterChannel)
}

// First apply static custom headers
currentHeaders.putAll(headers)

// If Flutter callback is enabled, invoke it
if (shouldIntercept && channel != null) {
invokeFlutterRequestCallback(request, currentHeaders, channel, continuation)
} else {
// No callback, just continue with static headers
val modifiedRequest = request.toBuilder()
.headers(currentHeaders)
.build()
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
continuation.run(requestOrResponse)
}
}

private fun invokeFlutterRequestCallback(
request: HttpRequest,
currentHeaders: HashMap<String, String>,
channel: MethodChannel,
continuation: HttpServiceInterceptorRequestContinuation
) {
val requestMap = mapOf(
"url" to request.url,
"method" to request.method.name,
"headers" to currentHeaders,
"body" to request.body
)

// Use async continuation pattern - call continuation directly from the Flutter callback
// This avoids blocking the calling thread and prevents deadlocks when called from main thread
mainHandler.post {
channel.invokeMethod("http#onRequest", requestMap, object : MethodChannel.Result {
override fun success(result: Any?) {
val builder = request.toBuilder()

@Suppress("UNCHECKED_CAST")
val modified = result as? Map<String, Any?>

if (modified != null) {
// Apply modified headers
@Suppress("UNCHECKED_CAST")
val newHeaders = modified["headers"] as? Map<String, String>
if (newHeaders != null) {
builder.headers(HashMap(newHeaders))
} else {
builder.headers(currentHeaders)
}

// Apply modified URL
val newUrl = modified["url"] as? String
if (newUrl != null) {
builder.url(newUrl)
}

// Apply modified body
@Suppress("UNCHECKED_CAST")
val newBody = modified["body"] as? ByteArray
if (newBody != null) {
builder.body(newBody)
}
} else {
builder.headers(currentHeaders)
}

val modifiedRequest = builder.build()
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
// Call continuation from inside the callback - this is safe because
// the native backend request() call is async (queues the request and returns immediately)
continuation.run(requestOrResponse)
}

override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
// On error, continue with original request + custom headers
val modifiedRequest = request.toBuilder()
.headers(currentHeaders)
.build()
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
continuation.run(requestOrResponse)
}

override fun notImplemented() {
// Not implemented, continue with original request + custom headers
val modifiedRequest = request.toBuilder()
.headers(currentHeaders)
.build()
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
continuation.run(requestOrResponse)
}
})
}
}

override fun onResponse(response: HttpResponse, continuation: HttpServiceInterceptorResponseContinuation) {
// Thread-safe access to mutable state
val (shouldIntercept, channel, shouldIncludeBody) = synchronized(lock) {
Triple(interceptResponses, flutterChannel, includeResponseBody)
}

// If Flutter callback is enabled, invoke it (non-blocking)
if (shouldIntercept && channel != null) {
// response.result is Expected<HttpRequestError, HttpResponseData>
// We need to check if it's a success and extract the value
val responseData = response.result.value
val responseMap = if (responseData != null) {
mapOf(
"url" to response.request.url,
"statusCode" to responseData.code,
"headers" to responseData.headers,
// Only include response body if explicitly opted in (to avoid performance issues)
"data" to if (shouldIncludeBody) responseData.data else null,
"requestHeaders" to response.request.headers
)
} else {
// Error response - still send basic info
mapOf(
"url" to response.request.url,
"statusCode" to -1,
"headers" to emptyMap<String, String>(),
"data" to null,
"requestHeaders" to response.request.headers
)
}

mainHandler.post {
channel.invokeMethod("http#onResponse", responseMap, object : MethodChannel.Result {
override fun success(result: Any?) {}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {}
override fun notImplemented() {}
})
}
}

continuation.run(response)
}

fun setCustomHeaders(headers: Map<String, String>) {
customHeaders.clear()
customHeaders.putAll(headers)
synchronized(lock) {
customHeaders.clear()
customHeaders.putAll(headers)
}
HttpServiceFactory.setHttpServiceInterceptor(this)
}

fun setFlutterChannel(channel: MethodChannel?) {
synchronized(lock) {
flutterChannel = channel
}
}

fun setInterceptorEnabled(
enabled: Boolean,
interceptRequests: Boolean,
interceptResponses: Boolean,
includeResponseBody: Boolean = false
) {
synchronized(lock) {
this.interceptRequests = enabled && interceptRequests
this.interceptResponses = enabled && interceptResponses
this.includeResponseBody = includeResponseBody
}
HttpServiceFactory.setHttpServiceInterceptor(this)
}

companion object {
@Volatile
private var instance: CustomHttpServiceInterceptor? = null

fun getInstance(): CustomHttpServiceInterceptor {
if (instance == null) {
instance = CustomHttpServiceInterceptor()
return instance ?: synchronized(this) {
instance ?: CustomHttpServiceInterceptor().also { instance = it }
}
return instance!!
}
}
}
4 changes: 4 additions & 0 deletions example/integration_test/all_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import 'snapshotter/snapshotter_test.dart' as snapshotter_test;
import 'viewport_test.dart' as viewport_test;
import 'interactive_features_test.dart' as interactive_features_test;
import 'map_recorder_test.dart' as map_recorder_test;
import 'http_interceptor_test.dart' as http_interceptor_test;

void main() {
// annotation tests
Expand Down Expand Up @@ -110,6 +111,9 @@ void main() {
viewport_test.main();
map_recorder_test.main();

// http interceptor tests
http_interceptor_test.main();

// location test has to be at the bottom as on iOS it triggers location permission dialog
// to be shown which makes tests that rely on QRF/QSF fail
// TODO: address this properly by granting the location permission somehow
Expand Down
Loading