Skip to content

Conversation

@protheeuz
Copy link

@protheeuz protheeuz commented Oct 16, 2025

What does this pull request do?

This PR implements the MapRecorder API for Flutter SDK, exposing functionality that already exists in Android and iOS native SDKs.

API Surface:

  • mapboxMap.recorder.startRecording() - Begin recording map interactions
  • mapboxMap.recorder.stopRecording() - Stop and retrieve recorded sequence
  • mapboxMap.recorder.replay() - Replay recorded sequence with customization options
  • mapboxMap.recorder.togglePause() - Pause/resume playback
  • mapboxMap.recorder.getState() - Get current playback state

Implementation approach:

  • Uses Pigeon v25.2.0 for type-safe platform communication
  • Wraps native MapboxMaps.MapRecorder (iOS) and MapboxMapRecorder (Android)
  • Marked as @experimental to match native SDK status

Example usage:

// Record map interactions
await mapboxMap.recorder.startRecording(
  timeWindow: 60000,
  loggingEnabled: true,
  compressed: true,
);

final sequence = await mapboxMap.recorder.stopRecording();

// Replay at 2x speed
await mapboxMap.recorder.replay(
  sequence,
  playbackCount: 2,
  playbackSpeedMultiplier: 2.0,
);

Files added:

  • Generated platform interfaces (Dart, Kotlin, Swift) via Pigeon
  • Native controllers for Android and iOS
  • Dart wrapper class with public API
  • Integration into MapboxMap
  • Working example with UI controls

What is the motivation and context behind this change?

Issue: #1034

Context:
MapRecorder is available in both native SDKs but missing from Flutter SDK, creating a feature gap for Flutter devs.

Use cases (per native SDK documentation):

  • Debug issues that require multiple steps to reproduce
  • Performance testing of custom scenarios

Native SDK references:

Implementation notes:

  • Created Pigeon definition file (pigeons/map_recorder.dart) as Mapbox internal tooling doesn't provide one for MapRecorder
  • Follows existing SDK patterns for consistency (similar to how Snapshotter, Offline, etc. are implemented)
  • API marked @experimental with no version compatibility guarantee for recorded file format

Testing:

  • Verified on Android emulator (API 34) and iOS simulator (iPhone 16 Plus)
  • Recording captures interactions correctly
  • Replay functionality works with speed multiplier and pause/resume
  • Example app demonstrates all features

Demo Videos:

Android (Pixel 9 - API 34)

tested-android.mp4

iOS (iPhone 16 Plus)

Simulator.Screen.Recording.-.iPhone.16.Plus.-.2025-10-16.at.11.16.59.mp4

Pull request checklist:

  • Add a changelog entry.
  • Write tests for all new functionality. If tests were not written, please explain why.
  • Add documentation comments for any added or updated public APIs.

Note on tests:
Unit tests were not added as this follows the existing pattern in the SDK. The codebase currently only has events_test.dart for event data serialization. Other features (annotations, offline, snapshotter) also don't have unit tests. Integration testing is handled via the example app, which demonstrates all MapRecorder features with UI controls.

Note on changelog:
Will add changelog entry once PR is approved, to match the appropriate version number.

Implements MapRecorder functionality to achieve feature parity with Android and iOS native SDKs.
- Add Pigeon-generated platform communication layer
- Implement MapRecorderController for Android and iOS platforms
- Create MapRecorder wrapper class with user-facing API
- Integrate into MapboxMap via recorder property
- Add working example demonstrating record/replay/pause features
- Mark API as @experimental matching native SDK status
@protheeuz protheeuz requested a review from a team as a code owner October 16, 2025 05:15
@protheeuz protheeuz requested a review from maios October 16, 2025 05:15
@maios maios requested a review from pjleonard37 October 22, 2025 12:21
@pjleonard37 pjleonard37 changed the base branch from main to public/protheeuz-map-recorder-support October 23, 2025 14:02
Copy link
Contributor

@pjleonard37 pjleonard37 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this contribution! This will be a great addition to the Flutter SDK. The implementation shows good understanding of the platform channel architecture and maintains consistency across Android and iOS.

