initialize static method
void
initialize()
Registers the Dart masking handler so the native SDK can request
pre-masked frames. Call once at app startup (e.g. in main()).
Masking BEHAVIOUR — maskAllTexts / maskAllImages / textsToMask — is
configured on CXSessionReplayOptions and applied automatically by
CxFlutterPlugin.initializeSessionReplay. You do NOT set masking here;
CXSessionReplayOptions is the single source of truth.
Implementation
static void initialize() {
_installFrameTracker();
methodChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'getMaskRegions':
final ids = (call.arguments as List).cast<String>();
return _getMaskRegions(ids);
case 'captureMaskedFlutterView':
// Take no frame while a route transition animates. The rasterised
// bitmap (committed layer) and the mask rects (live render tree) can
// momentarily describe different routes mid-navigation — image of one
// screen, masks of another — producing misaligned masks. Mirrors the
// iOS native isNavigationTransitionActive() skip. The destination is
// captured once the transition settles.
if (_isNavigationTransitionActive()) return null;
// Capture ONLY when the UI is fully settled. While anything is
// animating (scroll, fling, transition) the committed layer and the
// live render tree can describe different scroll/layout states —
// capturing then bakes masks at the wrong offset (text rendered
// un-masked). Answer null instead and let the next capture proceed.
//
// No tap exception is needed any more: native takes the tap frame at
// finger-down, before the ripple starts and before Dart's own gesture
// callbacks run, so the settled frame this sees IS the frame the user
// tapped on.
if (_shouldSkip(
_captureInFlight, _sinceLastFrame.elapsedMilliseconds)) {
return null;
}
_captureInFlight++;
try {
// Stashes the exported mask geometry on the side: the collector
// runs exactly once per capture, inside the same synchronous
// slice as the rasterisation kickoff (see
// FrameSource.walkMaskRects), so the geometry reported alongside
// the bytes comes from the SAME walk the compositor used — rects
// and pixels can never describe different frames. The two rect
// sets differ on purpose: the compositor blacks out the
// content-granular rects, while the exported geometry is expanded
// to the enclosing control for tap-marker suppression (see
// collectCaptureMaskRectsSync).
var exportedLogicalRects = const <Rect>[];
final source = FlutterFrameSource.forImplicitView(
maskCollector: () {
final collected =
SessionReplayMasking.collectCaptureMaskRectsSync();
exportedLogicalRects = collected.exported;
return collected.content;
},
captureScale: _captureScale,
);
if (source == null) return null;
final frame = await _captureFrameSynced(source);
final masked = await _compositeMasks(frame.image, frame.rects);
try {
// Debug-only: dump the exact Dart-masked frame to disk so the
// masking can be validated locally (mirrors the iOS native
// Documents/SessionReplay debug save).
await _saveMaskedFrameForDebug(masked);
final byteData =
await masked.toByteData(format: ui.ImageByteFormat.rawRgba);
if (byteData == null) return null;
// Same ratio the compositor used, not PlatformDispatcher's:
// an app overriding createViewConfiguration makes those differ.
final dpr = source.devicePixelRatio;
return MaskedFrameBytes(
bytes: byteData.buffer.asUint8List(),
width: masked.width,
height: masked.height,
maskRects: MaskedFrameBytes.hostMaskRects(
exportedLogicalRects,
isIOS: Platform.isIOS,
devicePixelRatio: dpr,
),
).toChannelMap();
} finally {
frame.image.dispose();
masked.dispose();
}
} catch (_) {
// Capture failed, timed out (e.g. the engine never committed a
// frame while backgrounded), or was rejected by the post-frame
// settle guard. Answer null; the finally releases the in-flight
// guard so capture self-heals next tick.
return null;
} finally {
_captureInFlight--;
}
default:
throw PlatformException(
code: 'unimplemented',
message: 'Method ${call.method} not implemented',
);
}
});
}