A feedback screen for Android and Flutter with no server: what testing taught us
In short: an app can send feedback straight to a form backend, so you don't need a server of your own. Our open-source
in-app-feedbacksamples do it in one file per platform: device info attached, an offline queue, and an optional screenshot. The biggest lessons from testing: save the report before you send it, expect Android's force stop to pause your queue, give every report an ID so a retry can't send it twice, decide what to do for each kind of answer, and be honest that an app can't keep its form ID secret.
We make BootForm, a form backend, so these samples send to it. The lessons apply to any backend you'd point an app at.
What we built
A "Send feedback" or "Report a bug" screen for an Android or Flutter app, where each report arrives in a dashboard (and by email, Slack or Discord if you like) with what you need to act on it:
- Device info, attached automatically: app version and build, OS version, device model, locale.
- An offline queue: a report written in airplane mode is kept and sent later.
- An optional screenshot of the screen the user was on, which they can see and leave out.
It's not an SDK. Each platform is one file you copy into your app, plus a small demo app: Kotlin with Jetpack Compose and WorkManager for Android, and Dart for Flutter. The whole API is one HTTP request:
POST https://f.bootform.com/{your-form-id}
Content-Type: multipart/form-data
Accept: application/jsonWe tested both demo apps on an Android emulator (API 36) against the live API, then tried to break them. This is what we learned.
1. Save first, send second
The obvious design sends the report when the user presses Send. That's the wrong moment to depend on the network: people write feedback when something's going wrong, and a bad connection is often part of it.
So both samples save the report on the device first, then try to send it. The button never waits on the network, and the screen says what actually happened. On Android:
Saved. It will be sent as soon as you're online, even if you close the app.
and only "Sent. Thank you!" once the server has answered. Telling a user "Sent" while the report is sitting in a queue is a small lie that turns into a real one if the queue is lost.
2. Android: WorkManager survives the system, not a force stop
On Android the queue is a WorkManager job with a network constraint and exponential backoff, starting at 30 seconds:
val request = OneTimeWorkRequestBuilder<FeedbackWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.setInputData(data.build())
.build()
WorkManager.getInstance(context).enqueue(request)We wrote a report in airplane mode and then tried the ways an app gets closed:
- The system kills the process (app in the background,
adb shell am kill <package>). Network back on: the report went out without the app being opened. That's the point of WorkManager. - Force stop (Settings, Apps, Force stop, or
adb shell am force-stop <package>). Network back on: nothing, until we opened the app again, and then it sent straight away.
The second isn't a bug. A force stop puts the app in Android's "stopped state": its scheduled jobs are cancelled until the user launches it again. WorkManager reschedules them on that next launch. adb shell dumpsys jobscheduler | grep <package> shows whether your job is still there.
What we changed: nothing in the code, but the README says it plainly, and the retries are bounded. WorkManager caps the backoff at 5 hours, and the sample gives up after 15 tries, about a day and a half of trying while online, instead of retrying forever.
3. Flutter: send when the app is open, and keep it simple
Flutter can run background work, but on iOS that means native setup and platform rules that are easy to get wrong in a sample people copy. So the Flutter version makes a different trade: each report is saved as a JSON file (plus the image) in the app's support folder, and the queue is sent when the app starts and whenever it comes back to the foreground:
BootFormFeedback.flush();
_lifecycle = AppLifecycleListener(onResume: BootFormFeedback.flush);It doesn't send while the app is closed, and reports older than 7 days are dropped. In return it needs only four common packages (http, path_provider, device_info_plus, package_info_plus) and no background setup on either platform. For feedback, "sent next time you open the app" is usually good enough.
4. Retries send duplicates, unless every report has an ID
A queue that retries is "at least once". If the server stores the report but the app never sees the answer (the connection drops, the app is killed mid-request), the retry sends it again, and you get the same bug report twice, in your inbox twice.
The first samples worked around it with a report_id field made on the device, so duplicates could at least be spotted by eye. A reader in r/FlutterDev asked the obvious question: why not let the server drop them? So BootForm now accepts an idempotency key, and both samples send the report's ID as one:
fields["report_id"]?.let { connection.setRequestProperty("Idempotency-Key", it) }The first request with a key is stored as usual. A repeat answers 200 with "duplicate": true and stores nothing, sends no second notification and doesn't count against the quota. If you build your own queue against any API, make the ID on the device, when the report is saved, not when it's sent: that's what makes every retry carry the same one.
5. Decide what each answer means
"Retry on failure" isn't enough, because some failures will never succeed and some only need part of the report dropped. Each sample has one small, pure function that turns the server's answer into one of four outcomes:
internal fun classify(status: Int, code: String?, hadScreenshot: Boolean): Outcome = when {
status in 200..299 -> Outcome.Sent
hadScreenshot && code in SCREENSHOT_REFUSED -> Outcome.DropScreenshot
status == 408 || status == 429 || status >= 500 -> Outcome.RetryLater
else -> Outcome.Rejected
}| The server says | The app does |
|---|---|
2xx | Deletes the report and its screenshot from the device. |
| The file isn't accepted (plan limit, file too large, form not claimed yet) | Sends the report again without the screenshot. |
Too many requests, a 5xx, a timeout, no network | Keeps it and tries later. |
| The form is gone or disabled, or the request is invalid | Gives up: sending the same report again would fail the same way. |
The "drop the screenshot" row is the one testing proved right. Our first test report, with a screenshot, was refused: the test form hadn't been claimed yet, and an unclaimed form never accepts files (so nobody can use a stranger's form ID as free file storage). The sample sent it again without the image, and it arrived. Without that row the whole report would have been rejected and lost, over an attachment. The same happens on a plan without file uploads, so the text always gets through.
Because classify is pure, it's unit tested without a device. An outside contributor added the remaining cases to both test suites during Hacktoberfest.
6. Screenshots: take them early, shrink them, let the user decide
- Take it before the feedback screen opens, so it shows what the user was looking at, not the feedback form.
- Shrink it on the device. Android sends a JPEG with its longest side at 1,280 pixels, usually 100 to 400 KB; Flutter sends a PNG of the same size.
- Show it, with a checkbox to leave it out. A screenshot can contain anything that was on screen, including personal data.
On BootForm, file uploads start on the Starter plan. On Free the report is still sent, just without the image, because of the rule above.
7. Attach the details nobody types
Users rarely write "Pixel 7, Android 16, version 2.4.1". The samples send it for them, as ordinary fields, so they show up next to the message and in exports:
| Field | Example |
|---|---|
app_version, app_build | 2.4.1, 241 |
os_version | Android 16 (API 36) |
device | Google Pixel 7 |
locale | en-GB |
platform | android or ios |
Locale earns its place quickly: "the text is cut off" is very often a long translation. Add your own fields the same way (the current screen, a user ID, a feature flag): they're just more keys.
8. Be honest: an app can't keep its form ID secret
A website form can be locked to your domain. An app can't: a native app sends no Origin header, so a form used from an app has to allow requests from anywhere. And the form ID ships inside your app, where anyone who unpacks it can read it and post junk straight to the API.
What still protects you: per-IP and per-form rate limits, your monthly quota, and rules that mark matching reports as spam (for example, a message containing http). A CAPTCHA like Turnstile is built for web pages, so it doesn't help here. And the worst case is spam, never a leak: the ID only lets someone send to your form, not read from it. For feedback, that trade-off is usually fine.
Two habits that follow from it:
- Use a separate form for app feedback, so a spam wave never mixes with your website's contact form.
- Claim the form before you ship. A form ID works before anyone claims it (BootForm holds up to 10 reports for 48 hours), and the first person to claim it owns it. Claim it while you're still developing, and it's yours.
What we haven't tested yet
- The Flutter sample on iOS. The code is the same, but we've only run it on Android.
- A screenshot accepted on a paid plan, end to end. The refusal path is tested; the success path only by the server's own tests.
- A reboot with reports queued. WorkManager's documentation says scheduled work survives one; we haven't checked it ourselves.
- A native SwiftUI version waits until it can be built and run on a real Mac.
If you try any of these, an issue on the repo is very welcome.
Try it
- Generate your own form ID (any random UUID, for example from our UUID generator) and paste it into the file. Never use one copied from a tutorial, this one included.
- Claim it at
app.bootform.com/claim/{your-id}with a free account. - Copy the one file for your platform from
BootForm/in-app-feedbackand add its dependencies: WorkManager on Android, the four packages above on Flutter. - Call it from your own feedback button. The demo apps show a plain screen you can restyle.
The repo is MIT licensed, so the files can go into closed-source apps.
Frequently asked questions
How do I add a feedback form to an Android app without a backend?
Send each report as a multipart POST to a form backend, from a WorkManager job so it survives a bad connection. The Compose sample is one Kotlin file that does this with device info and an optional screenshot.
How do I add a bug report screen to a Flutter app?
Copy bootform_feedback.dart, call BootFormFeedback.send(...) from your screen, and call BootFormFeedback.flush() when the app starts and resumes.
What happens if the user is offline?
The report is saved on the device and sent later: on Android by WorkManager, even with the app closed (unless the user force-stops it); on Flutter the next time the app is opened.
How is this different from a crash reporter?
A crash reporter catches what the app does wrong without the user. A feedback screen catches what the user wants to tell you: a confusing screen, a missing feature, "it's slow on my phone". They work well side by side.
Can I get the reports in Slack, Discord or email?
Yes. That's set on the form in BootForm, so the app code doesn't change.