There are a few items to address before we can merge:

  1. Please add tests for the core functionality (i.e. basic usage of the recorder, data handling, playback states). You can create a new file here and follow the pattern.
  2. Please add a short changelog entry. You can use the main heading for now and we'll move it if needed.
  3. [Android] Recorder instances need to be cleaned up to avoid memory leaks
  4. [Android] A few minor items can be safely removed, see individual comments
  5. [iOS] PigeonError should be removed
    I also added some recommendations for the example, but they are not critical issues.

Once you make these edits I'll approve and then you can merge the changes into this temporary branch. We'll then run that branch through our CI system to ensure all tests pass and the formatting is correct. Then we'll merge into main and release these changes with our next release!

Thanks again and let me know if you have questions about any of this.

child: ElevatedButton.icon(
onPressed: isReplaying ? _togglePause : null,
icon: const Icon(Icons.pause),
label: const Text('Pause'),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to update this text and icon after the user selects Pause so that they know to click the button again to restart the recording.

val data = getRecorder().stopRecording()
val bytes = ByteArray(data.remaining())
data.get(bytes)
data.rewind()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this data.rewind() is necessary as the data has already been copied to the byte array. Is there another purpose I'm missing?

private val mapboxMap: MapboxMap
) : _MapRecorderMessenger {

private var recorder: MapboxMapRecorder? = null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be cleaned up to avoid memory leaks. Please add a dispose() and call it from MapboxMapController.dispose()

fun dispose() {
  recorder = null
}

And then in MapboxMapController.kt:

override fun dispose() {
  if (mapView == null) {
    return
  }
  lifecycleHelper?.dispose()
  lifecycleHelper = null
  mapView?.setViewTreeLifecycleOwner(null)
  mapView = null
  mapboxMap = null
  methodChannel.setMethodCallHandler(null)

  StyleManager.setUp(messenger, null, channelSuffix)
  _CameraManager.setUp(messenger, null, channelSuffix)
  Projection.setUp(messenger, null, channelSuffix)
  _MapInterface.setUp(messenger, null, channelSuffix)
  _AnimationManager.setUp(messenger, null, channelSuffix)
  annotationController.dispose()
  _LocationComponentSettingsInterface.setUp(messenger, null, channelSuffix)
  LogoSettingsInterface.setUp(messenger, null, channelSuffix)
  GesturesSettingsInterface.setUp(messenger, null, channelSuffix)
  CompassSettingsInterface.setUp(messenger, null, channelSuffix)
  ScaleBarSettingsInterface.setUp(messenger, null, channelSuffix)
  AttributionSettingsInterface.setUp(messenger, null, channelSuffix)
  _ViewportMessenger.setUp(messenger, null, channelSuffix)
  _PerformanceStatisticsApi.setUp(messenger, null, channelSuffix)
  _MapRecorderMessenger.setUp(messenger, null, channelSuffix)  // ← ADD THIS
  mapRecorderController.dispose() // ← ADD THIS
}

override fun startRecording(options: MapRecorderOptions) {
val nativeOptions = com.mapbox.maps.MapRecorderOptions.Builder()
.apply {
options.timeWindow?.let { timeWindow(it.toLong()) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this toLong() conversion is redundant - timeWindow is already Long? from the pigeon definition.

avoidPlaybackPauses: false,
);

setState(() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a critical issue as this is just an example, but it is worth noting that the replay() method is async. If the user navigates away during replay, the widget could be disposed, and calling setState() on a disposed widget will throw an exception.

This could be fixed by wrapping in an if (mounted) block like this:

if (mounted) {
  setState(() {
    isReplaying = false;
  });
}

The same applies to the other setState and error calls here.

Comment on lines +26 to +28
bool isRecording = false;
bool isReplaying = false;
String playbackState = 'IDLE';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to use just one approach to state management (either the boolean or the playback state). Another option would be an enum of all potential states.

}

private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not compile on iOS because PigeonError is not defined. However, I think it can just be removed in favor of the default error.

@@ -0,0 +1,88 @@
package com.mapbox.maps.mapbox_maps

import com.mapbox.bindgen.DataRef
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this import can be removed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants