So, in this article, I will be showing you how you can download files to the device in Flutter — and actually save them somewhere the user can find them.
This sounds trivial until you ship it. I have debugged more "the download finished but the file is nowhere" reports than I can count, and the root cause is almost always the same: the code downloads bytes correctly and then writes them to a location the user has no way to reach, or Android's storage rules block the write silently. Every generation of Android changes the rules slightly, which is why a tutorial from two years ago will compile fine and then fail on a modern device.
The good news: the pattern is stable, the packages are mature, and once you understand where each platform expects files to go, the whole thing is about thirty lines of Dart.
For this purpose, we need to add these dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
path_provider: ^2.1.0
permission_handler: ^11.3.0
-
diohandles the actual download with progress reporting and robust error handling. -
path_providergives you the platform's correct directories — the ones that exist on the device, not paths you guess. -
permission_handlerlets you ask for storage permission cleanly on Android.
Let's jump into the coding part.
Step 1: Where Files Are Allowed to Go
Android and iOS have completely different rules about where a downloaded file may live, and this is the heart of the whole article.
On iOS, there is one real answer: the Documents directory (visible in the Files app) or, for media, the app's own sandbox. Users can't browse the whole filesystem anyway, so you save to a directory your app owns and expose.
On Android, since Android 10 (API 29), scoped storage means your app cannot just write anywhere. The honest options are:
-
App-specific external directory —
getExternalStorageDirectory(). No permission needed, but the folder is hidden from the user's file manager on many devices unless your app exposes it. -
The public Downloads folder — via the MediaStore or
getDownloadsDirectory(). This is what users expect when they tap "download": the file lands inDownload/and shows up in their file manager. This is the target I use for anything the user is meant to keep.
The rule I follow: save to the app's own directory for internal artifacts, save to the public Downloads folder when the user explicitly asked to download something they will open later.
Step 2: Android Permissions
For the public Downloads folder on modern Android, the permission story is more forgiving than people expect — Android 10+ allows writing to Downloads via MediaStore without the old WRITE_EXTERNAL_STORAGE monster permission for most cases. But you will still hit permission requests depending on the device and Android version, so handle it cleanly:
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
The maxSdkVersion="28" matters. On Android 9 and below the old storage permission applies. On Android 10 and above scoped storage replaces it, and declaring the permission unconditionally triggers confusing runtime prompts on devices that no longer need it. This single attribute has saved me a surprising amount of support-ticket pain.
Step 3: The Download Function
Here is a complete, production-shaped download using dio, with a progress callback and explicit save locations:
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
Future<String?> downloadFile({
required String url,
required String fileName,
void Function(double progress)? onProgress,
}) async {
final Dio dio = Dio();
Directory? dir;
try {
dir = await getDownloadsDirectory();
} catch (_) {
dir = null;
}
// Fallback for platforms without a Downloads directory.
final String savePath =
'${(dir ?? await getApplicationDocumentsDirectory()).path}/$fileName';
final Response response = await dio.download(
url,
savePath,
onReceiveProgress: (int received, int total) {
if (total != -1 && onProgress != null) {
onProgress(received / total);
}
},
);
if (response.statusCode == 200) {
return savePath;
}
return null;
}
A few details worth your attention:
-
getDownloadsDirectory()comes frompath_providerand returns the platform's real downloads location. On Android it maps to the public Downloads folder; on iOS it returns the app's Documents directory so the file appears in the Files app. - The fallback to the documents directory covers desktops and any platform quirk where a Downloads directory does not exist.
- The
onReceiveProgresscallback is what lets you render a progress bar. Pass it straight into aValueNotifieror a state object and the UI updates live. -
dio.downloadstreams the file to disk instead of holding the whole thing in memory, so a 500 MB file will not OOM your app.
Step 4: Calling It From the UI
The wiring is straightforward. Here is a minimal screen that downloads a file and reports progress:
class DownloadButton extends StatefulWidget {
const DownloadButton({super.key});
@override
State<DownloadButton> createState() => _DownloadButtonState();
}
class _DownloadButtonState extends State<DownloadButton> {
double _progress = 0;
bool _downloading = false;
Future<void> _startDownload() async {
setState(() {
_downloading = true;
_progress = 0;
});
final String? path = await downloadFile(
url: 'https://example.com/files/invoice.pdf',
fileName: 'invoice.pdf',
onProgress: (p) => setState(() => _progress = p),
);
if (!mounted) return;
setState(() => _downloading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(path != null
? 'Downloaded to $path'
: 'Download failed. Check your connection.'),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_downloading)
LinearProgressIndicator(value: _progress)
else
FilledButton(
onPressed: _startDownload,
child: const Text('Download invoice'),
),
],
);
}
}
That is a complete, working download flow: tap, progress, file on the device.
Step 5: File Names and Collisions
The last detail that separates a demo from a production app is file naming. Two users downloading the same invoice, or one user downloading the same report twice a day, produce collisions — and depending on the platform, the second file either silently overwrites the first or fails. The pattern I use:
import 'package:path/path.dart' as p;
Future<String> uniqueSavePath(Directory dir, String fileName) async {
final File file = File('${dir.path}/$fileName');
if (!await file.exists()) return file.path;
final String ext = p.extension(fileName);
final String base = p.basenameWithoutExtension(fileName);
File candidate = file;
int i = 1;
while (await candidate.exists()) {
candidate = File('${dir.path}/$base($i)$ext');
i++;
}
return candidate.path;
}
This gives you report.pdf, report(1).pdf, report(2).pdf — the convention every file manager already uses, so users instantly understand it. Overwriting a user's previous download without asking is a data-loss bug; never let the second download silently clobber the first. If you prefer, you can also ask first with a dialog — but in my experience the (1) suffix pattern removes the question entirely and users prefer it.
Important Notes
Here is what will bite you in production, in rough order of frequency:
Scoped storage is not optional. On Android 10+, writing to arbitrary paths returns errors or silently no-ops. Always use
getDownloadsDirectory()or the MediaStore instead of hardcoding/storage/emulated/0/Download. If you hardcode, you will get "download succeeded" in the log and "file not found" in the UI — I have seen exactly that combination in a production app.getDownloadsDirectory()returns null on some platforms. Android can return null on certain devices and API levels. The fallback I showed is not a nice-to-have; it is the difference between a crash and a working download on those devices.HTTPS is mandatory on modern platforms. Android 9+ and iOS both block cleartext HTTP by default. If your file URL is
http://, the download will fail with a connection error. Serve files over HTTPS, or the fix is a per-domain network-security exception, which you should avoid shipping.Large files need a resume story. A 500 MB download that dies at 90% because the user locked their phone is a bad day.
diosupportsDownloadRangefor resumable downloads — persist the received byte count and resume from there. It is more code, and it is worth it for anything over a few tens of megabytes.The user may want to share the file. After download, use
share_plusto let them send it. Combine the two and you have the flow every invoice, report, and receipt app wants.iOS background downloads are a separate topic. The
dioapproach keeps your app foreground. If a client asks for "download even when the app is closed," that is a background transfer session on the native side and a much bigger project. Say so up front.Sanitize the file name from the server. If your download URL ends in a user-controlled name, strip slashes, spaces, and path separators before building the save path. A name like
../../etc/somethingon an unsanitized path is a vulnerability you do not want to debug after the fact.
A Workflow Note From the Field
I hit this problem most often in document-heavy apps — a logistics client wanted field agents to pull delivery manifests and store them offline, and an accounting client wanted report exports to land in Downloads for their auditors. When the requirements get that specific — exact file names, deterministic folders, resume behavior — I sketch the whole flow end to end before writing the app code, the same way I prototype an automation in a builder like misar.dev before committing to implementation. The pattern holds: decide where files live, decide what happens on failure, then write the thirty lines.
A Quick FAQ
Does this work for media files that should appear in the gallery? Partially. For photos and videos, the correct target on Android is the MediaStore (MediaStore.Images / MediaStore.Video) so they register in the gallery, not just the Downloads folder. That is a slightly different code path from the one above — same dio download, different destination.
Can I download multiple files at once? Yes, and you should not overthink it — run several dio.download calls in parallel and report aggregate progress. What you should not do is spin up a thread per file on the platform side; Dart's async model handles concurrency natively.
Do I need permission_handler at all? For downloads to the app's own directories and to the public Downloads folder on Android 10+, usually not. The permission code is a fallback for older Android versions and specific device quirks. Keep it for the maxSdkVersion=28 path, not for the modern one.
What about storing the download in a database? For offline-first apps, the pattern is to save the file to the app's documents directory and store its path in your database, rather than relying on the public Downloads folder. That way your app can open it without user permission, and the user-facing export is a separate optional step.
That's it — a complete file-download implementation in Flutter that lands files where users can actually find them, with progress reporting, collision-safe naming, and the permission handling that keeps it working on modern Android.
I have also written about file uploads to storage services and handling large media in Flutter — comment below with your download use case and I'll cover it next.
*Gulshan Yad
Top comments (0)