bug_recorder

Rolling-buffer screen recording for Flutter bug reports.

A normal shake-to-record package starts recording after the tester notices the bug. By then the steps that caused it are gone, and the tester usually cannot remember them. This package keeps the last N seconds of screen activity in a circular buffer at all times, so a shake preserves what already happened:

T-20s ──────────────────────────────────────────────── T=0 ──────────── T+10s
  tester taps, types, navigates, hits the bug          shake          aftermath
  └───────────── recovered from the buffer ──────────┘ └── recorded after ──┘
                              one merged MP4

Implemented with a real rolling buffer, not a post-hoc recording: a permanent hardware H.264 encoder writes into short, keyframe-aligned MP4 segments, old segments are deleted as the window slides, and a trigger remuxes the relevant ones into a single file. ~6 MB of disk for a 20-second window. No decode, no re-encode, no quality loss.


What each platform can actually do

Read this table before designing anything around the package. The differences are structural, not gaps in the implementation.

Android iOS
Rolling buffer of the last N seconds
Recover the seconds before a shake
Post-trigger recording + merge
Captures other apps / system UI ✅ whole display ❌ your app only
Keeps buffering while backgrounded ✅ (foreground svc) ❌ ReplayKit suspends
Hide content from the video only FLAG_SECURE ❌ pause, leaving a gap
Consent prompt every session (14+) once per app launch
Simulator / emulator emulator ✅ Simulator ❌
Minimum version API 24 (7.0) iOS 13

The short version:

Android gives a system-wide buffer that survives backgrounding. iOS gives an app-scoped, foreground-only buffer.

Both deliver "the 20 seconds before the shake". These are queryable at runtime, so you can adapt your UI instead of promising the tester something the OS will not give:

final caps = await BugRecorder.instance.initialize();
if (!caps.supported) show(caps.unsupportedReason!);
if (!caps.supportsInvisibleRedaction) warnTesterAboutGaps();

Full analysis: doc/FEASIBILITY.md. Design rationale and the rolling-buffer comparison: doc/ARCHITECTURE.md.


Quick start

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await BugRecorder.instance.initialize(
    config: const BugRecorderConfig(
      bufferDuration: Duration(seconds: 20),
      postTriggerDuration: Duration(seconds: 10),
    ),
  );

  // Before start(), so a crashed previous run is still recoverable.
  final crashed = await BugRecorder.instance.recoverPreviousSession();
  if (crashed != null) queueForUpload(crashed.file);

  await BugRecorder.instance.start();   // shows the system consent prompt

  runApp(const MyApp());
}

Then wire up the result. Shake triggering is on by default:

BugRecorderScope(
  onRecording: (result) => showBugReportSheet(result.file),
  child: const MyApp(),
)

Or await a manual trigger. The future resolves after the post-roll:

final recording = await BugRecorder.instance.capture();
print('${recording.preRoll} before + ${recording.postRoll} after');

API

Member Purpose
initialize({config}) Validates config, probes the device. Returns PlatformCapabilities. Never throws on an unsupported platform.
start() Requests consent and starts buffering. Returns false if the user declined — not an exception.
stop() / pause() / resume() pause keeps buffered segments and the consent token; stop discards both.
capture({reason}) Freezes the buffer, records the post-roll, merges. Resolves with a RecordingResult.
recoverPreviousSession() Merges segments left by a run that crashed. Returns null after a clean exit.
setRedacted(bool) / redactWhile(action) Hide sensitive UI. See Privacy.
state ValueListenable<RecorderState> — use with ValueListenableBuilder.
events Stream<BugRecorderEvent> — a sealed hierarchy, so switch is exhaustive.
recordings Stream<RecordingResult> — just the finished files.
bufferStats() Current occupancy: duration, segment count, bytes, evictions.
clearCache() Deletes buffered segments and stored recordings. Returns bytes freed.
dispose() Releases everything.

Widgets: BugRecorderScope, BugRecorderStateBuilder, PrivacyShield.

BugRecorder.instance is the shared recorder. The class is also directly constructible with an injected platform, which is what makes it testable — see Testing.

Events

recorder.events.listen((event) {
  switch (event) {
    case CaptureTriggered():   showCountdown();
    case CaptureProgress(:final fraction): updateRing(fraction);
    case CaptureCompleted(:final result): showSheet(result);
    case CaptureFailed(:final error):    reportError(error);
    case RecorderWarning(:final code):   logger.warn(code);
    case RecorderInterrupted():          offerRestart();
    case ShakeDetected(:final peakMagnitudeG): tuneThreshold(peakMagnitudeG);
    case _: break;
  }
});

recorder.events.whereType<CaptureCompleted>() is provided for filtering.

Errors

Everything thrown descends from the sealed BugRecorderException, so failure handling can be exhaustive:

