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:
- Setting the phone down — one sharp impulse, often over 3 g, with no
direction reversal. Rejected by
minimumShakeCount+requireDirectionReversal. - Scrolling and flicks — the device barely translates. Rejected by threshold.
- 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.
- 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.
pauseWhenBackgroundeddefaults totruefor 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 everystart().- On Android 13+, request
POST_NOTIFICATIONSyourself 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.
startCaptureshows a system consent alert, generally once per app launch.- ReplayKit does not exist on the Simulator.
capabilities.supportedwill befalse; 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()reportssupported: falserather 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.