Flutter SDK
The Dart package heardfix adds a report form, shake to report, context and diagnostics capture, and an offline queue to a Flutter app. Read the concepts first: app key, endpoint, App Check, kinds and offline queue are explained there. A runnable app is in flutter/example.
Requirements
- Flutter 3.41 or later, Dart 3.11 or later.
- iOS or Android. Flutter web is not supported yet.
- Your app in the Heardfix back office: its page gives the key and the initialisation to copy.
- Firebase in your app, with App Check: see Authentication. Without Firebase, enable the key only method on the app page instead: skip App Check and
appCheckTokenbelow.
Install
The package is not on pub.dev yet. Add it from its git repository in pubspec.yaml:
dependencies:
heardfix:
git:
url: https://git.ownweb.fr/ownweb/ownweb-opensource/heardfix.git
path: flutter
Add App Check if your app does not use it yet. Skip it with the key only method:
flutter pub add firebase_core firebase_app_check
Continuous integration
The repository is not public yet: flutter pub get needs read access to it. Ask OwnWeb for a deploy token with the read_repository scope, store its username and token as secret variables of your CI (HEARDFIX_GIT_USERNAME, HEARDFIX_GIT_TOKEN), and run this before flutter pub get:
git config --global \
url."https://${HEARDFIX_GIT_USERNAME}:${HEARDFIX_GIT_TOKEN}@git.ownweb.fr/ownweb/ownweb-opensource/heardfix.git".insteadOf \
"https://git.ownweb.fr/ownweb/ownweb-opensource/heardfix.git"
The committed pubspec.yaml keeps the plain URL: the token never goes into the repository.
Initialise
In main, before runApp: Firebase, App Check, your crash reporters, then Heardfix. Copy the Heardfix.init call from the app page of the back office: it holds your key.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await FirebaseAppCheck.instance.activate(
providerAndroid: kDebugMode ? const AndroidDebugProvider() : const AndroidPlayIntegrityProvider(),
providerApple: kDebugMode ? const AppleDebugProvider() : const AppleAppAttestProvider(),
);
await Heardfix.init(
appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE',
appCheckToken: FirebaseAppCheck.instance.getToken,
);
runApp(const BreathingApp());
}
That is all the app wraps: no widget around the app, no navigator key. The debug provider lets debug builds send reports from a simulator: register its token as Develop against the back office shows.
With the key only method, the app key is enough: no Firebase, no appCheckToken.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Heardfix.init(appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE');
runApp(const SkiApp());
}
| Parameter | Role |
|---|---|
appKey |
Key of the app, sent as X-Heardfix-App. Public, like a Sentry DSN: commit it. null or empty turns Heardfix off, see Turn it off |
appCheckToken |
Gives a fresh App Check token before each upload: pass FirebaseAppCheck.instance.getToken without calling it. Left out with the key only method |
endpoint |
Address of a back office of your own. Omitted: the back office hosted by OwnWeb |
userEmail |
Prefills the email field from the signed in account. The user can change or clear it. Optional |
crashedPreviousRun, onInstallationId |
Crashlytics correlation, see Sentry and Crashlytics correlation. Optional |
requestHeaders |
Other headers to send, for an app that authenticates with a Sanctum token instead of App Check |
translations |
Texts per language code, see Translations |
theme |
Theme of the report form, and of the shake prompt on Android, built from the app theme, see Theme. Omitted: the app theme |
await Heardfix.init(
appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE',
appCheckToken: FirebaseAppCheck.instance.getToken,
userEmail: () => FirebaseAuth.instance.currentUser?.email,
);
init never throws: when something fails while starting, the error goes to FlutterError.reportError, so to your crash reporter, and the app starts without Heardfix. After init, Heardfix.maybeInstance gives the running instance, or null while Heardfix is off. Heardfix.instance throws when it is off: use it only where Heardfix is sure to run.
Turn it off
Heardfix stays off, and init returns null, when:
-
appKeyisnullor empty. Give one key per flavor, andnullwhere a flavor has no app in the back office:await Heardfix.init( appKey: switch (appFlavor) { 'production' => 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE', 'staging' => 'hf_3cR5gH2jE8d2Kq7Lm0pX4vB9sT1wZ6yN', _ => null, }, appCheckToken: FirebaseAppCheck.instance.getToken, );Without flavors, read the key at build time, and leave it out of the builds that must not report:
const heardfixAppKey = String.fromEnvironment('HEARDFIX_APP_KEY'); await Heardfix.init(appKey: heardfixAppKey);flutter build ipa --dart-define=HEARDFIX_APP_KEY=hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jEWithout
--dart-define, the key is empty and Heardfix stays off. A key read from a.envfile at run time works the same way: passnullwhen the file has none. -
endpointis plain HTTP on any other host thanlocalhost,127.0.0.1or10.0.2.2(the Android emulator's address of your computer): reports carry screenshots and logs, they never leave your computer unencrypted.
Off, Heardfix installs no error handler, keeps no queue, opens no prompt on a shake, and HeardfixTiles shows nothing. The debug console says why it is off.
Platform setup
iOS
The gallery opens without asking the user for photo library access. App Store Connect still requires the photo library key of the image_picker package Heardfix uses: keep it in ios/Runner/Info.plist, or add it.
<key>NSPhotoLibraryUsageDescription</key>
<string>To attach photos to a problem you report.</string>
Heardfix never opens the camera or the microphone. image_picker also lists NSCameraUsageDescription: add it only if App Store Connect asks for it.
Shake to report needs no permission: it listens to the system shake gesture on iOS, and reads the accelerometer on Android.
For App Check, add the App Attest capability to the Runner target in Xcode (Signing and Capabilities). Do not call FirebaseApp.configure() in AppDelegate: the App Check provider chosen in Dart would be ignored on real devices. Firebase.initializeApp() in Dart is enough.
Android
Reports go through the network: android/app/src/main/AndroidManifest.xml needs the internet permission. New Flutter projects only declare it for debug and profile builds. An app that already uses Firebase has it.
<uses-permission android:name="android.permission.INTERNET" />
image_picker follows the Android Gradle plugin versions of current Flutter projects: if the build asks for a newer one, raise com.android.application in android/settings.gradle.kts.
For Play Integrity, add the SHA-256 fingerprint of your app signing key to your Android app in the Firebase project settings.
Entry points
Place HeardfixTiles in the profile or settings screen:
ListView(
children: const [
// ...the other tiles of the screen
HeardfixTiles(),
],
);
It shows "Report a problem", "Suggest an improvement" and the shake to report switch, in the app language, with the ListTile theme of the app. It shows nothing while Heardfix is off.
Each entry calls openReportForm, which captures the current screen, then opens the form in the root navigator, above tab bars. kind defaults to ReportKind.bug. The form title, the description label, the email hint and the messages after sending follow the kind.
The form has a title (3 to 120 characters), a description (up to 5000 characters), the email (optional) and up to 3 photos: from the gallery, or the screenshot of the screen the user was on ("Add the screenshot"). Tapping a photo shows it in full, where the user removes it or edits it: crop, hide an area under large blocks of one color, draw, add text, erase. A hidden area moves when dragged, and resizes by its corners and edges. Only the edited photo is sent, as a PNG under 10 MB. The original stays in memory until the form closes, so the retouches can be changed again; it is never written to disk nor sent. An "Attached information" block shows the user what is sent with the report.
Shaking the phone is the other way in: see Shake to report.
Entries of your own
When the settings screen of your app has its own row design, build the same three entries with your rows, as HeardfixTiles does. stringsFor gives the texts of Heardfix in the app language: "Report a problem", "Suggest an improvement", and the title and description of the shake switch.
final heardfix = Heardfix.maybeInstance;
if (heardfix == null) return const SizedBox.shrink();
final strings = heardfix.stringsFor(Localizations.localeOf(context));
return Column(
children: [
SettingsRow(
title: strings.bugForm.formTitle,
onTap: () => heardfix.openReportForm(context),
),
SettingsRow(
title: strings.featureForm.formTitle,
onTap: () => heardfix.openReportForm(context, kind: ReportKind.feature),
),
ValueListenableBuilder<bool>(
valueListenable: heardfix.shake.enabled,
builder: (context, enabled, _) => SettingsSwitchRow(
title: strings.shakeSettingTitle,
subtitle: strings.shakeSettingDescription,
value: enabled,
onChanged: (value) => heardfix.shake.setEnabled(value: value),
),
),
],
);
SettingsRow and SettingsSwitchRow stand for the rows of your app. Show nothing while Heardfix.maybeInstance is null: Heardfix is off. Keep the shake switch, see Accessibility.
Verify
- Run a debug build with the App Check debug provider, its token registered in the Firebase console. The console must not say "Heardfix is off".
- Tap "Report a problem", type a title and a description, and send.
- The form says "Thank you, your report has been sent."
- In the back office, the report shows under Heardfix, Reports. The members of your organization receive an alert email.
Any other message: see Troubleshooting.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The form says "No connection" while the phone is online, and the report never arrives | The back office answered 401 or 403: App Check refused |
Register the debug token in the Firebase console. Check the Firebase project number on the app page. Activate App Check before Heardfix.init. On iOS, remove FirebaseApp.configure() from AppDelegate |
| With the key only method, the form says "No connection" while the phone is online, and the report never arrives | The key was regenerated: the back office answers 401 to the old one. The SDK keeps the reports |
Copy the new key from the app page into Heardfix.init and publish an update: the queued reports leave with it |
| With the key only method, reports stop arriving until the next day | The app went beyond its daily report limit: 429 until midnight |
Wait: the SDK sends them after midnight. If your users send more every day, ask OwnWeb to raise the limit |
The form says the report could not be sent, and the Heardfix log holds a 404 warning |
The back office does not know the app key, or the endpoint is wrong. The SDK dropped the report | Check the key and the endpoint against the app page |
The form says the report could not be sent, and the Heardfix log holds a 413 warning |
The request or an image is larger than the back office accepts (10 MB per image, see the API contract). The SDK resizes gallery photos well under this limit, but sends the screenshot at full resolution. The SDK dropped the report | Send the report again without the screenshot. If it still fails, the back office accepts less than the contract requires: send the warning to whoever runs it |
The form says the report could not be sent, and the Heardfix log holds a 422 warning |
The back office refused a field of the report. The SDK dropped the report | Check that userEmail returns a valid email or null. Otherwise, update the SDK to the version of the support matrix |
| A report stays in the queue and never reaches the back office | No network, or the back office answers 5xx, 401, 403 or 429: the SDK retries with a growing delay. A queue of 10 reports drops the oldest, and a report older than 7 days is dropped |
Check the network and the rows above. The Heardfix log holds a warning for each dropped report. See Offline queue |
| The form says "Too many reports; contact the developer of the site." | The organization reached the reception ceiling of its plan: 402 on Free, the SDK dropped the report; 429 on a paid plan, the SDK sends it again once a place frees up, or drops it when that is more than 7 days away |
An admin of the organization changes the plan on the Plan page of the back office: the ceiling rises at once |
| Reports stop arriving while you test, then come back an hour later | More than 5 reports in an hour from one installation: 429 |
Wait: the SDK sends them after the delay |
StateError: Heardfix is not started |
Heardfix.instance used while Heardfix is off, or before init completed |
Await Heardfix.init before runApp. Where Heardfix may be off, use Heardfix.maybeInstance |
HeardfixTiles shows nothing |
Heardfix is off: the debug console says why (no app key, plain HTTP endpoint), or init was not awaited before runApp |
See Turn it off |
flutter pub get fails in CI on the heardfix repository |
The CI has no access to the repository, which is not public yet | Add the deploy token, see Continuous integration |
| Shaking does nothing | The user turned it off, "No thanks" paused it for 10 minutes, or a HeardfixShakeBlocker covers the screen |
In the iOS simulator, use Device, Shake |
App Store and Play Store
Both stores ask what data the app collects. Heardfix sends exactly what the API contract lists, over HTTPS, only when the user sends a report. Add it to your own declarations. The categories below are suggestions: the answers are yours, and your data protection officer validates them.
| Data sent | App Store (privacy details) | Play Store (data safety) |
|---|---|---|
| Email, when the user gives it | Contact info, email address | Personal info, email address |
| Title and description | User content, other user content | App activity, other user generated content |
| Photos and screenshots the user attaches | User content, photos or videos | Photos and videos, photos |
| Logs and caught errors | Diagnostics, crash data and other diagnostic data | App info and performance, crash logs and diagnostics |
| App version and build, platform, OS, device model, locale, battery, memory, storage, install, update and start dates, report kind, how the form was opened (button or shake), send time, whether the app crashed on its previous run | Diagnostics, other diagnostic data | App info and performance, other app performance data |
| Installation id, Sentry event ids | Identifiers, device ID | Device or other IDs |
- Health data: the title, the description and the photos can reveal a health condition. The categories above do not say so: check with your data protection officer whether your declarations must.
- Purpose: app functionality (support). No tracking, no advertising.
- Linked to the user: yes. The installation id links the reports of one installation, and the email, when given, links them to the person.
- Optional: the user decides to send a report, and the email is optional.
- Deletion: a user who gave an email can ask to have their reports erased: the back office erases every report of this email on request. Reports sent without an email cannot be erased on request. Every report is purged 13 months after its last activity. See data protection.
Reference
Logger
The plugin keeps the last 200 log lines in memory, in a ring buffer. Feed it from your app:
final logger = Heardfix.maybeInstance?.logger;
logger?.info('Session started');
logger?.warning('Audio focus lost, retrying');
logger?.error('Audio player failed');
Levels: debug, info, warning, error. The 201st line pushes out the oldest one. Logs are only sent as part of a report, never on their own.
A message longer than 2000 characters is cut before sending. The texts of the diagnostics never make the back office refuse a report; their structure (200 entries at most, known levels, ISO 8601 dates) is always valid as the plugin builds it.
Never log personal or health data: no email, no name, no measurement, no free text typed by the user. Log events and technical states instead (Session started, Audio focus denied).
Screens
HeardfixNavigatorObserver writes the name of each screen the user reaches to the log (Route /history). Add it to the observers of the navigator:
MaterialApp(navigatorObservers: [HeardfixNavigatorObserver()], ...);
GoRouter(observers: [HeardfixNavigatorObserver()], ...);
Routes without a name, like dialogs and the report form, are skipped. With go_router, the name is the route name, or else its path pattern relative to its parent route (:id under /sessions), never the values of the path: give each GoRoute a name for a readable log. A ShellRoute has its own navigator: add an observer to its observers too. With named routes of your own, never put an email, a name or a measurement in the route name.
Errors
init wraps FlutterError.onError and PlatformDispatcher.instance.onError: uncaught errors are recorded with their stack traces, then forwarded to the handlers already installed (Crashlytics, Sentry). Install those first.
Record an error your app caught itself:
try {
await player.play();
} catch (error, stackTrace) {
Heardfix.maybeInstance?.recordError(error, stackTrace);
}
A report carries the last 20 errors. Error messages are cut at 2000 characters, stack traces at 20000.
Sentry and Crashlytics correlation
The plugin generates an installation id (UUID v4) once per install. Give it to Sentry and Crashlytics, so a triager can find the user's events from a report.
Order in main: crash reporters first, then Heardfix.init: it forwards errors to the handlers already installed.
With Crashlytics, pass its methods without calling them:
await Heardfix.init(
appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE',
appCheckToken: FirebaseAppCheck.instance.getToken,
crashedPreviousRun: FirebaseCrashlytics.instance.didCrashOnPreviousExecution,
onInstallationId: FirebaseCrashlytics.instance.setUserIdentifier,
);
With Sentry, record the id of each event, and give the installation id to Sentry:
await SentryFlutter.init((options) {
options.dsn = sentryDsn;
options.beforeSend = (event, hint) {
Heardfix.maybeInstance?.recordSentryEventId(event.eventId.toString());
return event;
};
});
await Heardfix.init(
appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE',
appCheckToken: FirebaseAppCheck.instance.getToken,
onInstallationId: (installationId) =>
Sentry.configureScope((scope) => scope.setUser(SentryUser(id: installationId))),
);
A failure of crashedPreviousRun or onInstallationId does not stop Heardfix: the error goes to FlutterError.reportError, and the flag is sent as unknown.
beforeSend uses Heardfix.maybeInstance: it runs for every event, including the ones sent before Heardfix.init completes, which Heardfix then does not record.
recordSentryEventIdkeeps the ids of the last 20 Sentry events of the session. The back office shows them on the report, with the Sentry issue of each event once the organization and the app are linked to Sentry (see laravel.md). A dashed or uppercase id is stored as 32 lowercase hexadecimal characters; any other string is ignored.- The installation id is pseudonymous personal data: set only the
idof the Sentry user, no email, no username, no IP address. See data-protection.md for the full allowlist.
Shake to report
Shaking the phone captures the current screen, then asks "A problem or an idea?" in the system alert of the platform, which tells the user how to turn shake to report off. "Report a problem" or "Suggest an improvement" opens the matching form with the screenshot already among the photos; the user can remove it. "No thanks", or closing the dialog, pauses shake detection for 10 minutes.
Detection uses the shake_gesture package (MIT): the system shake gesture on iOS, the same as shake to undo, and a sustained shake on Android, so walking or running does not trigger it. The report form itself blocks shake detection while it is open.
Disable it on some screens
Disable shake detection where the user moves the phone on purpose, or where a prompt would interrupt them. A breathing app disables it during a session:
@override
Widget build(BuildContext context) {
return const HeardfixShakeBlocker(
child: BreathingSessionView(),
);
}
Detection resumes when the last blocker leaves the tree.
Accessibility
Shaking can be hard or impossible for some users, and tremors can trigger it. Two rules follow (WCAG 2.5.4, Motion Actuation):
- The report button stays available in the app. Shake is a shortcut, never the only way.
- A user setting turns shake detection off.
HeardfixTilesshows it. With entries of your own, expose it in your settings screen, where Heardfix runs:
final shake = Heardfix.instance.shake;
ValueListenableBuilder<bool>(
valueListenable: shake.enabled,
builder: (context, enabled, _) => SwitchListTile(
title: const Text('Shake to report a problem'),
value: enabled,
onChanged: (value) => shake.setEnabled(value: value),
),
);
The setting is on by default, stored on the device and survives restarts.
Offline queue
The plugin follows the offline rules. On Flutter:
- Storage: one folder per report in the app's private support directory (
heardfix/queue), holding the JSON payload and the photos. The OS encrypts it at rest. Shared preferences are never used for reports. - At most 10 reports. A report waiting for more than 7 days is dropped. Beyond 10, the oldest ones are dropped.
- The queue is flushed at
init, when the app comes back to the foreground, and at the next scheduled retry. Atinit, every queued report is attempted right away, without waiting for its backoff, except a report answered429, which waits until its delay has passed. A429delay survives an app restart. - Backoff starts at 30 seconds and doubles at each failed attempt, up to 30 minutes: 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, then every 30 min.
Translations
French and English ship. The language follows the app locale, as Localizations.localeOf gives it; any other language falls back to English. An app that translates outside Flutter's localizations, with flutter_translate or easy_localization, and declares one locale in supportedLocales, shows Heardfix in that language only.
Add or replace a language with a full HeardfixStrings, keyed by language code:
await Heardfix.init(
// ...
translations: {
'de': const HeardfixStrings(
bugForm: HeardfixFormStrings(formTitle: 'Problem melden', /* ... */),
featureForm: HeardfixFormStrings(formTitle: 'Verbesserung vorschlagen', /* ... */),
// ... every other text
),
},
);
HeardfixStrings.en and HeardfixStrings.fr list every text to provide, the texts of HeardfixTiles (shakeSettingTitle, shakeSettingDescription) and of the shake prompt (shakePromptMessage) included. The texts that depend on the kind (form title, description label and error, email hint, messages after sending) live in bugForm and featureForm.
Theme
The report form uses Material widgets and takes the look of the app theme. The shake prompt is the system alert of the platform: on Android it follows the theme too, on iOS it is the Cupertino alert, which keeps the iOS look and ignores dialogTheme and the button themes. When the app theme does not suit them, pass theme. It receives the app theme and returns the theme of the Heardfix screens, so the app screens keep theirs:
await Heardfix.init(
// ...
theme: (appTheme) => appTheme.copyWith(
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white,
labelStyle: TextStyle(color: Colors.black87),
),
dialogTheme: const DialogThemeData(backgroundColor: Colors.white),
extensions: [
...appTheme.extensions.values,
HeardfixStyles(
photosTitle: appTheme.textTheme.titleMedium?.copyWith(color: Colors.black87),
),
],
),
);
The fields, buttons, app bar and the Android dialog follow the component themes of this theme (inputDecorationTheme, filledButtonTheme, outlinedButtonTheme, textButtonTheme, appBarTheme, dialogTheme, expansionTileTheme for the folding sections, actionIconTheme for the back arrow), as described in the ThemeData documentation.
The photo viewer and editor keep their own dark look and take only the primary color and the platform from this theme.
The texts Heardfix styles itself are set through the HeardfixStyles extension. A style left null falls back on a role of the text theme:
| Style | Text | Fallback |
|---|---|---|
photosTitle |
Title of the photos | bodyLarge |
infoLabel |
Label of each attached information | labelMedium |
infoValue |
Value of each attached information | bodyMedium |
log |
Technical log | bodySmall, in a monospace font |
The message shown after sending opens in the app, outside the Heardfix theme: it takes snackBarTheme.backgroundColor and snackBarTheme.contentTextStyle from the Heardfix theme, the rest from the app theme.
Test your app
In widget tests, leave Heardfix off: without Heardfix.init, HeardfixTiles shows nothing and Heardfix.maybeInstance is null, so your screens build as in an app without Heardfix.
To test a screen with Heardfix running, stand in for the plugins it starts with, restore the error handlers init wraps, and stop it after each test:
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:heardfix/heardfix.dart';
import 'package:shared_preferences/shared_preferences.dart';
late FlutterExceptionHandler? flutterErrorHandler;
late ErrorCallback? platformErrorHandler;
setUp(() async {
flutterErrorHandler = FlutterError.onError;
platformErrorHandler = PlatformDispatcher.instance.onError;
SharedPreferences.setMockInitialValues({});
final supportDirectory = Directory.systemTemp.createTempSync('heardfix_');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(_) async => supportDirectory.path,
);
await Heardfix.init(appKey: 'hf_8d2Kq7Lm0pX4vB9sT1wZ6yN3cR5gH2jE');
});
tearDown(() {
Heardfix.resetForTesting();
FlutterError.onError = flutterErrorHandler;
PlatformDispatcher.instance.onError = platformErrorHandler;
});
shared_preferences comes with Heardfix: add it to your dev_dependencies if the depend_on_referenced_packages lint asks for it.
Develop the plugin locally
To work on the plugin from a local checkout or a git worktree, create a pubspec_overrides.yaml next to the app's pubspec.yaml, and add it to .gitignore:
dependency_overrides:
heardfix:
path: ../heardfix/flutter
The committed pubspec.yaml keeps the git dependency: no local path is ever committed.