Back to Engineering Log
August 01, 202612 min read

Rejourney Flutter SDK Is Now in Open Beta

Why PixelCopy can return a black Flutter surface, how retained-layer capture recovers it, and why privacy masks lag behind route transitions on purpose.

M

The Rejourney Flutter SDK is now in open beta on pub.dev. The current release is 0.3.1. It supports Flutter 3.22+, Dart 3.3+, iOS 15.1+, and Android API 24+.

flutter pub add rejourney

// Configure first. Recording starts only when start() is called.
await Rejourney.init('pk_live_your_public_key');
await Rejourney.start();
01 // THE BRIDGE

Keep the Dart side small

Rejourney already had native recorders for iOS and Android. Rewriting their lifecycle, upload, crash recovery, and capture logic in Dart would have created a third recorder with subtly different behavior. The Flutter package instead uses one method channel, co.rejourney.flutter/methods, as a narrow boundary around the native cores.

Dart owns the parts that only Flutter can describe well: route changes, framework errors, widget bounds, and the retained render layer. Native code owns session state, remote recording policy, the capture timer, encoding, persistence, and upload. The boundary carries plain maps, lists, byte arrays, and scalar values. Rejourney._channelValue rejects anything the platform channel cannot carry before it reaches Kotlin or Swift.

init() also does less than its name might suggest. It validates the project key and options, then configures both native sides. Capture waits for an explicit start(). That split gives an app a clean place to wait for consent. It also lets remote sampling and the recording kill switch run before a framebuffer read begins.

Platform boundary
abstract class RejourneyPlatform extends PlatformInterface {
  Future<T?> invoke<T>(
    String method,
    [Map<String, Object?>? arguments],
  );

  Stream<Map<String, Object?>> get events;
}
02 // ANDROID GPU CAPTURE

PixelCopy said success. The replay was black.

A Flutter screen on Android is usually presented through a FlutterSurfaceView. Calling draw() on the surrounding Android view hierarchy gets the container, but not the GPU pixels on that surface. We use PixelCopy on the Flutter surface for the normal path.

The first beta builds exposed a worse failure mode. On some renderer and device combinations, PixelCopy returned SUCCESS and handed us a correctly sized black bitmap. A toast could make the whole window look non-empty, leaving a replay with one small native overlay floating over a black Flutter app. Checking the result code was not enough.

The recorder now samples a 24 by 24 grid and classifies the result. The detector checks near-black ratio, luma range, and how many pixels contain a meaningful signal. One branch requires at least 98.5% near-black samples with a maximum luma below 48. Another catches the sparse-overlay case. The classifier has no Android graphics dependency, so its edge cases run as ordinary JVM tests.

False-success classification
val nearBlackRatio = nearBlack.toDouble() / visible.toDouble()
return (nearBlackRatio >= 0.985 && maximumLuma < 48) ||
    (nonBlack <= meaningfulSignalFloor && maximumLuma - minimumLuma < 12) ||
    (nearBlackRatio >= 0.975 && nonBlack <= sparseOverlayFloor)

We also find Flutter by its typed FlutterView hierarchy. Matching a class-name string worked in debug builds and failed after Android minification changed the name. That was a small bug with a very convincing local test result.

03 // RETAINED LAYERS

The fallback asks Dart for pixels

Once the native recorder recognizes a black Flutter surface, the method channel runs in the other direction. Android calls _captureFlutterFrame. Dart reads the root OffsetLayer from Flutter's RenderView and rasterizes that retained layer with toImage().

A request can arrive while Flutter is building a frame. During transient callbacks, mid-frame microtasks, or persistent callbacks, the capture waits for endOfFrame. It then calculates a pixel ratio for the native target dimensions, requests raw RGBA bytes, copies those bytes, and disposes the engine image. The copy matters because an engine may back ByteData with image-owned storage.

Retained Flutter scene readback
final phase = SchedulerBinding.instance.schedulerPhase;
if (phase == SchedulerPhase.transientCallbacks ||
    phase == SchedulerPhase.midFrameMicrotasks ||
    phase == SchedulerPhase.persistentCallbacks) {
  await SchedulerBinding.instance.endOfFrame;
}

final layer = renderView.layer as OffsetLayer;
final image = await layer.toImage(bounds, pixelRatio: scale);
final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba);

Android turns the returned bytes into a bitmap, composites any native sheet roots, applies redaction, and sends JPEG work to its single-thread encode executor. The retained-layer request uses half the usual replay width and height. That cuts the readback to one quarter of the pixels on the devices already struggling with the normal path.

Compatibility capture also runs less often. Its idle heartbeat is 15 seconds. A high-importance visual change can request a frame after a 5-second minimum interval, and navigation waits 2.5 seconds for the destination to settle. An in-flight guard drops overlapping ticks. A five-second timeout releases the guard if Dart never answers. The app's live FlutterSurfaceView stays in place throughout.

The active source is visible through SDK metrics. lastCaptureSource, flutterBlackFrameFallbackCount, and retained-layer readback timings tell us whether a report came from the fast surface-copy path or the compatibility path. Without those counters, a replay can look fixed while a device quietly pays far more for every frame.