RecorderUnsupportedException, RecorderPermissionDeniedException, RecorderNotInitializedException, RecorderStateException, RecorderCaptureException, RecorderInterruptedException, RecorderPlatformException.


Configuration

const BugRecorderConfig(
  bufferDuration      : Duration(seconds: 20),  // history to keep
  postTriggerDuration : Duration(seconds: 10),  // record after the trigger
  segmentDuration     : Duration(seconds: 2),   // see below
  maxDimension        : 720,                    // longest edge
  frameRate           : 24,                     // Android: exact on API 30+
  bitrate             : null,                   // null => derived per device
  maxCacheBytes       : 192 * 1024 * 1024,      // hard disk ceiling
  pauseWhenBackgrounded: true,                  // privacy default
  enableShakeTrigger  : true,
  manageLifecycle     : true,
  retainedRecordings  : 5,
  shake               : ShakeConfig(),
)

frameRate is enforced exactly on iOS and on Android API 30+ (via KEY_MAX_FPS_TO_ENCODER). On Android 7-10 it stays a rate-control hint, because a VirtualDisplay pushes frames at the display's refresh rate and decimating them would need an intermediate GL pass that costs more than it saves. Expect up to ~60 fps of encoder work there.

segmentDuration is the knob worth understanding — it is also the encoder's keyframe interval:

  • Shorter (0.5–1 s): less video lost if the app crashes mid-segment. But more keyframes, so a higher bitrate for the same quality.
  • Longer (3–5 s): better compression, fewer files. A crash can lose up to 5 s.

validate() rejects configurations that would silently misbehave — including one where the requested window cannot fit inside maxCacheBytes, which would otherwise deliver a quietly truncated buffer.

Sizing

At the defaults (720p, 24 fps, ~2 Mbps), a 20 s window is ~6 MB and a 30 s recording ~7.5 MB. Compare with keeping raw frames: 20 s at 720p RGBA is 1.77 GB — the ratio that drove the whole design.


Shake detection

Pure Dart over the plugin's own accelerometer channel — no sensors_plus dependency, and you can substitute any Stream<AccelerationSample>.

const ShakeConfig(
  thresholdG              : 2.4,                              // peak, gravity removed
  minimumShakeCount       : 3,                                // reversals required
  window                  : Duration(milliseconds: 900),
  cooldown                : Duration(seconds: 5),
  minimumInterval         : Duration(milliseconds: 70),       // debounce
  requireDirectionReversal: true,
)

The defaults reject the four gestures that actually cause false positives:

  1. Setting the phone down — one sharp impulse, often over 3 g, with no direction reversal. Rejected by minimumShakeCount + requireDirectionReversal.
  2. Scrolling and flicks — the device barely translates. Rejected by threshold.
  3. Walking with the phone in hand — periodic, ~1.2–1.8 g, below threshold. This is why the default is 2.4 g and not 1.5 g.
  4. Rotation — the gravity vector rotates but its magnitude stays at 1 g, and the high-pass removes it entirely. Rejected by construction.

All four are covered by tests in test/shake_detector_test.dart.

Tune with the ShakeDetected event, which reports peak magnitude and whether the gesture was accepted, so a stream full of rejections tells you the threshold is wrong.


Privacy

This package records the screen. Treat it accordingly.

  • Ship it in internal/TestFlight builds. If it must exist in production, gate it behind a staff flag and require per-session opt-in.
  • On Android the capture is system-wide. pauseWhenBackgrounded defaults to true for exactly this reason: with it off, a backgrounded buffer records the user's messages, banking app and password manager. Turn it off deliberately or not at all.
  • Buffers live in the cache directory on both platforms, so the OS can reclaim them and they stay out of iCloud/Google backups.
  • Both platforms provide system-level disclosure — a mandatory notification on Android, a consent alert on iOS. Neither is visible while the tester is being recorded, so add your own indicator (the example app has one).

Hiding sensitive screens

PrivacyShield(
  child: TextField(obscureText: true, /* ... */),
)

// or scoped:
await recorder.redactWhile(() => showPaymentSheet(context));

Android: sets FLAG_SECURE, so MediaProjection renders the window black. Genuinely invisible redaction — the user sees everything, the video does not. It is a window flag, so the whole Flutter surface blanks, and the user's own screenshots are blocked while it is set.

iOS: there is no equivalent. ReplayKit captures the composited layer tree, so anything the user can see is in the recording. PrivacyShield therefore pauses capture on iOS and the video contains a gap. Check capabilities.supportsInvisibleRedaction to know which you are getting.

Flutter's own SensitiveContent widget drives Android's View.setContentSensitivity and is the better mechanism where available — but it needs Android 15+ and does nothing on iOS. PrivacyShield works from API 24 and handles iOS. They compose safely; use both if you want the OS path plus a floor everywhere else.


