diff --git a/CHANGELOG.md b/CHANGELOG.md index 21eae5cc3..cf267e19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt index fe3f0776d..6ebe8ca30 100644 --- a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt @@ -347,19 +347,6 @@ class MapboxMapController( "mapView#submitViewSizeHint" -> { result.success(null) // no-op on this platform } - "map#setCustomHeaders" -> { - try { - val headers = call.argument>("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() } diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapsPlugin.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapsPlugin.kt index 6389b3d32..17372ed6f 100644 --- a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapsPlugin.kt +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapsPlugin.kt @@ -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 @@ -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 @@ -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>("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("enabled") ?: false + val interceptRequests = call.argument("interceptRequests") ?: false + val interceptResponses = call.argument("interceptResponses") ?: false + val includeResponseBody = call.argument("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) { diff --git a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/http/CustomHttpServiceInterceptor.kt b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/http/CustomHttpServiceInterceptor.kt index a16edbc6d..ad7429ce5 100644 --- a/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/http/CustomHttpServiceInterceptor.kt +++ b/android/src/main/kotlin/com/mapbox/maps/mapbox_maps/http/CustomHttpServiceInterceptor.kt @@ -1,5 +1,7 @@ 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 @@ -7,39 +9,199 @@ 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 = 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, + 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 + + if (modified != null) { + // Apply modified headers + @Suppress("UNCHECKED_CAST") + val newHeaders = modified["headers"] as? Map + 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 + // 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(), + "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) { - 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!! } } } \ No newline at end of file diff --git a/example/integration_test/all_test.dart b/example/integration_test/all_test.dart index a0758de34..5b25fbf38 100644 --- a/example/integration_test/all_test.dart +++ b/example/integration_test/all_test.dart @@ -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 @@ -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 diff --git a/example/integration_test/http_interceptor_test.dart b/example/integration_test/http_interceptor_test.dart new file mode 100644 index 000000000..57dc06f35 --- /dev/null +++ b/example/integration_test/http_interceptor_test.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; +import 'empty_map_widget.dart' as app; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() async { + // Clean up interceptors after each test + MapboxMapsOptions.setHttpRequestInterceptor(null); + MapboxMapsOptions.setHttpResponseInterceptor(null); + MapboxMapsOptions.setCustomHeaders({}); + }); + + group('HTTP Interceptor - Deadlock Prevention', () { + testWidgets( + 'map loads successfully with request interceptor set before map creation', + (WidgetTester tester) async { + // This test verifies the fix for the deadlock issue. + // Previously, setting an interceptor before map creation could cause + // a deadlock because HTTP requests made during map initialization + // would block the main thread waiting for the Flutter callback. + // + // With the async continuation pattern fix, this should complete + // successfully without hanging. + + final requestsIntercepted = []; + + // Set up interceptor BEFORE creating the map + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + requestsIntercepted.add(request.url); + // Add a custom header to prove interception worked + return request.copyWith( + headers: {...request.headers, 'X-Test-Intercepted': 'true'}, + ); + }); + + // Create the map - this will make HTTP requests during initialization + // If deadlock fix is working, this will complete; if not, it will hang + final mapFuture = app.main(); + await tester.pumpAndSettle(); + + // Wait for map to load with a timeout - if deadlock occurs, this fails + final mapboxMap = await mapFuture.timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + 'Map failed to load - possible deadlock in HTTP interceptor'), + ); + + // Verify map loaded successfully + expect(mapboxMap, isNotNull); + + // Verify that requests were actually intercepted + // (style and tile requests should have been captured) + expect(requestsIntercepted, isNotEmpty, + reason: 'HTTP requests should have been intercepted during map load'); + }); + + testWidgets( + 'map loads successfully with response interceptor set before map creation', + (WidgetTester tester) async { + final responsesIntercepted = []; + + // Set up response interceptor BEFORE creating the map + await MapboxMapsOptions.setHttpResponseInterceptor((response) async { + responsesIntercepted.add(response.url); + }); + + // Create the map + final mapFuture = app.main(); + await tester.pumpAndSettle(); + + // Wait for map to load + final mapboxMap = await mapFuture.timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + 'Map failed to load - possible deadlock in HTTP interceptor'), + ); + + expect(mapboxMap, isNotNull); + + // Wait a bit for responses to come in + await tester.pump(const Duration(seconds: 2)); + + expect(responsesIntercepted, isNotEmpty, + reason: + 'HTTP responses should have been intercepted during map load'); + }); + + testWidgets( + 'map loads successfully with both interceptors set before map creation', + (WidgetTester tester) async { + final requestsIntercepted = []; + final responsesIntercepted = []; + + // Set up both interceptors BEFORE creating the map + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + requestsIntercepted.add(request.url); + return request.copyWith( + headers: { + ...request.headers, + 'X-Request-Id': 'test-${request.url.hashCode}' + }, + ); + }); + + await MapboxMapsOptions.setHttpResponseInterceptor((response) async { + responsesIntercepted.add(response.url); + }); + + // Create the map + final mapFuture = app.main(); + await tester.pumpAndSettle(); + + final mapboxMap = await mapFuture.timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException( + 'Map failed to load - possible deadlock in HTTP interceptor'), + ); + + expect(mapboxMap, isNotNull); + await tester.pump(const Duration(seconds: 2)); + + expect(requestsIntercepted, isNotEmpty); + expect(responsesIntercepted, isNotEmpty); + }); + }); + + group('HTTP Interceptor - Custom Headers', () { + testWidgets('custom headers are applied to requests', + (WidgetTester tester) async { + Map? capturedHeaders; + + await MapboxMapsOptions.setCustomHeaders({ + 'X-Static-Header': 'static-value', + 'X-App-Version': '1.0.0', + }); + + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + // Capture headers from a mapbox request + if (request.url.contains('mapbox')) { + capturedHeaders = Map.from(request.headers); + } + return null; // Don't modify the request + }); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + + // Custom headers should have been applied + expect(capturedHeaders, isNotNull); + expect(capturedHeaders!['X-Static-Header'], equals('static-value')); + expect(capturedHeaders!['X-App-Version'], equals('1.0.0')); + }); + }); + + group('HTTP Interceptor - Response Body Opt-in', () { + testWidgets('response body is null by default (not included)', + (WidgetTester tester) async { + final responseBodies = ?>[]; + + // Set up response interceptor WITHOUT includeResponseBody + // Body should be null by default to avoid performance issues + await MapboxMapsOptions.setHttpResponseInterceptor((response) async { + responseBodies.add(response.data); + }); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + await tester.pump(const Duration(seconds: 2)); + + // All response bodies should be null (not included) + expect(responseBodies, isNotEmpty, + reason: 'Should have captured some responses'); + expect(responseBodies.every((body) => body == null), isTrue, + reason: + 'Response bodies should be null by default to avoid performance issues'); + }); + + testWidgets('response body is included when opted in', + (WidgetTester tester) async { + final responseBodies = ?>[]; + + // Set up response interceptor WITH includeResponseBody: true + await MapboxMapsOptions.setHttpResponseInterceptor( + (response) async { + responseBodies.add(response.data); + }, + includeResponseBody: true, + ); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + await tester.pump(const Duration(seconds: 2)); + + // At least some response bodies should be non-null + expect(responseBodies, isNotEmpty, + reason: 'Should have captured some responses'); + expect( + responseBodies.any((body) => body != null && body.isNotEmpty), isTrue, + reason: + 'At least some response bodies should be included when opted in'); + }); + }); + + group('HTTP Interceptor - Request Modification', () { + testWidgets('modified request is used', (WidgetTester tester) async { + String? capturedTestHeader; + + // First interceptor adds a header + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + return request.copyWith( + headers: {...request.headers, 'X-Modified': 'yes'}, + ); + }); + + // Response interceptor checks if the modified header roundtripped + await MapboxMapsOptions.setHttpResponseInterceptor((response) async { + if (response.requestHeaders.containsKey('X-Modified')) { + capturedTestHeader = response.requestHeaders['X-Modified']; + } + }); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + await tester.pump(const Duration(seconds: 2)); + + // The modified header should have been sent with requests + // and roundtripped back in the response + expect(capturedTestHeader, equals('yes'), + reason: 'Modified request headers should be used'); + }); + + testWidgets('returning null from interceptor uses original request', + (WidgetTester tester) async { + int interceptCallCount = 0; + + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + interceptCallCount++; + return null; // Return null to use original request unchanged + }); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + + // Interceptor should have been called, but returning null + // means the original request was used + expect(interceptCallCount, greaterThan(0), + reason: 'Interceptor should have been called'); + }); + }); + + group('HTTP Interceptor - Request/Response Correlation', () { + testWidgets('all requests receive corresponding responses', + (WidgetTester tester) async { + // This test verifies the async continuation pattern correctly handles + // concurrent requests - each request should get a matching response. + // This tests the thread safety and correlation logic, not channel performance. + + final requestUrls = {}; + final responseUrls = {}; + final requestTimestamps = {}; + final responseTimestamps = {}; + + // Track all requests + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + requestUrls.add(request.url); + requestTimestamps[request.url] = DateTime.now(); + return null; // Don't modify + }); + + // Track all responses + await MapboxMapsOptions.setHttpResponseInterceptor((response) async { + responseUrls.add(response.url); + responseTimestamps[response.url] = DateTime.now(); + }); + + // Load the map - this generates many concurrent HTTP requests + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + await tester.pump(const Duration(seconds: 3)); + + // Log correlation results + // ignore: avoid_print + print('Request/Response correlation results:'); + // ignore: avoid_print + print(' Total requests intercepted: ${requestUrls.length}'); + // ignore: avoid_print + print(' Total responses intercepted: ${responseUrls.length}'); + + // Key assertion: we should have intercepted requests + expect(requestUrls, isNotEmpty, + reason: 'Should have intercepted HTTP requests'); + + // Key assertion: we should have intercepted responses + expect(responseUrls, isNotEmpty, + reason: 'Should have intercepted HTTP responses'); + + // Check how many requests have matching responses + final matchedUrls = requestUrls.intersection(responseUrls); + final unmatchedRequests = requestUrls.difference(responseUrls); + + // ignore: avoid_print + print(' Matched request/response pairs: ${matchedUrls.length}'); + // ignore: avoid_print + print(' Requests without responses: ${unmatchedRequests.length}'); + + // Most requests should have corresponding responses + // (some may be in-flight or failed, so we don't require 100%) + final matchRate = matchedUrls.length / requestUrls.length; + // ignore: avoid_print + print(' Match rate: ${(matchRate * 100).toStringAsFixed(1)}%'); + + expect(matchRate, greaterThan(0.5), + reason: + 'At least 50% of requests should have matching responses (got ${(matchRate * 100).toStringAsFixed(1)}%)'); + + // Verify response timestamps are after request timestamps (causality check) + for (final url in matchedUrls) { + final requestTime = requestTimestamps[url]; + final responseTime = responseTimestamps[url]; + if (requestTime != null && responseTime != null) { + expect( + responseTime.isAfter(requestTime) || responseTime == requestTime, + isTrue, + reason: 'Response should come after request for $url'); + } + } + }); + }); + + group('HTTP Interceptor - Cleanup', () { + testWidgets('interceptors can be disabled', (WidgetTester tester) async { + int requestCountWithInterceptor = 0; + int requestCountAfterDisabling = 0; + + // Enable interceptor + await MapboxMapsOptions.setHttpRequestInterceptor((request) async { + requestCountWithInterceptor++; + return null; + }); + + final mapFuture = app.main(); + await tester.pumpAndSettle(); + await mapFuture; + + final initialCount = requestCountWithInterceptor; + expect(initialCount, greaterThan(0)); + + // Disable interceptor + await MapboxMapsOptions.setHttpRequestInterceptor(null); + + // Force some additional requests by panning the map + // Note: This is a basic check - interceptor should not be called + requestCountAfterDisabling = requestCountWithInterceptor; + + // After disabling, the counter should not increase + // (or at least should be stable) + expect(requestCountAfterDisabling, equals(requestCountWithInterceptor)); + }); + }); +} diff --git a/example/lib/custom_header_example.dart b/example/lib/custom_header_example.dart index 4eb823e18..e7831ca0b 100644 --- a/example/lib/custom_header_example.dart +++ b/example/lib/custom_header_example.dart @@ -3,6 +3,77 @@ import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'example.dart'; +/// Represents a logged HTTP request or response for display +class HttpLogEntry { + final DateTime timestamp; + final bool isRequest; + final String url; + final String method; + final int? statusCode; + final Map headers; + final int? bodySize; + final String? requestId; + + HttpLogEntry({ + required this.timestamp, + required this.isRequest, + required this.url, + required this.method, + this.statusCode, + required this.headers, + this.bodySize, + this.requestId, + }); + + String get displayText { + if (isRequest) { + return '$method ${_truncateUrl(url)}'; + } else { + return '$statusCode ${_truncateUrl(url)}'; + } + } + + String _truncateUrl(String url) { + if (url.length > 60) { + return '${url.substring(0, 57)}...'; + } + return url; + } +} + +/// Groups a request with its corresponding response +class HttpLogGroup { + final String? requestId; + final HttpLogEntry? request; + final HttpLogEntry? response; + + HttpLogGroup({this.requestId, this.request, this.response}); + + /// Returns the timestamp to use for sorting (request time, or response time if no request) + DateTime get sortTimestamp => + request?.timestamp ?? response?.timestamp ?? DateTime.now(); + + /// Calculates duration between request and response in milliseconds + int? get durationMs { + if (request != null && response != null) { + return response!.timestamp.difference(request!.timestamp).inMilliseconds; + } + return null; + } + + /// Returns the URL from either request or response + String get url => request?.url ?? response?.url ?? ''; + + /// Returns the method from request + String get method => request?.method ?? ''; + + /// Returns true if we have both request and response + bool get isComplete => request != null && response != null; + + /// Returns true if this is a pending request (no response yet) + bool get isPending => request != null && response == null; +} + class CustomHeaderExample extends StatefulWidget implements Example { @override final Widget leading = const Icon(Icons.network_check); @@ -21,21 +92,328 @@ class CustomHeaderExampleState extends State { MapboxMap? mapboxMap; var mapProject = StyleProjectionName.globe; var locale = 'en'; + bool useInterceptor = + true; // Start with interceptor enabled to capture ALL requests + List requestLog = []; + static const int maxLogEntries = 50; + + // Dart-level request ID generation for correlating requests with responses. + // This demonstrates how users can implement their own correlation strategy + // by adding a custom header that gets roundtripped in the response. + int _requestIdCounter = 0; + + /// Generates a unique request ID for correlating requests with responses. + /// In production, you might want to use a UUID package instead. + String _generateRequestId() { + _requestIdCounter++; + return 'req-$_requestIdCounter-${DateTime.now().millisecondsSinceEpoch}'; + } + + /// Groups log entries by requestId for display + List get groupedLog { + final Map groups = {}; + final List ungrouped = []; + + for (final entry in requestLog) { + if (entry.requestId != null) { + final id = entry.requestId!; + final existing = groups[id]; + if (existing == null) { + groups[id] = HttpLogGroup( + requestId: id, + request: entry.isRequest ? entry : null, + response: entry.isRequest ? null : entry, + ); + } else { + groups[id] = HttpLogGroup( + requestId: id, + request: entry.isRequest ? entry : existing.request, + response: entry.isRequest ? existing.response : entry, + ); + } + } else { + ungrouped.add(entry); + } + } + + // Convert to list and sort by most recent first + final result = groups.values.toList(); + // Add ungrouped entries as single-item groups + for (final entry in ungrouped) { + result.add(HttpLogGroup( + requestId: null, + request: entry.isRequest ? entry : null, + response: entry.isRequest ? null : entry, + )); + } + + result.sort((a, b) => b.sortTimestamp.compareTo(a.sortTimestamp)); + return result; + } _onMapCreated(MapboxMap mapboxMap) { this.mapboxMap = mapboxMap; - mapboxMap.setCustomHeaders({'Authorization': 'Bearer your_access_token'}); + } + + void _setupHttpInterceptor() { + // Using static methods on MapboxMapsOptions - these work BEFORE any map is created, + // ensuring all HTTP requests (including initial style/tile requests) are intercepted. + if (useInterceptor) { + // Set up request interceptor for dynamic header injection + MapboxMapsOptions.setHttpRequestInterceptor((request) async { + // Generate a unique ID for this request at the Dart level. + // This ID is added as a header and will be roundtripped in the response, + // allowing us to correlate requests with their responses without + // maintaining any state (like a URL -> ID map). + final requestId = _generateRequestId(); + + // Always add the request ID header, and conditionally add other custom headers + final modifiedRequest = request.copyWith( + headers: { + ...request.headers, + 'X-Request-Id': requestId, + if (request.url.contains('api.mapbox.com')) + 'X-Custom-Header': 'custom-value', + }, + ); + + // Log the request AFTER modification so we see the final headers + final entry = HttpLogEntry( + timestamp: DateTime.now(), + isRequest: true, + url: modifiedRequest.url, + method: modifiedRequest.method, + headers: Map.from(modifiedRequest.headers), + bodySize: modifiedRequest.body?.length, + requestId: requestId, + ); + + setState(() { + requestLog.insert(0, entry); + if (requestLog.length > maxLogEntries) { + requestLog.removeLast(); + } + }); + + return modifiedRequest; + }); + + // Optionally set up response interceptor for logging/monitoring + MapboxMapsOptions.setHttpResponseInterceptor((response) async { + // The request ID is available via the roundtripped request headers. + // This eliminates the need for maintaining a URL -> ID map. + final requestId = response.requestHeaders['X-Request-Id']; + + final entry = HttpLogEntry( + timestamp: DateTime.now(), + isRequest: false, + url: response.url, + method: '', // Response doesn't have method + statusCode: response.statusCode, + headers: Map.from(response.headers), + bodySize: response.data?.length, + requestId: requestId, + ); + + setState(() { + requestLog.insert(0, entry); + if (requestLog.length > maxLogEntries) { + requestLog.removeLast(); + } + }); + }); + } else { + // Disable interceptors + MapboxMapsOptions.setHttpRequestInterceptor(null); + MapboxMapsOptions.setHttpResponseInterceptor(null); + } + } + + void _showRequestDetails(BuildContext context, HttpLogEntry entry) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) => Container( + padding: const EdgeInsets.all(16), + child: ListView( + controller: scrollController, + children: [ + // Header + Row( + children: [ + Icon( + entry.isRequest ? Icons.arrow_upward : Icons.arrow_downward, + color: entry.isRequest + ? Colors.blue + : _getStatusColor(entry.statusCode), + ), + const SizedBox(width: 8), + Text( + entry.isRequest ? 'REQUEST' : 'RESPONSE', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const Divider(), + + // Timestamp + _buildDetailRow('Timestamp', _formatTimestamp(entry.timestamp)), + + // Method (for requests) + if (entry.isRequest) _buildDetailRow('Method', entry.method), + + // Status Code (for responses) + if (!entry.isRequest) + _buildDetailRow( + 'Status Code', + '${entry.statusCode}', + valueColor: _getStatusColor(entry.statusCode), + ), + + // URL + _buildDetailRow('URL', entry.url, isSelectable: true), + + // Body Size + if (entry.bodySize != null) + _buildDetailRow('Body Size', '${entry.bodySize} bytes'), + + const SizedBox(height: 16), + + // Headers Section + const Text( + 'Headers', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + if (entry.headers.isEmpty) + const Text( + 'No headers', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ) + else + ...entry.headers.entries.map( + (e) => _buildHeaderRow(e.key, e.value), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDetailRow(String label, String value, + {Color? valueColor, bool isSelectable = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + Expanded( + child: isSelectable + ? SelectableText( + value, + style: TextStyle(color: valueColor), + ) + : Text( + value, + style: TextStyle(color: valueColor), + ), + ), + ], + ), + ); + } + + Widget _buildHeaderRow(String key, String value) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.blueGrey, + ), + ), + SelectableText( + value, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ); + } + + Color _getStatusColor(int? statusCode) { + if (statusCode == null) return Colors.grey; + if (statusCode >= 200 && statusCode < 300) return Colors.green; + if (statusCode >= 300 && statusCode < 400) return Colors.orange; + if (statusCode >= 400) return Colors.red; + return Colors.grey; + } + + String _formatTimestamp(DateTime timestamp) { + return '${timestamp.hour.toString().padLeft(2, '0')}:' + '${timestamp.minute.toString().padLeft(2, '0')}:' + '${timestamp.second.toString().padLeft(2, '0')}.' + '${timestamp.millisecond.toString().padLeft(3, '0')}'; } @override void initState() { super.initState(); + // Set up interceptors BEFORE the map is created. + // This ensures ALL requests are intercepted, including + // the initial style and tile requests during map initialization. + MapboxMapsOptions.setCustomHeaders({'X-Static-Header': 'static-value'}); + _setupHttpInterceptor(); } @override void dispose() { + // Clean up using static methods + MapboxMapsOptions.setCustomHeaders({}); + MapboxMapsOptions.setHttpRequestInterceptor(null); + MapboxMapsOptions.setHttpResponseInterceptor(null); super.dispose(); - mapboxMap?.setCustomHeaders({}); } @override @@ -43,15 +421,7 @@ class CustomHeaderExampleState extends State { final MapWidget mapWidget = MapWidget( key: ValueKey("mapWidget"), onMapCreated: _onMapCreated, - onResourceRequestListener: (request) { - return null; - }, - ); - - final List listViewChildren = []; - - listViewChildren.addAll( - [], + onResourceRequestListener: (request) {}, ); return Column( @@ -63,7 +433,280 @@ class CustomHeaderExampleState extends State { height: MediaQuery.of(context).size.height - 400, child: mapWidget), ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Text('Enable HTTP Interceptor:'), + Switch( + value: useInterceptor, + onChanged: (value) { + setState(() { + useInterceptor = value; + _setupHttpInterceptor(); + }); + }, + ), + const Spacer(), + if (requestLog.isNotEmpty) + TextButton.icon( + onPressed: () { + setState(() { + requestLog.clear(); + }); + }, + icon: const Icon(Icons.clear_all, size: 16), + label: const Text('Clear'), + ), + ], + ), + ), + if (!useInterceptor) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Enable the HTTP interceptor to see requests and responses.\n' + 'Tap on any entry to view full details including headers.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ), + if (useInterceptor && requestLog.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Waiting for HTTP requests...\n' + 'Pan or zoom the map to trigger tile requests.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ), + if (requestLog.isNotEmpty) + Expanded( + child: ListView.builder( + itemCount: groupedLog.length, + itemBuilder: (context, index) { + final group = groupedLog[index]; + return _buildGroupedLogTile(context, group); + }, + ), + ), ], ); } + + Widget _buildGroupedLogTile(BuildContext context, HttpLogGroup group) { + final durationMs = group.durationMs; + final statusCode = group.response?.statusCode; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: group.isPending + ? Colors.orange.shade200 + : (statusCode != null && statusCode >= 200 && statusCode < 300) + ? Colors.green.shade200 + : Colors.grey.shade200, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Request row + if (group.request != null) + InkWell( + onTap: () => _showRequestDetails(context, group.request!), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(8)), + child: _buildEntryRow(group.request!, isFirst: true), + ), + // Divider with duration + if (group.request != null && group.response != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade50, + ), + child: Row( + children: [ + Icon(Icons.swap_vert, size: 14, color: Colors.grey.shade400), + const SizedBox(width: 8), + Text( + durationMs != null ? '${durationMs}ms' : '...', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + if (group.requestId != null) + Text( + 'ID: ${group.requestId}', + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade400, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + // Pending indicator (when we only have request, no response yet) + if (group.isPending) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade50, + ), + child: Row( + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.orange.shade400), + ), + ), + const SizedBox(width: 8), + Text( + 'Waiting for response...', + style: TextStyle( + fontSize: 11, + color: Colors.orange.shade700, + ), + ), + ], + ), + ), + // Response row + if (group.response != null) + InkWell( + onTap: () => _showRequestDetails(context, group.response!), + borderRadius: + const BorderRadius.vertical(bottom: Radius.circular(8)), + child: _buildEntryRow(group.response!, + isFirst: group.request == null), + ), + ], + ), + ); + } + + Widget _buildEntryRow(HttpLogEntry entry, {required bool isFirst}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + // Request/Response indicator + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: entry.isRequest + ? Colors.blue.shade50 + : _getStatusColor(entry.statusCode).withAlpha(26), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + entry.isRequest ? Icons.arrow_upward : Icons.arrow_downward, + size: 14, + color: entry.isRequest + ? Colors.blue + : _getStatusColor(entry.statusCode), + ), + ), + const SizedBox(width: 10), + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (entry.isRequest) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + entry.method, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.blue.shade800, + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: + _getStatusColor(entry.statusCode).withAlpha(51), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '${entry.statusCode}', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: _getStatusColor(entry.statusCode), + ), + ), + ), + const SizedBox(width: 6), + Text( + _formatTimestamp(entry.timestamp), + style: TextStyle( + fontSize: 9, + color: Colors.grey.shade600, + ), + ), + const SizedBox(width: 6), + Text( + '${entry.headers.length} headers', + style: TextStyle( + fontSize: 9, + color: Colors.grey.shade500, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + entry.url, + style: const TextStyle(fontSize: 10), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + Icons.chevron_right, + size: 18, + color: Colors.grey.shade400, + ), + ], + ), + ); + } } diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/CustomHttpServiceInterceptor.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/CustomHttpServiceInterceptor.swift index 6e916a49b..37db73635 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/CustomHttpServiceInterceptor.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/CustomHttpServiceInterceptor.swift @@ -1,30 +1,189 @@ +import Flutter import MapboxMaps final class CustomHttpServiceInterceptor: HttpServiceInterceptorInterface { + static let shared = CustomHttpServiceInterceptor() - var customHeaders: [String: String] = [:] - - func onRequest(for request: HttpRequest, continuation: @escaping HttpServiceInterceptorRequestContinuation) { - var modifiedHeaders = request.headers - - for (key, value) in customHeaders { - modifiedHeaders[key] = value - } - - let modifiedRequest = HttpRequest( - method: request.method, - url: request.url, - headers: modifiedHeaders, - timeout: request.timeout, - networkRestriction: request.networkRestriction, - sdkInformation: request.sdkInformation, - body: request.body, - flags: request.flags - ) - continuation(HttpRequestOrResponse.fromHttpRequest(modifiedRequest)) - } - - func onResponse(for response: HttpResponse, continuation: @escaping HttpServiceInterceptorResponseContinuation) { - continuation(response) - } + // Thread synchronization lock for protecting mutable state + private let lock = NSLock() + + private var _customHeaders: [String: String] = [:] + private var _flutterChannel: FlutterMethodChannel? + private var _interceptRequests: Bool = false + private var _interceptResponses: Bool = false + private var _includeResponseBody: Bool = false + + var customHeaders: [String: String] { + get { lock.withLock { _customHeaders } } + set { lock.withLock { _customHeaders = newValue } } + } + + var flutterChannel: FlutterMethodChannel? { + get { lock.withLock { _flutterChannel } } + set { lock.withLock { _flutterChannel = newValue } } + } + + var interceptRequests: Bool { + get { lock.withLock { _interceptRequests } } + set { lock.withLock { _interceptRequests = newValue } } + } + + var interceptResponses: Bool { + get { lock.withLock { _interceptResponses } } + set { lock.withLock { _interceptResponses = newValue } } + } + + var includeResponseBody: Bool { + get { lock.withLock { _includeResponseBody } } + set { lock.withLock { _includeResponseBody = newValue } } + } + + private init() {} + + func onRequest( + for request: HttpRequest, continuation: @escaping HttpServiceInterceptorRequestContinuation + ) { + var modifiedHeaders = request.headers + + // First apply static custom headers + for (key, value) in customHeaders { + modifiedHeaders[key] = value + } + + // If Flutter callback is enabled, invoke it + if interceptRequests, let channel = flutterChannel { + invokeFlutterRequestCallback( + request: request, + currentHeaders: modifiedHeaders, + channel: channel, + continuation: continuation + ) + } else { + // No callback, just continue with static headers + let modifiedRequest = HttpRequest( + method: request.method, + url: request.url, + headers: modifiedHeaders, + timeout: request.timeout, + networkRestriction: request.networkRestriction, + sdkInformation: request.sdkInformation, + body: request.body, + flags: request.flags + ) + continuation(HttpRequestOrResponse.fromHttpRequest(modifiedRequest)) + } + } + + private func invokeFlutterRequestCallback( + request: HttpRequest, + currentHeaders: [String: String], + channel: FlutterMethodChannel, + continuation: @escaping HttpServiceInterceptorRequestContinuation + ) { + let requestMap: [String: Any?] = [ + "url": request.url, + "method": request.method.rawValue, + "headers": currentHeaders, + "body": 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 + DispatchQueue.main.async { + channel.invokeMethod("http#onRequest", arguments: requestMap) { result in + var finalHeaders = currentHeaders + var finalUrl = request.url + var finalBody = request.body + + if let modified = result as? [String: Any] { + // Apply modified headers + if let newHeaders = modified["headers"] as? [String: String] { + finalHeaders = newHeaders + } + + // Apply modified URL + if let newUrl = modified["url"] as? String { + finalUrl = newUrl + } + + // Apply modified body + if let newBody = modified["body"] as? FlutterStandardTypedData { + finalBody = newBody.data + } else if let newBody = modified["body"] as? Data { + finalBody = newBody + } + } + + let modifiedRequest = HttpRequest( + method: request.method, + url: finalUrl, + headers: finalHeaders, + timeout: request.timeout, + networkRestriction: request.networkRestriction, + sdkInformation: request.sdkInformation, + body: finalBody, + flags: request.flags + ) + // Call continuation from inside the callback - this is safe because + // the native backend request() call is async (queues the request and returns immediately) + continuation(HttpRequestOrResponse.fromHttpRequest(modifiedRequest)) + } + } + } + + func onResponse( + for response: HttpResponse, + continuation: @escaping HttpServiceInterceptorResponseContinuation + ) { + // If Flutter callback is enabled, invoke it (non-blocking) + if interceptResponses, let channel = flutterChannel { + // Capture includeResponseBody value under lock to ensure thread safety + let shouldIncludeBody = includeResponseBody + + // response.result is Result + // We need to extract the success value + var responseMap: [String: Any?] + switch response.result { + case .success(let responseData): + responseMap = [ + "url": response.request.url, + "statusCode": Int(responseData.code), + "headers": responseData.headers, + // Only include response body if explicitly opted in (to avoid performance issues) + "data": shouldIncludeBody ? responseData.data : nil, + "requestHeaders": response.request.headers, + ] + case .failure(_): + responseMap = [ + "url": response.request.url, + "statusCode": -1, + "headers": [:] as [String: String], + "data": nil, + "requestHeaders": response.request.headers, + ] + } + + DispatchQueue.main.async { + channel.invokeMethod("http#onResponse", arguments: responseMap, result: nil) + } + } + + continuation(response) + } + + func setFlutterChannel(_ channel: FlutterMethodChannel?) { + flutterChannel = channel + } + + func setInterceptorEnabled( + enabled: Bool, + interceptRequests: Bool, + interceptResponses: Bool, + includeResponseBody: Bool = false + ) { + self.interceptRequests = enabled && interceptRequests + self.interceptResponses = enabled && interceptResponses + self.includeResponseBody = includeResponseBody + HttpServiceFactory.setHttpServiceInterceptorForInterceptor(self) + } } diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift index 4caa745b9..8e74400e0 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapController.swift @@ -29,9 +29,12 @@ final class MapboxMapController: NSObject, FlutterPlatformView { pluginVersion: String, eventTypes: [Int] ) { - binaryMessenger = SuffixBinaryMessenger(messenger: registrar.messenger(), suffix: String(channelSuffix)) + binaryMessenger = SuffixBinaryMessenger( + messenger: registrar.messenger(), suffix: String(channelSuffix)) _ = SettingsServiceFactory.getInstanceFor(.nonPersistent) - .set(key: "com.mapbox.common.telemetry.internal.custom_user_agent_fragment", value: "FlutterPlugin/\(pluginVersion)") + .set( + key: "com.mapbox.common.telemetry.internal.custom_user_agent_fragment", + value: "FlutterPlugin/\(pluginVersion)") mapView = MapView(frame: frame, mapInitOptions: mapInitOptions) mapboxMap = mapView.mapboxMap @@ -48,44 +51,70 @@ final class MapboxMapController: NSObject, FlutterPlatformView { ) let styleController = StyleController(styleManager: mapboxMap) - StyleManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: styleController, messageChannelSuffix: binaryMessenger.suffix) + StyleManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: styleController, + messageChannelSuffix: binaryMessenger.suffix) let cameraController = CameraController(withMapboxMap: mapboxMap) - _CameraManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: cameraController, messageChannelSuffix: binaryMessenger.suffix) + _CameraManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: cameraController, + messageChannelSuffix: binaryMessenger.suffix) - let mapInterfaceController = MapInterfaceController(withMapboxMap: mapboxMap, mapView: mapView) - _MapInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: mapInterfaceController, messageChannelSuffix: binaryMessenger.suffix) + let mapInterfaceController = MapInterfaceController( + withMapboxMap: mapboxMap, mapView: mapView) + _MapInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: mapInterfaceController, + messageChannelSuffix: binaryMessenger.suffix) let mapProjectionController = MapProjectionController() - ProjectionSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: mapProjectionController, messageChannelSuffix: binaryMessenger.suffix) + ProjectionSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: mapProjectionController, + messageChannelSuffix: binaryMessenger.suffix) let animationController = AnimationController(withMapView: mapView) - _AnimationManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: animationController, messageChannelSuffix: binaryMessenger.suffix) + _AnimationManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: animationController, + messageChannelSuffix: binaryMessenger.suffix) let locationController = LocationController(withMapView: mapView) - _LocationComponentSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: locationController, messageChannelSuffix: binaryMessenger.suffix) + _LocationComponentSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: locationController, + messageChannelSuffix: binaryMessenger.suffix) gesturesController = GesturesController(withMapView: mapView) - GesturesSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: gesturesController, messageChannelSuffix: binaryMessenger.suffix) + GesturesSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: gesturesController, + messageChannelSuffix: binaryMessenger.suffix) interactionsController = InteractionsController(withMapView: mapView) let logoController = LogoController(withMapView: mapView) - LogoSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: logoController, messageChannelSuffix: binaryMessenger.suffix) + LogoSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: logoController, + messageChannelSuffix: binaryMessenger.suffix) let attributionController = AttributionController(withMapView: mapView) - AttributionSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: attributionController, messageChannelSuffix: binaryMessenger.suffix) + AttributionSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: attributionController, + messageChannelSuffix: binaryMessenger.suffix) let compassController = CompassController(withMapView: mapView) - CompassSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: compassController, messageChannelSuffix: binaryMessenger.suffix) + CompassSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: compassController, + messageChannelSuffix: binaryMessenger.suffix) let scaleBarController = ScaleBarController(withMapView: mapView) - ScaleBarSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: scaleBarController, messageChannelSuffix: binaryMessenger.suffix) + ScaleBarSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: scaleBarController, + messageChannelSuffix: binaryMessenger.suffix) let indoorSelectorController = IndoorSelectorController(withMapView: mapView) - IndoorSelectorSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: indoorSelectorController, messageChannelSuffix: binaryMessenger.suffix) + IndoorSelectorSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: indoorSelectorController, + messageChannelSuffix: binaryMessenger.suffix) - annotationController = AnnotationController(withMapView: mapView, messenger: binaryMessenger) + annotationController = AnnotationController( + withMapView: mapView, messenger: binaryMessenger) annotationController!.setup() let viewportController = ViewportController( @@ -93,7 +122,9 @@ final class MapboxMapController: NSObject, FlutterPlatformView { cameraManager: mapView.camera, mapboxMap: mapboxMap ) - _ViewportMessengerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: viewportController, messageChannelSuffix: binaryMessenger.suffix) + _ViewportMessengerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: viewportController, + messageChannelSuffix: binaryMessenger.suffix) let performanceStatisticsController = PerformanceStatisticsController( mapboxMap: mapView.mapboxMap, @@ -114,7 +145,8 @@ final class MapboxMapController: NSObject, FlutterPlatformView { super.init() - channel.setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) } + channel.setMethodCallHandler { [weak self] in self?.onMethodCall(methodCall: $0, result: $1) + } } func onMethodCall(methodCall: FlutterMethodCall, result: @escaping FlutterResult) { @@ -130,7 +162,8 @@ final class MapboxMapController: NSObject, FlutterPlatformView { gesturesController!.removeListeners() result(nil) case "interactions#add_interaction": - interactionsController!.addInteraction(messenger: binaryMessenger, methodCall: methodCall) + interactionsController!.addInteraction( + messenger: binaryMessenger, methodCall: methodCall) result(nil) case "interactions#remove_interaction": interactionsController!.removeInteraction(methodCall: methodCall) @@ -143,11 +176,14 @@ final class MapboxMapController: NSObject, FlutterPlatformView { let snapshot = try mapView.snapshot() result(snapshot.pngData()) } catch { - result(FlutterError(code: "2342345", message: error.localizedDescription, details: nil)) + result( + FlutterError(code: "2342345", message: error.localizedDescription, details: nil) + ) } case "mapView#submitViewSizeHint": if let arguments = methodCall.arguments as? [String: Double], - let width = arguments["width"], let height = arguments["height"] { + let width = arguments["width"], let height = arguments["height"] + { let size = CGSize(width: width, height: height) guard size != .zero else { return } @@ -156,22 +192,6 @@ final class MapboxMapController: NSObject, FlutterPlatformView { mapView.superview?.frame = CGRect(origin: .zero, size: size) } result(nil) - case "map#setCustomHeaders": - guard let arguments = methodCall.arguments as? [String: Any], - let headers = arguments["headers"] as? [String: String] - else { - result(FlutterError( - code: "setCustomHeaders", - message: "could not decode arguments", - details: nil - )) - return - } - let customInterceptor = CustomHttpServiceInterceptor() - HttpServiceFactory.setHttpServiceInterceptorForInterceptor(customInterceptor) - customInterceptor.customHeaders = headers - result(nil) - default: result(FlutterMethodNotImplemented) } @@ -180,20 +200,48 @@ final class MapboxMapController: NSObject, FlutterPlatformView { private func releaseMethodChannels() { channel.setMethodCallHandler(nil) - StyleManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - _CameraManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - _MapInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - ProjectionSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - _AnimationManagerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - _LocationComponentSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - GesturesSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - LogoSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - AttributionSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - CompassSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - ScaleBarSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - IndoorSelectorSettingsInterfaceSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) + StyleManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + _CameraManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + _MapInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + ProjectionSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + _AnimationManagerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + _LocationComponentSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + GesturesSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + LogoSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + AttributionSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + CompassSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + ScaleBarSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + IndoorSelectorSettingsInterfaceSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) annotationController?.tearDown() - _ViewportMessengerSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) - _PerformanceStatisticsApiSetup.setUp(binaryMessenger: binaryMessenger.messenger, api: nil, messageChannelSuffix: binaryMessenger.suffix) + _ViewportMessengerSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) + _PerformanceStatisticsApiSetup.setUp( + binaryMessenger: binaryMessenger.messenger, api: nil, + messageChannelSuffix: binaryMessenger.suffix) } } diff --git a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift index 42b27b5e0..8a631d419 100644 --- a/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift +++ b/ios/mapbox_maps_flutter/Sources/mapbox_maps_flutter/Classes/MapboxMapsPlugin.swift @@ -1,8 +1,10 @@ import Flutter -import UIKit import MapboxMaps +import UIKit public class MapboxMapsPlugin: NSObject, FlutterPlugin { + private static var httpInterceptorChannel: FlutterMethodChannel? + public static func register(with registrar: FlutterPluginRegistrar) { let instance = MapboxMapFactory(withRegistrar: registrar) registrar.register(instance, withId: "plugins.flutter.io/mapbox_maps") @@ -13,17 +15,81 @@ public class MapboxMapsPlugin: NSObject, FlutterPlugin { private static func setupStaticChannels(with registrar: FlutterPluginRegistrar) { let binaryMessenger = registrar.messenger() - let mapboxOptionsController = MapboxOptionsController(assetKeyLookup: registrar.lookupKey(forAsset:)) - let snapshotterInstanceManager = SnapshotterInstanceManager(binaryMessenger: binaryMessenger) + let mapboxOptionsController = MapboxOptionsController( + assetKeyLookup: registrar.lookupKey(forAsset:)) + let snapshotterInstanceManager = SnapshotterInstanceManager( + binaryMessenger: binaryMessenger) let offlineMapInstanceManager = OfflineMapInstanceManager(binaryMessenger: binaryMessenger) _MapboxOptionsSetup.setUp(binaryMessenger: binaryMessenger, api: mapboxOptionsController) - _MapboxMapsOptionsSetup.setUp(binaryMessenger: binaryMessenger, api: mapboxOptionsController) - _SnapshotterInstanceManagerSetup.setUp(binaryMessenger: binaryMessenger, api: snapshotterInstanceManager) - _OfflineMapInstanceManagerSetup.setUp(binaryMessenger: binaryMessenger, api: offlineMapInstanceManager) - _TileStoreInstanceManagerSetup.setUp(binaryMessenger: binaryMessenger, api: offlineMapInstanceManager) + _MapboxMapsOptionsSetup.setUp( + binaryMessenger: binaryMessenger, api: mapboxOptionsController) + _SnapshotterInstanceManagerSetup.setUp( + binaryMessenger: binaryMessenger, api: snapshotterInstanceManager) + _OfflineMapInstanceManagerSetup.setUp( + binaryMessenger: binaryMessenger, api: offlineMapInstanceManager) + _TileStoreInstanceManagerSetup.setUp( + binaryMessenger: binaryMessenger, api: offlineMapInstanceManager) _OfflineSwitchSetup.setUp(binaryMessenger: binaryMessenger, api: OfflineSwitch.shared) LoggingController.setup(binaryMessenger) + + // Setup static HTTP interceptor channel - available before any map is created + setupHttpInterceptorChannel(binaryMessenger: binaryMessenger) + } + + private static func setupHttpInterceptorChannel(binaryMessenger: FlutterBinaryMessenger) { + httpInterceptorChannel = FlutterMethodChannel( + name: "com.mapbox.maps.flutter/http_interceptor", + binaryMessenger: binaryMessenger + ) + + let interceptor = CustomHttpServiceInterceptor.shared + interceptor.setFlutterChannel(httpInterceptorChannel) + + httpInterceptorChannel?.setMethodCallHandler { call, result in + switch call.method { + case "setCustomHeaders": + guard let arguments = call.arguments as? [String: Any], + let headers = arguments["headers"] as? [String: String] + else { + result( + FlutterError( + code: "setCustomHeaders", + message: "could not decode arguments", + details: nil + )) + return + } + interceptor.setCustomHeaders(headers) + result(nil) + + case "setHttpInterceptorEnabled": + guard let arguments = call.arguments as? [String: Any], + let enabled = arguments["enabled"] as? Bool, + let interceptRequests = arguments["interceptRequests"] as? Bool, + let interceptResponses = arguments["interceptResponses"] as? Bool + else { + result( + FlutterError( + code: "setHttpInterceptorEnabled", + message: "could not decode arguments", + details: nil + )) + return + } + let includeResponseBody = arguments["includeResponseBody"] as? Bool ?? false + interceptor.setInterceptorEnabled( + enabled: enabled, + interceptRequests: interceptRequests, + interceptResponses: interceptResponses, + includeResponseBody: includeResponseBody + ) + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } } } diff --git a/lib/mapbox_maps_flutter.dart b/lib/mapbox_maps_flutter.dart index a9d10580f..625ef40a3 100644 --- a/lib/mapbox_maps_flutter.dart +++ b/lib/mapbox_maps_flutter.dart @@ -89,5 +89,6 @@ part 'src/viewport/transitions/fly_viewport_transition.dart'; part 'src/viewport/transitions/easing_viewport_transition.dart'; part 'src/package_info.dart'; part 'src/http/http_service.dart'; +part 'src/http/http_interceptor_controller.dart'; part 'src/cancelable.dart'; part 'src/deprecated.dart'; diff --git a/lib/src/http/http_interceptor_controller.dart b/lib/src/http/http_interceptor_controller.dart new file mode 100644 index 000000000..8aa2195ef --- /dev/null +++ b/lib/src/http/http_interceptor_controller.dart @@ -0,0 +1,113 @@ +part of mapbox_maps_flutter; + +/// Static controller for managing HTTP interceptors. +/// +/// This controller communicates with the native HTTP interceptor channel +/// that is set up before any map is created, ensuring all HTTP requests +/// (including those made during map initialization) can be intercepted. +class _HttpInterceptorController { + static final _HttpInterceptorController _instance = + _HttpInterceptorController._internal(); + + factory _HttpInterceptorController() => _instance; + + _HttpInterceptorController._internal() { + _channel.setMethodCallHandler(_handleMethodCall); + } + + static const MethodChannel _channel = + MethodChannel('com.mapbox.maps.flutter/http_interceptor'); + + HttpRequestInterceptor? _requestInterceptor; + HttpResponseInterceptor? _responseInterceptor; + bool _includeResponseBody = false; + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'http#onRequest': + if (_requestInterceptor != null) { + final requestMap = call.arguments as Map; + final request = HttpInterceptorRequest.fromMap(requestMap); + final modifiedRequest = await _requestInterceptor!(request); + if (modifiedRequest != null) { + return modifiedRequest.toMap(); + } + } + return null; + case 'http#onResponse': + if (_responseInterceptor != null) { + try { + final responseMap = call.arguments as Map; + final response = HttpInterceptorResponse.fromMap(responseMap); + await _responseInterceptor!(response); + } catch (e) { + print('Error handling http#onResponse: $e'); + print('Arguments: ${call.arguments}'); + } + } + return null; + default: + throw MissingPluginException( + 'No handler for method ${call.method} on channel ${_channel.name}'); + } + } + + /// Sets custom HTTP headers that will be applied to all Mapbox HTTP requests. + /// + /// These headers are applied statically to all requests before any + /// request interceptor is called. + Future setCustomHeaders(Map headers) async { + await _channel.invokeMethod('setCustomHeaders', {'headers': headers}); + } + + /// Sets a callback to intercept HTTP requests before they are sent. + /// + /// The [interceptor] callback is called for each HTTP request made by Mapbox. + /// You can modify the request by returning a new [HttpInterceptorRequest], + /// or return null to use the original request unchanged. + /// + /// This should be called before creating any [MapWidget] to ensure all + /// requests (including those made during map initialization) are intercepted. + Future setHttpRequestInterceptor( + HttpRequestInterceptor? interceptor) async { + _requestInterceptor = interceptor; + await _updateInterceptorState(); + } + + /// Sets a callback to intercept HTTP responses after they are received. + /// + /// The [interceptor] callback is called for each HTTP response received by Mapbox. + /// This is useful for logging, monitoring, or debugging HTTP traffic. + /// + /// Note: The response cannot be modified; this is for observation only. + /// + /// If [includeResponseBody] is true, the response body will be included in + /// the [HttpInterceptorResponse]. This defaults to false to avoid performance + /// issues with large tile bodies (200-500KB). Only enable this if you need + /// to inspect the response body content. + /// + /// This should be called before creating any [MapWidget] to ensure all + /// responses (including those from map initialization requests) are captured. + Future setHttpResponseInterceptor( + HttpResponseInterceptor? interceptor, { + bool includeResponseBody = false, + }) async { + _responseInterceptor = interceptor; + _includeResponseBody = includeResponseBody; + await _updateInterceptorState(); + } + + Future _updateInterceptorState() async { + final shouldEnable = + _requestInterceptor != null || _responseInterceptor != null; + final interceptRequests = _requestInterceptor != null; + final interceptResponses = _responseInterceptor != null; + + await _channel.invokeMethod('setHttpInterceptorEnabled', { + 'enabled': shouldEnable, + 'interceptRequests': interceptRequests, + 'interceptResponses': interceptResponses, + 'includeResponseBody': _includeResponseBody, + }); + } +} diff --git a/lib/src/http/http_service.dart b/lib/src/http/http_service.dart index cc2d081b6..c6f38d6cd 100644 --- a/lib/src/http/http_service.dart +++ b/lib/src/http/http_service.dart @@ -1,5 +1,177 @@ part of mapbox_maps_flutter; +/// Represents an HTTP request that can be intercepted and modified. +/// +/// This class is passed to the [HttpRequestInterceptor] callback, allowing +/// you to inspect and modify the request before it is sent. +/// +/// The following fields can be modified using [copyWith]: +/// - [url] - The request URL +/// - [headers] - The request headers +/// - [body] - The request body +/// +/// Note: [method] is included for inspection but cannot be modified by the +/// native HTTP interceptor. +class HttpInterceptorRequest { + /// The URL of the request. Can be modified. + final String url; + + /// The HTTP method (e.g., "GET", "POST"). + /// Note: This field is read-only and cannot be modified by the interceptor. + final String method; + + /// The headers of the request. Can be modified. + final Map headers; + + /// The request body, if any. Can be modified. + final Uint8List? body; + + /// Creates a new [HttpInterceptorRequest]. + HttpInterceptorRequest({ + required this.url, + required this.method, + required this.headers, + this.body, + }); + + /// Creates a copy of this request with the given fields replaced. + HttpInterceptorRequest copyWith({ + String? url, + String? method, + Map? headers, + Uint8List? body, + }) { + return HttpInterceptorRequest( + url: url ?? this.url, + method: method ?? this.method, + headers: headers ?? Map.from(this.headers), + body: body ?? this.body, + ); + } + + /// Converts this request to a map for serialization. + Map toMap() { + return { + 'url': url, + 'method': method, + 'headers': headers, + 'body': body, + }; + } + + /// Creates a request from a map. + factory HttpInterceptorRequest.fromMap(Map map) { + return HttpInterceptorRequest( + url: map['url'] as String, + method: map['method'] as String, + headers: Map.from(map['headers'] ?? {}), + body: map['body'] as Uint8List?, + ); + } + + @override + String toString() { + return 'HttpInterceptorRequest(url: $url, method: $method, headers: $headers)'; + } +} + +/// Represents an HTTP response that can be intercepted. +/// +/// This class is passed to the [HttpResponseInterceptor] callback, allowing +/// you to inspect the response after it is received. +class HttpInterceptorResponse { + /// The URL of the original request. + final String url; + + /// The HTTP status code of the response. + final int statusCode; + + /// The response headers. + final Map headers; + + /// The response body, if any. + final Uint8List? data; + + /// The headers from the original request. + /// Useful for correlating requests with responses using custom headers + /// like `X-Request-Id`. + final Map requestHeaders; + + /// Creates a new [HttpInterceptorResponse]. + HttpInterceptorResponse({ + required this.url, + required this.statusCode, + required this.headers, + this.data, + this.requestHeaders = const {}, + }); + + /// Creates a response from a map. + factory HttpInterceptorResponse.fromMap(Map map) { + // statusCode might come as int or long depending on platform + final statusCodeValue = map['statusCode']; + final int statusCode; + if (statusCodeValue is int) { + statusCode = statusCodeValue; + } else if (statusCodeValue is num) { + statusCode = statusCodeValue.toInt(); + } else { + statusCode = -1; + } + + // data might come as Uint8List or List depending on platform + final dataValue = map['data']; + final Uint8List? data; + if (dataValue is Uint8List) { + data = dataValue; + } else if (dataValue is List) { + data = Uint8List.fromList(dataValue.cast()); + } else { + data = null; + } + + return HttpInterceptorResponse( + url: map['url'] as String, + statusCode: statusCode, + headers: Map.from(map['headers'] ?? {}), + data: data, + requestHeaders: Map.from(map['requestHeaders'] ?? {}), + ); + } + + @override + String toString() { + return 'HttpInterceptorResponse(url: $url, statusCode: $statusCode, headers: $headers)'; + } +} + +/// Callback type for intercepting HTTP requests. +/// +/// Return the modified [HttpInterceptorRequest] to use the modified request, +/// or return null to use the original request unchanged. +/// +/// Example: +/// ```dart +/// Future onRequest(HttpInterceptorRequest request) async { +/// // Add authorization header only for specific domains +/// if (request.url.contains('my-tile-server.com')) { +/// return request.copyWith( +/// headers: {...request.headers, 'Authorization': 'Bearer token'}, +/// ); +/// } +/// return null; // Use original request for other domains +/// } +/// ``` +typedef HttpRequestInterceptor = Future Function( + HttpInterceptorRequest request); + +/// Callback type for intercepting HTTP responses. +/// +/// This callback is called after a response is received, allowing you to +/// inspect or log the response. Note that the response cannot be modified. +typedef HttpResponseInterceptor = Future Function( + HttpInterceptorResponse response); + /// A service that handles HTTP-related functionality for Mapbox class MapboxHttpService { late final MethodChannel _channel; diff --git a/lib/src/mapbox_map.dart b/lib/src/mapbox_map.dart index 6cbdfa9d4..03bebe361 100644 --- a/lib/src/mapbox_map.dart +++ b/lib/src/mapbox_map.dart @@ -255,9 +255,10 @@ class MapboxMap extends ChangeNotifier { binaryMessenger: _mapboxMapsPlatform.binaryMessenger, messageChannelSuffix: _mapboxMapsPlatform.channelSuffix.toString()); - late final MapboxHttpService httpService = MapboxHttpService( + late final MapboxHttpService httpService = MapboxHttpService( binaryMessenger: _mapboxMapsPlatform.binaryMessenger, channelSuffix: _mapboxMapsPlatform.channelSuffix); + OnMapTapListener? onMapTapListener; OnMapLongTapListener? onMapLongTapListener; OnMapScrollListener? onMapScrollListener; @@ -863,27 +864,29 @@ class MapboxMap extends ChangeNotifier { Future setSnapshotLegacyMode(bool enable) => _mapInterface.setSnapshotLegacyMode(enable); - /// Set custom headers for all Mapbox HTTP requests + /// Sets custom HTTP headers that will be sent with all map-related HTTP requests. /// - /// [headers] is a map of header names to header values + /// This is a convenience method that delegates to [MapboxMapsOptions.setCustomHeaders]. + /// Headers set here will apply to all Mapbox HTTP requests globally, not just this map instance. /// - /// Throws a [PlatformException] if the native implementation is not available - /// or if the operation fails + /// Common use cases include: + /// - Authentication tokens for private tile servers + /// - API keys for third-party services + /// - Custom tracking or analytics headers /// /// Example: /// ```dart - /// MapboxMap.setCustomHeaders({ - /// "Authorization": "Bearer your_secret_token", + /// await mapboxMap.setCustomHeaders({ + /// 'Authorization': 'Bearer your_token', + /// 'X-Custom-Header': 'value', /// }); /// ``` /// - /// Throws a [PlatformException] if the native implementation is not available - /// or if the operation fails - Future setCustomHeaders(Map headers) => - MapboxHttpService( - binaryMessenger: _mapboxMapsPlatform.binaryMessenger, - channelSuffix: _mapboxMapsPlatform.channelSuffix) - .setCustomHeaders(headers); + /// Note: For setting headers before the map is created, use + /// [MapboxMapsOptions.setCustomHeaders] directly in your app's initialization. + Future setCustomHeaders(Map headers) { + return MapboxMapsOptions.setCustomHeaders(headers); + } } class _GestureListener extends GestureListener { diff --git a/lib/src/mapbox_maps_options.dart b/lib/src/mapbox_maps_options.dart index fad1e734d..c594b5fa4 100644 --- a/lib/src/mapbox_maps_options.dart +++ b/lib/src/mapbox_maps_options.dart @@ -129,4 +129,108 @@ final class MapboxMapsOptions { static Future clearData() { return _options.clearData(); } + + // HTTP Interceptor static controller + static final _HttpInterceptorController _httpInterceptorController = + _HttpInterceptorController(); + + /// Sets custom HTTP headers that will be applied to all Mapbox HTTP requests. + /// + /// These headers are applied statically to all requests, including requests + /// made during map initialization. This should be called before creating + /// any [MapWidget] to ensure headers are applied to all requests. + /// + /// Example: + /// ```dart + /// // In your main() or initState, before creating MapWidget: + /// MapboxMapsOptions.setCustomHeaders({ + /// 'Authorization': 'Bearer your_secret_token', + /// }); + /// ``` + /// + /// Throws a [PlatformException] if the native implementation is not available. + static Future setCustomHeaders(Map headers) { + return _httpInterceptorController.setCustomHeaders(headers); + } + + /// Sets a callback to intercept HTTP requests before they are sent. + /// + /// The [interceptor] callback is called for each HTTP request made by Mapbox. + /// You can modify the request by returning a new [HttpInterceptorRequest], + /// or return null to use the original request unchanged. + /// + /// **Important:** This should be called before creating any [MapWidget] to + /// ensure all requests (including those made during map initialization for + /// styles, tiles, glyphs, etc.) are intercepted. + /// + /// The following request properties can be modified: + /// - [HttpInterceptorRequest.url] - Redirect to a different URL + /// - [HttpInterceptorRequest.headers] - Add/modify/remove headers + /// - [HttpInterceptorRequest.body] - Modify request body + /// + /// Note: [HttpInterceptorRequest.method] is included for inspection but + /// cannot be modified by the native interceptor. + /// + /// Example: + /// ```dart + /// // In your main() or initState, before creating MapWidget: + /// MapboxMapsOptions.setHttpRequestInterceptor((request) async { + /// // Add authorization header only for your custom tile server + /// if (request.url.contains('my-tile-server.com')) { + /// return request.copyWith( + /// headers: {...request.headers, 'Authorization': 'Bearer token'}, + /// ); + /// } + /// return null; // Use original request for other domains + /// }); + /// ``` + /// + /// To remove the interceptor, call this method with null. + /// + /// Throws a [PlatformException] if the native implementation is not available. + static Future setHttpRequestInterceptor( + HttpRequestInterceptor? interceptor) { + return _httpInterceptorController.setHttpRequestInterceptor(interceptor); + } + + /// Sets a callback to intercept HTTP responses after they are received. + /// + /// The [interceptor] callback is called for each HTTP response received by Mapbox. + /// This is useful for logging, monitoring, or debugging HTTP traffic. + /// + /// **Important:** This should be called before creating any [MapWidget] to + /// ensure all responses (including those from map initialization requests) + /// are captured. + /// + /// Note: The response cannot be modified; this is for observation only. + /// + /// The [HttpInterceptorResponse.requestHeaders] field contains the headers + /// from the original request, which is useful for correlating requests with + /// responses using custom headers like `X-Request-Id`. + /// + /// If [includeResponseBody] is true, the response body will be included in + /// the [HttpInterceptorResponse.body] field. This defaults to false to avoid + /// performance issues with large tile bodies (200-500KB per tile). Only + /// enable this if you need to inspect the actual response body content. + /// + /// Example: + /// ```dart + /// // In your main() or initState, before creating MapWidget: + /// MapboxMapsOptions.setHttpResponseInterceptor((response) async { + /// print('Received response from ${response.url}: ${response.statusCode}'); + /// }); + /// ``` + /// + /// To remove the interceptor, call this method with null. + /// + /// Throws a [PlatformException] if the native implementation is not available. + static Future setHttpResponseInterceptor( + HttpResponseInterceptor? interceptor, { + bool includeResponseBody = false, + }) { + return _httpInterceptorController.setHttpResponseInterceptor( + interceptor, + includeResponseBody: includeResponseBody, + ); + } } diff --git a/lib/src/mapbox_maps_platform.dart b/lib/src/mapbox_maps_platform.dart index 12b261009..34f40ecd1 100644 --- a/lib/src/mapbox_maps_platform.dart +++ b/lib/src/mapbox_maps_platform.dart @@ -23,6 +23,7 @@ class _MapboxMapsPlatform { Future _handleMethodCall(MethodCall call) async { print( "Handle method call ${call.method}, arguments: ${call.arguments} not supported"); + return null; } Widget buildView(