Skip to content

Commit 4015162

Browse files
committed
feat: add HTTP interceptor support
1 parent baef3e7 commit 4015162

17 files changed

Lines changed: 1766 additions & 136 deletions

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
### main
22

3+
* Add HTTP request/response interceptor API for dynamic header injection and request monitoring.
4+
* `MapboxMapsOptions.setHttpRequestInterceptor()` - Intercept and modify HTTP requests before they are sent. Supports modifying URL, headers, and body.
5+
* `MapboxMapsOptions.setHttpResponseInterceptor()` - Inspect HTTP responses after they are received.
6+
* `MapboxMapsOptions.setCustomHeaders()` - Set static headers that are applied to all requests.
7+
* `HttpInterceptorRequest` - Represents an intercepted request with `url`, `method`, `headers`, and `body` properties. Use `copyWith()` to create modified requests.
8+
* `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`.
9+
10+
**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`.
11+
12+
Example usage:
13+
```dart
14+
// In initState() or before creating MapWidget:
15+
16+
// Set static headers (applied to all requests)
17+
MapboxMapsOptions.setCustomHeaders({'X-App-Version': '1.0.0'});
18+
19+
// Add dynamic custom headers to requests
20+
MapboxMapsOptions.setHttpRequestInterceptor((request) async {
21+
if (request.url.contains('api.mapbox.com')) {
22+
return request.copyWith(
23+
headers: {...request.headers, 'Authorization': 'Bearer token'},
24+
);
25+
}
26+
return null; // Use original request
27+
});
28+
29+
// Monitor responses
30+
MapboxMapsOptions.setHttpResponseInterceptor((response) async {
31+
print('Response: ${response.statusCode} for ${response.url}');
32+
});
33+
```
34+
335
### 2.19.0-beta.1
436

537
* Update Maps SDK to v11.19.0-beta.1

android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapController.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,19 @@ class MapboxMapController(
355355
result.error("HEADER_ERROR", e.message, null)
356356
}
357357
}
358+
"map#setHttpInterceptorEnabled" -> {
359+
try {
360+
val enabled = call.argument<Boolean>("enabled") ?: false
361+
val interceptRequests = call.argument<Boolean>("interceptRequests") ?: false
362+
val interceptResponses = call.argument<Boolean>("interceptResponses") ?: false
363+
val interceptor = CustomHttpServiceInterceptor.getInstance()
364+
interceptor.setFlutterChannel(methodChannel)
365+
interceptor.setInterceptorEnabled(enabled, interceptRequests, interceptResponses)
366+
result.success(null)
367+
} catch (e: Exception) {
368+
result.error("INTERCEPTOR_ERROR", e.message, null)
369+
}
370+
}
358371
else -> {
359372
result.notImplemented()
360373
}

android/src/main/kotlin/com/mapbox/maps/mapbox_maps/MapboxMapsPlugin.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.mapbox.maps.mapbox_maps
22

33
import android.content.Context
44
import androidx.lifecycle.Lifecycle
5+
import com.mapbox.maps.mapbox_maps.http.CustomHttpServiceInterceptor
56
import com.mapbox.maps.mapbox_maps.offline.OfflineMapInstanceManager
67
import com.mapbox.maps.mapbox_maps.offline.OfflineSwitch
78
import com.mapbox.maps.mapbox_maps.pigeons._MapboxMapsOptions
@@ -17,10 +18,12 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
1718
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
1819
import io.flutter.embedding.engine.plugins.lifecycle.FlutterLifecycleAdapter
1920
import io.flutter.plugin.common.BinaryMessenger
21+
import io.flutter.plugin.common.MethodChannel
2022

2123
/** MapboxMapsPlugin */
2224
class MapboxMapsPlugin : FlutterPlugin, ActivityAware {
2325
private var lifecycle: Lifecycle? = null
26+
private var httpInterceptorChannel: MethodChannel? = null
2427

2528
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
2629
flutterPluginBinding
@@ -54,6 +57,47 @@ class MapboxMapsPlugin : FlutterPlugin, ActivityAware {
5457
_TileStoreInstanceManager.setUp(binaryMessenger, offlineMapInstanceManager)
5558
_OfflineSwitch.setUp(binaryMessenger, offlineSwitch)
5659
LoggingController.setup(binaryMessenger)
60+
61+
// Setup static HTTP interceptor channel - available before any map is created
62+
setupHttpInterceptorChannel(binaryMessenger)
63+
}
64+
65+
private fun setupHttpInterceptorChannel(binaryMessenger: BinaryMessenger) {
66+
httpInterceptorChannel = MethodChannel(binaryMessenger, "com.mapbox.maps.flutter/http_interceptor")
67+
val interceptor = CustomHttpServiceInterceptor.getInstance()
68+
interceptor.setFlutterChannel(httpInterceptorChannel)
69+
70+
httpInterceptorChannel?.setMethodCallHandler { call, result ->
71+
when (call.method) {
72+
"setCustomHeaders" -> {
73+
try {
74+
val headers = call.argument<Map<String, String>>("headers")
75+
headers?.let {
76+
interceptor.setCustomHeaders(headers)
77+
result.success(null)
78+
} ?: run {
79+
result.error("INVALID_ARGUMENTS", "Headers cannot be null", null)
80+
}
81+
} catch (e: Exception) {
82+
result.error("HEADER_ERROR", e.message, null)
83+
}
84+
}
85+
"setHttpInterceptorEnabled" -> {
86+
try {
87+
val enabled = call.argument<Boolean>("enabled") ?: false
88+
val interceptRequests = call.argument<Boolean>("interceptRequests") ?: false
89+
val interceptResponses = call.argument<Boolean>("interceptResponses") ?: false
90+
interceptor.setInterceptorEnabled(enabled, interceptRequests, interceptResponses)
91+
result.success(null)
92+
} catch (e: Exception) {
93+
result.error("INTERCEPTOR_ERROR", e.message, null)
94+
}
95+
}
96+
else -> {
97+
result.notImplemented()
98+
}
99+
}
100+
}
57101
}
58102

59103
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {

android/src/main/kotlin/com/mapbox/maps/mapbox_maps/http/CustomHttpServiceInterceptor.kt

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,154 @@
11
package com.mapbox.maps.mapbox_maps.http
22

3+
import android.os.Handler
4+
import android.os.Looper
35
import com.mapbox.common.HttpRequest
46
import com.mapbox.common.HttpRequestOrResponse
57
import com.mapbox.common.HttpResponse
68
import com.mapbox.common.HttpServiceFactory
79
import com.mapbox.common.HttpServiceInterceptorInterface
810
import com.mapbox.common.HttpServiceInterceptorRequestContinuation
911
import com.mapbox.common.HttpServiceInterceptorResponseContinuation
12+
import io.flutter.plugin.common.MethodChannel
13+
import java.util.concurrent.CountDownLatch
14+
import java.util.concurrent.TimeUnit
1015

1116
class CustomHttpServiceInterceptor : HttpServiceInterceptorInterface {
1217
private var customHeaders: MutableMap<String, String> = mutableMapOf()
18+
private var flutterChannel: MethodChannel? = null
19+
private var interceptRequests: Boolean = false
20+
private var interceptResponses: Boolean = false
21+
private val mainHandler = Handler(Looper.getMainLooper())
1322

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

26+
// First apply static custom headers
1727
currentHeaders.putAll(customHeaders)
18-
val modifiedRequest = request.toBuilder()
19-
.headers(currentHeaders)
20-
.build()
28+
29+
// If Flutter callback is enabled, invoke it
30+
if (interceptRequests && flutterChannel != null) {
31+
invokeFlutterRequestCallback(request, currentHeaders, continuation)
32+
} else {
33+
// No callback, just continue with static headers
34+
val modifiedRequest = request.toBuilder()
35+
.headers(currentHeaders)
36+
.build()
37+
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
38+
continuation.run(requestOrResponse)
39+
}
40+
}
41+
42+
private fun invokeFlutterRequestCallback(
43+
request: HttpRequest,
44+
currentHeaders: HashMap<String, String>,
45+
continuation: HttpServiceInterceptorRequestContinuation
46+
) {
47+
val requestMap = mapOf(
48+
"url" to request.url,
49+
"method" to request.method.name,
50+
"headers" to currentHeaders,
51+
"body" to request.body
52+
)
53+
54+
// Use a latch to wait for the Flutter response
55+
val latch = CountDownLatch(1)
56+
var modifiedRequestMap: Map<String, Any?>? = null
57+
58+
mainHandler.post {
59+
flutterChannel?.invokeMethod("http#onRequest", requestMap, object : MethodChannel.Result {
60+
override fun success(result: Any?) {
61+
@Suppress("UNCHECKED_CAST")
62+
modifiedRequestMap = result as? Map<String, Any?>
63+
latch.countDown()
64+
}
65+
66+
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
67+
// On error, continue with original request
68+
latch.countDown()
69+
}
70+
71+
override fun notImplemented() {
72+
// Not implemented, continue with original request
73+
latch.countDown()
74+
}
75+
})
76+
}
77+
78+
// Wait for Flutter response with a timeout
79+
val received = latch.await(5, TimeUnit.SECONDS)
80+
81+
val builder = request.toBuilder()
82+
83+
if (received && modifiedRequestMap != null) {
84+
val modified = modifiedRequestMap!!
85+
86+
// Apply modified headers
87+
@Suppress("UNCHECKED_CAST")
88+
val newHeaders = modified["headers"] as? Map<String, String>
89+
if (newHeaders != null) {
90+
builder.headers(HashMap(newHeaders))
91+
} else {
92+
builder.headers(currentHeaders)
93+
}
94+
95+
// Apply modified URL
96+
val newUrl = modified["url"] as? String
97+
if (newUrl != null) {
98+
builder.url(newUrl)
99+
}
100+
101+
// Apply modified body
102+
@Suppress("UNCHECKED_CAST")
103+
val newBody = modified["body"] as? ByteArray
104+
if (newBody != null) {
105+
builder.body(newBody)
106+
}
107+
108+
// Note: method is not modifiable via the builder API
109+
} else {
110+
builder.headers(currentHeaders)
111+
}
112+
113+
val modifiedRequest = builder.build()
21114
val requestOrResponse = HttpRequestOrResponse(modifiedRequest)
22115
continuation.run(requestOrResponse)
23116
}
24117

25118
override fun onResponse(response: HttpResponse, continuation: HttpServiceInterceptorResponseContinuation) {
119+
// If Flutter callback is enabled, invoke it (non-blocking)
120+
if (interceptResponses && flutterChannel != null) {
121+
// response.result is Expected<HttpRequestError, HttpResponseData>
122+
// We need to check if it's a success and extract the value
123+
val responseData = response.result.value
124+
val responseMap = if (responseData != null) {
125+
mapOf(
126+
"url" to response.request.url,
127+
"statusCode" to responseData.code,
128+
"headers" to responseData.headers,
129+
"data" to responseData.data,
130+
"requestHeaders" to response.request.headers
131+
)
132+
} else {
133+
// Error response - still send basic info
134+
mapOf(
135+
"url" to response.request.url,
136+
"statusCode" to -1,
137+
"headers" to emptyMap<String, String>(),
138+
"data" to null,
139+
"requestHeaders" to response.request.headers
140+
)
141+
}
142+
143+
mainHandler.post {
144+
flutterChannel?.invokeMethod("http#onResponse", responseMap, object : MethodChannel.Result {
145+
override fun success(result: Any?) {}
146+
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {}
147+
override fun notImplemented() {}
148+
})
149+
}
150+
}
151+
26152
continuation.run(response)
27153
}
28154

@@ -32,6 +158,16 @@ class CustomHttpServiceInterceptor : HttpServiceInterceptorInterface {
32158
HttpServiceFactory.setHttpServiceInterceptor(this)
33159
}
34160

161+
fun setFlutterChannel(channel: MethodChannel?) {
162+
flutterChannel = channel
163+
}
164+
165+
fun setInterceptorEnabled(enabled: Boolean, interceptRequests: Boolean, interceptResponses: Boolean) {
166+
this.interceptRequests = enabled && interceptRequests
167+
this.interceptResponses = enabled && interceptResponses
168+
HttpServiceFactory.setHttpServiceInterceptor(this)
169+
}
170+
35171
companion object {
36172
private var instance: CustomHttpServiceInterceptor? = null
37173

example/ios/Flutter/Debug.xcconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
12
#include "Generated.xcconfig"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
12
#include "Generated.xcconfig"

example/ios/Podfile

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Uncomment this line to define a global platform for your project
2+
# platform :ios, '13.0'
3+
4+
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
5+
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
6+
7+
project 'Runner', {
8+
'Debug' => :debug,
9+
'Profile' => :release,
10+
'Release' => :release,
11+
}
12+
13+
def flutter_root
14+
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
15+
unless File.exist?(generated_xcode_build_settings_path)
16+
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
17+
end
18+
19+
File.foreach(generated_xcode_build_settings_path) do |line|
20+
matches = line.match(/FLUTTER_ROOT\=(.*)/)
21+
return matches[1].strip if matches
22+
end
23+
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
24+
end
25+
26+
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
27+
28+
flutter_ios_podfile_setup
29+
30+
target 'Runner' do
31+
use_frameworks!
32+
33+
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
34+
target 'RunnerTests' do
35+
inherit! :search_paths
36+
end
37+
end
38+
39+
post_install do |installer|
40+
installer.pods_project.targets.each do |target|
41+
flutter_additional_ios_build_settings(target)
42+
end
43+
end

0 commit comments

Comments
 (0)