Lifecycle

Transition What happens to the buffer
Backgrounded Default: paused, current segment finalised, contents retained so the pre-roll survives. Set pauseWhenBackgrounded: false on Android to keep capturing the whole display.
Foregrounded Resumes. The paused interval is collapsed out of the timeline, so "the last 20 seconds" means 20 seconds of activity, not 19 seconds of a frozen frame from before a phone call.
During a capture Never paused — the tester may have backgrounded the app because that is the bug.
Terminated Segments and manifest left on disk for recovery.
Crashed The in-flight segment is unfinalised and unplayable; every earlier one is intact. recoverPreviousSession() merges them.

Crash recovery is the payoff of putting the buffer on disk: a crash still yields the last ~20 seconds, which is arguably the most valuable recording the package can produce. Call it at startup, before start().


Permissions and setup

Android

Nothing to add — the plugin's manifest merges FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PROJECTION, POST_NOTIFICATIONS and the service declaration.

Two things to know:

  • start() shows the system screen-capture dialog. Unavoidable; on Android 14+ the consent token is single-use, so it appears on every start().
  • On Android 13+, request POST_NOTIFICATIONS yourself if you want the capture notification to be visible. The service runs either way.
  • minSdk 24.

iOS

Nothing to add. No Info.plist keys are required: the microphone is explicitly disabled, so iOS never asks for it.

  • startCapture shows a system consent alert, generally once per app launch.
  • ReplayKit does not exist on the Simulator. capabilities.supported will be false; handle it rather than treating it as a bug.
  • iOS 13+.

Testing

No device needed

flutter analyze                                    # package + example
flutter test                                       # 143 tests
cd example/android && ./gradlew :bug_recorder:test  # 22 Kotlin tests

On a device or emulator

Unit tests cannot answer the only question that really matters — is the output file actually the length it claims to be? Two tools are included for that:

# Drives the example app through the real consent flow, triggers a capture,
# pulls the merged MP4 and probes it. --shake injects accelerometer data
# instead of tapping the button (emulator only).
tool/android_e2e.sh --shake

# Or probe any recording you already have. Pure Dart, no ffprobe needed.
dart bin/probe_mp4.dart --expect-duration 30 bug_1787488642773.mp4

probe_mp4.dart reports duration, frame count, keyframe spacing, average frame rate, bitrate and the longest inter-frame gap, and exits non-zero when an --expect assertion fails, so it drops straight into CI.

A note on interpreting it: a low average frame rate is not a bug. Both platforms only produce a frame when pixels change, so a tester reading a static screen legitimately yields a few frames per second. Generate real screen activity before drawing conclusions — tool/android_e2e.sh does this for you.

Verified on a Pixel 8 emulator (Android 17 / API 37): a requested 20 s + 10 s produced a 30.005 s recording at 23.56 fps, with 8 of 32 segments retained. Full results and the iOS gap are in doc/PRODUCTION_REVIEW.md.

Testing your own code against it

The platform boundary is a PlatformInterface, so the whole Dart layer is testable without a device:

final platform = FakePlatform();
final recorder = BugRecorder(platform: platform);
await recorder.initialize();
await recorder.start();

final future = recorder.capture();
platform.emitCompleted(preRollMs: 20000, postRollMs: 10000);
expect((await future).preRoll, const Duration(seconds: 20));

ShakeDetector is a pure function of its input samples, using the sensor's own monotonic timestamps, so gesture tests are synchronous — no fake clock, no Future.delayed, no flakes.

Suite: 143 Dart tests (flutter test) and 22 Kotlin JVM tests covering retention policy and config decoding (cd example/android && ./gradlew :bug_recorder:test).


Not supported

Stated plainly, so nobody discovers these the hard way:

  • No audio track. Mixing audio across rotating segments needs per-segment A/V sync handling that v1 does not attempt.
  • Rotation on Android letterboxes. The encoder's surface size is fixed at configure time, and changing it would emit new SPS/PPS and break remux-based merging.
  • Rotation on iOS shortens the recording. A rotation opens a new segment group; a recording spanning it keeps only the newer part, with a warning in result.warnings.
  • Merging across a background gap keeps the newest group only. Same reason.
  • No macOS, Windows, web, or Linux. initialize() reports supported: false rather than throwing, so a shared codebase still compiles.
  • iOS cannot capture outside your app and cannot buffer in the background. A Broadcast Upload Extension would lift both, at the cost of a ~50 MB memory limit, an App Group, and a user-facing picker.

License

See LICENSE.

Libraries

bug_recorder
Rolling-buffer screen recording for bug reports.
bug_recorder_platform_interface
The platform contract, exported separately so that alternative implementations (or tests) can depend on it without pulling in the widgets.