DEV Community

Cover image for Offline file uploading in Flutter
Reme Le Hane
Reme Le Hane

Posted on • Originally published at remejuan.substack.com

Offline file uploading in Flutter

The Problem

Previously image storage was a pretty low usage feature of our application, only storing small digital copies so a signature that where often under 10kb each, more recently we had clients wanting to capture photos and even multiple photos per order.

Our current storage option posed a problem, as users of Firebase for it’s realtime capabilities and backed in offline support, we simply stored our images in a dedicated collection as base64 string, when the files are less than 10kb and there is always only ever 1 of them, this is not an issue, however Firebase has a document size limit of 1024kb.

Anyone using a modern phone can see the issue here, even capturing photos at 20% quality still resulted in many users being able to successfully store 0 photos. Both iPhone 15 owners on the team had that problem.

The solution (Part 1)

When starting to solution this we went with Firebase storage as it also had a level of offline and automated resume functionality, but to be sure we where happy with it, we literally tested in in production for about 4 months as an additional storage option alongside Firestore, this way assuming all went well this clients using newer phones would still have access to images, albeit via a support channel.

This worked well, or so we thought as in testing everything worked great whether the device was on or offline, so we went ahead and began updating the rest of the system to stop using the Firestore for accessing the files.

The Curve ball

Soon after starting this process one of our clients reported issues with this file uploading, the one thing we where never really be able to test, and honestly never thought of, was testing under garbage conditions, while Firebase Storage worked well with working internet and no internet, it was a nightmare when the internet was trash, what we had no noticed is that as long as there was a connection it would wait and continuously retry to start the upload, before allowing any form of actual offline/resume support.

This meant many drivers where stuck at clients for considerable amounts of time, some reporting up to 30min simply trying to upload small images, looking at the storage many of which around 500kb, not a problem under normal circumstances, but a nightmare when the internet is complete garbage.

Back to the drawing board

We decided to rework the solution to be completely offline first and support scheduled resume and isolate running.

To pull this off I started by adding Drift and Workmanager to the project, Drift being a pretty nice and easy to use local database option and Workmanager a background schedule, like a CRON.

The process was pretty simple, instead of sending the pending uploads directly to Firebase Storage, we first wrote it to Drift, storing all relevant data along with the Uint8List (BlobColumn in drift) image data.

The actual upload workflow was conditioned based on exiting connectivity logic that relies both on the device being connected and successful pings to Google to verify a working connection.

Uploading

Once we had everything on the DB and where in a position to begin uploading, we used one of Drift’s built in capabilities of DB Isolates, so spin off an instance of the DB into an isolated and begin the uploading within that instead of our own isolate or on the UI thread.

Here we have the uploadFiles method which makes use of Drifts computeWithDatabse method, this is typically used for dealing with computational queries that can lock up the UI, but works equally as well for dealing with background and scheduled tasks.

@pragma('vm:entry-point')
Future<void> uploadFiles(AppDatabase database) async {
  final token = RootIsolateToken.instance!;

  await database.computeWithDatabase(
    computation: (database) async {
      BackgroundIsolateBinaryMessenger.ensureInitialized(token);

      await databaseFileUpload(database);

      return Future.value(true);
    },
    connect: (connection) => AppDatabase(connection),
  );
}
Enter fullscreen mode Exit fullscreen mode

There are 2 methods here, computation which handles the processing of your DB transactions and connect which naturally handles connecting to your database.

You can follow Drifts docs for the setup, but the small change that needs to be made to facilitate this is your database needs to optionally take a QueryExecutor

AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
Enter fullscreen mode Exit fullscreen mode

This allows you to pass in an existing connection instead of it opening up it’s own one which would be the _openConnection function you will see above.

This was working great online, but for reasons I never had time to understand, Firebase Storage’s uploading was doing something that simply broke the isolates so we where never able to upload images successfully offline or via the scheduler.

As for Workmanager their docs detail the setup and usage pretty well, to support the background process I am calling the uploadFiles function above and wherein an instance of the AppDatabase is passed in, for the rest of the app I found it simplest to register AppDatabase as a singleton on get_it as apposed to a Riverpod provider within the app, I was running into warning about opening multiple instances and while there is probably a correct way to have done it with Riverpod as Drift themselves has examples using it, time crunch…

Plan C??

Luckily I know just enough NodeJS/NestJS to be dangerous and kinda useful on the BE, so Saturday morning before 5am, unable to let this bug and really entertaining challenge go, I spent up a very empty endpoint on our BE to test with, all it did was simply logout the data.

A few quick tests via Postman to make sure it worked, I went back into the app and pulled out firebase Storage and connected up our REST endpoint and a few small code tweaks I was uploading online, offline and in the background.


Off to Seapoint for a run with the dogs… (No I did not take this particular Saturday off)


Photo of a dog

So while Firebase Storage appears to be quite unhappy running in a background Isolate, http was very happy to oblige.

The HTTP flow to support background workflows is pretty straight forward, but there are some caveats.

@pragma('vm:entry-point')
Future<String?> _backgroundFileUpload(
  UploadRequestData data,
  Uint8List imageBuffer,
) async {
  final sharedPreferences = await SharedPreferences.getInstance()
    ..reload();

  final bearerToken = sharedPreferences.getString(BEARER_TOKEN_KEY)!;
  final url = sharedPreferences.getString(API_URL)!;

  final headers = {
    "content-type": "application/json",
    "Authorization": "Bearer $bearerToken",
    "x-app-version": APP_VERSION,
  };

  final uri = Uri.https(url, '/api/upload/pod');

  final request = http.MultipartRequest('POST', uri);

  request.headers.addAll(headers);

  request.files.add(
    http.MultipartFile.fromBytes(
      'image', // The key name as expected by the API
      Uint8List.fromList(imageBuffer),
      contentType: MediaType('image', 'jpeg'),
      filename: data.file_name,
    ),
  );

  request.fields.addAll({
    'task_id': data.task_id,
    'file_name': data.file_name,
    'type': data.type,
    'status': data.status,
    'timestamp': data.timestamp,
  });

  final response = await request.send();

  if (response.statusCode != 201) {
    return null;
  }

  return response.stream.bytesToString();
}
Enter fullscreen mode Exit fullscreen mode

As with most apps we make use of environment variables for the different APIs we need to connect to (Dev/Staging/Prod), SharedPreferences is one of the simplest ways to bring these variables into an Isolate, being stored in a file on device you simple need to ensure you reload before accessing any data to ensure that you not only have the data, but also the latest version of it in that Isolate.

From there, everything works as it would for nay MultipartFile upload, while we are storing the Uint8List in the BlobColumn in Drift, I found that simply passing that to the API resulted in errors and had to convert the BlobColumn into a Uint8List again.

Wrapping Up

From here on out it really was just clean up and some more testing, solving a few issues in code that only showed up in the release build, making sure the API actually saved the files and that all relevant DB documents where updated in Firebase and the cleanup process for successful uploads was done within the app.

Top comments (0)