How to save image correctly that's being uploaded to Flutter Android app?

Viewed 376

First, I created a sample HttpServer console app using shelf-related packages to make sure I have the correct code to handle the image upload URI handler. The console app was able to receive and save the image correctly then I tried the same code to Flutter Android app with only one minor difference which is the location of the saved image file.

Here's the code:

import 'package:shelf_router/shelf_router.dart' as srouter;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:mime/mime.dart';
import 'package:path_provider/path_provider.dart' as prod;

var app = srouter.Router();
final directory = await prod.getExternalStorageDirectory();
app.post('/upload', (Request request) async {
      String? boundary = request.headers['content-type'];
      final payload = await request.read();

      final boundWord = 'boundary=';
      if (boundary != null) {
        var boundIndex = boundary.indexOf(boundWord);
        boundary = boundary.substring(boundIndex + boundWord.length);
        final transformer = MimeMultipartTransformer(boundary);
        final parts = await transformer.bind(payload).toList();

        for (var part in parts) {
          final content = await part.toList();
          await File('${directory?.path}/newImgFile.png').writeAsBytes(content[0]);
        }
      }

      return Response.ok('Upload done');
    });
await io.serve(app, '0.0.0.0', 8080);

When I ran this and I send an image file using curl, it seems that it does everything correctly but when I checked the saved newImgFile, it's incorrect.

The file size doesn't match at all. I had my test image is in 900 KB but it was saved with much less than that (I think it was 30 KB).

I did notice one thing that's different on the content after this code line:

final content = await part.toList();

With the same image upload, content is a list of only 1 item for HttpServer console app but for HttpServer Flutter Android app, content is a list with 2 items.

I don't know if that's going to help in solving this issue but that's a difference that I am noticing.

1 Answers

I think the most useful conceptual model of MIME multipart uploads is:

  • any number of parts, in any order, each comprised of
  • any number of chunks, in consecutive order

Based on what you've described, you are receiving multiple chunks for at least one of the parts, which you will need to deal with. There is another issue with your code that may crop up, and that is the assumption that the part you care about is last (eg, you may have multiple parts, but you are overwriting the file with the last one anyway).

Putting them both together, you could do something like the following:

import 'package:http_parser/http_parser.dart';

...

app.post('/upload', (Request request) async {
  final contentType = request.headers['content-type'];
  if (contentType == null) {
    return Response(400, body: 'Missing content-type');
  }

  final mediaType = MediaType.parse(contentType);
  if (mediaType.mimeType != 'multipart/form-data') {
    return Response(400, body: 'Invalid content-type');
  }

  final boundary = mediaType.parameters['boundary'];
  if (boundary == null) {
    return Response(400, body: 'Missing boundary');
  }

  final payload = request.read();
  final parts = await MimeMultipartTransformer(boundary).bind(payload).toList();
  for (final part in parts) {
    if (part.headers['content-type'] != 'image/png') {
      continue;
    }   

    final file = File('${directory?.path}/newImgFile.png');
    if (await file.exists()) {
      await file.delete();
    }   
    final chunks = await part.toList();
    for (final chunk in chunks) {
      await file.writeAsBytes(chunk, mode: FileMode.append);
    }   

    return Response.ok('Upload done');
  }

  return Response(400, body: 'No good parts');
});

which will:

  • check whether the request Content-Type is valid (using MediaType)
  • find the first part that has the appropriate image/png content type
  • delete the destination file if it already exists
  • append each chunk to the newly created destination file

Going further, you could take advantage of the fact that both MimeMultipartTransformer.bind() and MimeMultipart implement Stream and do something like the following:

app.post('/upload', (Request request) async {
  final contentType = request.headers['content-type'];
  if (contentType == null) {
    return Response(400, body: 'Missing content-type');
  }

  final mediaType = MediaType.parse(contentType);
  if (mediaType.mimeType != 'multipart/form-data') {
    return Response(400, body: 'Invalid content-type');
  }

  final boundary = mediaType.parameters['boundary'];
  if (boundary == null) {
    return Response(400, body: 'Missing boundary');
  }

  final payload = request.read();
  final parts = MimeMultipartTransformer(boundary).bind(payload).where((part) {
    return part.headers['content-type'] == 'image/png';
  }); 

  final partsIterator = StreamIterator(parts);
  while (await partsIterator.moveNext()) {
    final part = partsIterator.current;

    final file = File('${directory?.path}/newImgFile.png');
    if (await file.exists()) {
      await file.delete();
    }   
    final chunksIterator = StreamIterator(part);
    while (await chunksIterator.moveNext()) {
      final chunk = chunksIterator.current;
      await file.writeAsBytes(chunk, mode: FileMode.append);
    }   

    return Response.ok('Upload done');
  }

  return Response(400, body: 'No good parts');
});

which:

  • does some filtering upfront to ensure only image/png parts are considered, and
  • uses StreamIterator to allow iterating over the stream with await
Related