Add the ability to resume downloads in your Flutter app
Summary
By the end of this post, you will be able to download files, handle incomplete downloads, resume downloads, cancel, get current download status (percentage or size remaining) and merge all chunks into one file.
Current issue
I was facing a problem with my current project. I need to handle large video files, sometimes the download would not complete and every time the user accesses this specific view the download starts again from the beginning.
At the time of writing this post, the dio plugin does not have the ability to append data to an existing file during the download.
So, what can we do?
We are going to implement a customized procedure to handle our business logic.
Imports
We are going to need to add some dependencies to help us with the implementation of our service.
import 'package:dio/dio.dart';
import 'package:path/path.dart' as path;
Procedure Parameters
We will receive the remote url of the file (fileUrl) to download and the local route of the file (fileLocalRouteStr) in our user’s local storage.
Future<File?> getItemFileWithProgress({
required String fileUrl,
required String fileLocalRouteStr,
}) async {
...
}
Local Variables
Let’s first create an instance of the Dio class and the necessary variables to handle the file path so we can “play” with the filename.
Dio dio = new Dio();
File localFile = File(fileLocalRouteStr);
String dir = path.dirname(fileLocalRouteStr);
String basename = path.basenameWithoutExtension(fileLocalRouteStr);
String extension = path.extension(fileLocalRouteStr);
String localRouteToSaveFileStr = fileLocalRouteStr;
Now let’s check if the local file exists.
bool existsSync = localFile.existsSync();
If the local file DO NOT EXIST, then we are in the best case scenario where we are going to start the download from scratch. But if the file DO EXIST then we need to do some magic.
The Magic ✨
First let’s get the source file size, then the local file size and add it to a list containing all chunk (file part) sizes.
if(existsSync) {
Response response = await dio.head(fileUrl);
int totalBytes = int.parse(response.headers.value('content-length')!);
int fileLocalSize = localFile.lengthSync();
List<int> sizes = [fileLocalSize];
We will create as many chunks of the file as needed and of course this number will be unknown so we will iterate over time until the (temporary) chunk file does not exists and each iteration will modify the chunk file name and add this chunk size to our list of sizes (we’ll need to know the sum of all sizes eventually).
int i = 1;
localRouteToSaveFileStr = '$dir/$basename''_$i$extension';
File _f = File(localRouteToSaveFileStr);
while (_f.existsSync()) {
sizes.add(_f.lengthSync());
i++;
localRouteToSaveFileStr = '$dir/$basename''_$i$extension';
_f = File(localRouteToSaveFileStr);
}
When the code exits the while loop, we have the new chunk-file ready to store the remaining bytes of the file. So we’ll need to add up the sizes so far and create the Options for the header in the download.
int sumSizes = sizes.fold(0, (p, c) => p + c);
Options options = Options(
headers: {'Range': 'bytes=$sumSizes-'},
);
}
We’re saying here, on the next download fetch only from this byte (sumSizes) forward to the end of the file (we could also specify the end range of bytes, but it’s not necessary in this case)
End of The magic ;)
Merge all chunks of the file
Wait, we still have to do a few things with all the chunk files. If the localFile exists, then we need to merge all the small pieces of the original file into one and delete the chunks after.
if (existsSync) {
var raf = await localFile.open(mode: FileMode.writeOnlyAppend);
int i = 1;
String filePartLocalRouteStr = '$dir/$basename''_$i$extension';
File _f = File(filePartLocalRouteStr);
while (_f.existsSync()) {
raf = await raf.writeFrom(await _f.readAsBytes());
await _f.delete();
i++;
filePartLocalRouteStr = '$dir/$basename''_$i$extension';
_f = File(filePartLocalRouteStr);
}
await raf.close();
}
return localFile;
We’ll open the localFile in write mode, but we’ll only adding the new bytes at the end (append), so we won’t overwrite what’s already there. Very similar to what we did before, we’ll iterate until the chunk filename doesn’t exist and then return the FULL FILE. 🥳
BONUS: Download progress & Cancel download
Let’s add 2 more variables to the environment
CancelToken cancelToken = CancelToken();
final percentNotifier = ValueNotifier<double?>(null);
The first one will be “cancelToken” to give us the possibility to cancel the current download, and the “percentNotifier” will help us to listen only to the percent changes so we don’t have to redraw all the screen, instead of only the desired widget.
Now we’ll need 2 more procedures to handle this new logic.
_cancel() {
cancelToken.cancel();
percentNotifier.value = null;
}
_onReceiveProgress(int received, int total) {
if (!cancelToken.isCancelled) {
int sum = sizes.fold(0, (p, c) => p + c);
received += sum;
percentNotifier.value = received / total;
}
}
Before executing the download we’ll need to check if the cancel token was already used and if so, refresh the variable with a new value.
if (cancelToken.isCancelled) {
cancelToken = CancelToken();
}
await dio.download(
fileUrl,
localRouteToSaveFileStr,
options: options,
cancelToken: cancelToken,
deleteOnError: false,
...
“deleteOnError” parameter in false will allow us to cancel the download and left the incomplete file in the user’s storage
Now we’ll listen to the Dio provided callback “onReceiveProgress” to update our notifier.
await dio.download(
fileUrl,
localRouteToSaveFileStr,
options: options,
cancelToken: cancelToken,
deleteOnError: false,
onReceiveProgress: (int received, int total) {
_onReceiveProgress(received, fileOriginSize);
},
);
Take a look at the example repo in GitHub:
https://github.com/rlazom/resumeDownload
If this was helpful to you please clap your hands a bit and follow me for more content. 👏👏👏
Top comments (1)
great nice