04 // PRIVACY MASKS

A mask has to follow time as well as space

RejourneyMask is a RenderProxyBox. It does not cover the widget in the live app. Before painting its child, it measures the child's global bounds and sends that rectangle to the native capture pipeline. Android converts Flutter logical pixels with the activity density; iOS can use the logical point coordinates directly.

Reporting the latest rectangle sounds sufficient until the widget scrolls or an outgoing route animates away. Flutter's transform callbacks and the native pixel copy are asynchronous. A captured frame may contain the prior position after Dart has already reported the new one.

While a masked widget is moving, the SDK sends the union of its previous and current rectangles. After movement settles, the mask shrinks to the current bounds. The first placement uses a shorter 750 ms safety window; later movement uses two seconds. When the render object detaches, its last rectangle stays registered for another two seconds because Flutter may still present cached pixels from the outgoing route.

Conservative movement mask
final safeRect = _reportedRect?.expandToInclude(rect) ?? rect;
if (safeRect != _reportedRect) {
  _reportedRect = safeRect;
  _sendRegion(safeRect);
}

_settleTimer = Timer(safetyWindow, () {
  _reportedRect = rect;
  _sendRegion(rect);
});

This choice occasionally leaves a stale white rectangle on the replay for a moment. We prefer that artifact to one frame of a card number or private message. Tests move a masked widget between paints and assert that the swept area remains covered.

05 // NAVIGATION

Route tracking waits for the screen it names

RejourneyNavigatorObserver handles push, pop, replace, and remove. It resolves a name from an app callback, the route settings, or the route's runtime type, then drops consecutive duplicates. The screen event is recorded immediately.

The matching visual marker is delayed until the next post-frame callback. Without that delay, a route event named /checkout can point at the final frame of /cart. On Android's retained-layer path, native code adds the settle delay and coalesces rapid requests. A navigation callback becomes a request for the first useful frame of the destination, not a command to read pixels at that exact instant.

Flutter's Router ecosystem has more than one source of truth, so the observer is optional. Apps using a Router package can call trackScreen() from their own navigation callback and still get the same native session timeline.

06 // ERRORS

Error capture should not steal the app's handlers

Flutter framework failures arrive through FlutterError.onError. Uncaught root-isolate errors arrive through PlatformDispatcher.instance.onError. The SDK saves both existing handlers before installing its wrappers and calls them after recording the incident. Disposing the capture handle restores the originals.

We do not put runApp() in a new zone during normal installation. Flutter expects binding initialization and runApp() to share a zone, and hiding a zone change inside an SDK is a good way to produce warnings in a host app. Teams that already use runZonedGuarded can keep doing so; the package exposes a guarded helper for explicit use.

Every Dart incident gets a timestamped, cryptographically random ID before it crosses the channel. The ID survives both native bridges. Crash and ANR recovery paths use the same incident-ID scheme, so the backend can merge duplicate transports without guessing from stack text. Release-obfuscated Dart types add another wrinkle: names such as _A are not useful categories, so the SDK falls back to an Error or Exception name found in the message.

07 // NETWORK AND STOP

Measure the response the caller actually consumes

Dart networking does not pass through URLSession or OkHttp in a way a native interceptor can always see. RejourneyHttpClient wraps package:http instead. It records method, URL, status, content type, byte counts, and timing. Request and response bodies are never recorded.

A streamed response is not finished when headers arrive. The wrapper counts chunks as the caller consumes them and emits its event on stream completion or error. That makes duration and response size describe the transfer the app experienced. The SDK host and configured ignore patterns are filtered before the event crosses the channel, which also prevents Rejourney from observing its own uploads.

Shutdown has a separate bound. stop() gives the native recorder 10 seconds by default to flush and finalize. If that deadline expires, Dart returns a successful local stop with uploadSuccess: false and the warning native_flush_timeout. The native call keeps running, and its persistence path can save pending work. An offline analytics SDK should not turn a sign-out or consent change into a frozen screen.

08 // OPEN BETA

What we want to learn in beta

The recorder and ingest format use the same native foundations as Rejourney's other mobile SDKs. The open-beta label is about the Flutter integration surface and the range of apps that sit on top of it. We are looking for signal in these areas:

  • Renderer and device combinations outside our Android emulator and device matrix.
  • Impeller, Flame, platform views, and custom rendering during animated transitions.
  • Router packages whose navigation model does not map cleanly to a root NavigatorObserver.
  • Add-to-app and multi-engine lifecycles where the active Android activity changes ownership.
  • Mask timing around complex slivers, overlays, and nested navigators.

A Flutter release is tagged only after Dart tests and benchmarks, Android native tests, emulator integration, an iOS simulator run, a standalone consumer build, and a pub publish --dry-run pass. CI checks the version in the pubspec, Dart API, Android package, CocoaPods spec, and both native runtime constants. The tag then publishes to pub.dev with a short-lived OIDC token.

Install the beta with flutter pub add rejourney. The package includes a runnable example, capture metrics for the Android fallback path, and debug-only crash and ANR hooks for validating a project before release.