flutterdio

Flutter Web Dio XMLHttpRequest error uploading large files


In a Flutter app I'm trying to upload large audio files to a go server and save them to Wasabi s3.

I can see my server log returning a status code 200 for the OPTIONS request but no post request and no errors. In my go server I am using the chi router and have added handler header to allow CORS from anywhere. My app is large and everything else works fine. The authorization bearer token is also being passed in the request.

I have already made the changes to disable web security and also tested the upload page on my production flutter server where it also fails.

Api log for the options request

"OPTIONS http://api.mydomain.com/transcript/upload/audio/file/2 HTTP/1.1" from 123.12.0.1:48558 - 200 0B in 39.061µs

Here are the errors I can glean from Flutter

DioExceptionType.connectionError
https://api.mydomain.com/transcript/upload/audio/file/2
https://api.mydomain.com/transcript/upload/audio/file/2
{Content-Type: application/octet-stream, Authorization: Bearer my-token, Access-Control-Allow-Origin: *}

Relevant api code

r := chi.NewRouter()

r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.URLFormat)
r.Use(render.SetContentType(render.ContentTypeJSON))
r.Use(cors.Handler(cors.Options{
    AllowedOrigins:   []string{"*"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
    ExposedHeaders:   []string{"Link"},
    AllowCredentials: false,
    MaxAge:           300, // Maximum value not ignored by any of major browsers
}))

const maxUploadSize = 500 * 1024 * 1024 // 500 MB

id := chi.URLParam(r, "transcriptId")

transcriptId, err := strconv.ParseInt(id, 10, 64)

if err := r.ParseMultipartForm(32 << 20); err != nil {
    log.Println(err)
    http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
    return
}

file, handler, err := r.FormFile("file")

Flutter upload code

class FileUploadView extends StatefulWidget {
  const FileUploadView({super.key});

  @override
  State<FileUploadView> createState() => _FileUploadViewState();
}

class _FileUploadViewState extends State<FileUploadView> {
  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback(
      (_) => showSnackBar(context),
    );
    super.initState();
  }

  FilePickerResult? result;
  PlatformFile? file;
  Response? response;
  String? progress;
  Dio dio = Dio();
  String success = 'Your file was uploaded successfully';
  String failure = 'Your file could not be uploaded';
  bool replaceFile = false;

  selectFile() async {
    FilePickerResult? result = await FilePicker.platform
        .pickFiles(type: FileType.any, withReadStream: true);

    if (result != null) {
      file = result.files.single;
    }

    setState(() {});
  }

  Future<void> uploadFile(BuildContext context, User user) async {
    final navigator = Navigator.of(context);

    const storage = FlutterSecureStorage();

    String? token = await storage.read(key: 'jwt');

    dio.options.headers['Content-Type'] = 'application/octet-stream';
    dio.options.headers["Authorization"] = "Bearer $token";
    dio.options.headers['Access-Control-Allow-Origin'] = '*';
    dio.options.baseUrl = user.fileUrl;

    final uploader = ChunkedUploader(dio);

    try {
      response = await uploader.upload(
        fileKey: 'file',
        method: 'POST',
        fileName: file!.name,
        fileSize: file!.size,
        fileDataStream: file!.readStream!,
        maxChunkSize: 32000000,
        path: user.fileUrl,
        onUploadProgress: (progress) => setState(
          () {
            progress;
          },
        ),
      );

      if (response!.statusCode == 200) {
        user.snackBarType = SnackBarType.success;

        user.snackBarMessage = success;

        navigator.pushNamedAndRemoveUntil(
            RoutePaths.matterTabs, (route) => false);
      } else {
        user.snackBarType = SnackBarType.failure;

        user.snackBarMessage = failure;

        navigator.pushNamedAndRemoveUntil(
            RoutePaths.matterTabs, (route) => false);
      }
    } on DioException catch (e) {
      if (e.response?.statusCode == 404) {
        print('status code 404');
      } else {
        print(e.message ?? 'no error message available');
        print(e.requestOptions.toString());
        print(e.response.toString());
        print(e.type.toString());
        print(user.fileUrl);
        print(dio.options.baseUrl);
        print(dio.options.headers);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    User user = Provider.of<User>(context, listen: false);
    
    return Scaffold(
      resizeToAvoidBottomInset: false,
      appBar: AppBar(
        centerTitle: true,
        title: Text(
          'app name',
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
        ),
        automaticallyImplyLeading: false,
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => {
            Navigator.of(context).pushNamedAndRemoveUntil(
                RoutePaths.matterTabs, (route) => false)
          },
        ),
      ),
      body: Container(
        padding: const EdgeInsets.all(12.0),
        child: SingleChildScrollView(
          child: Center(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: <Widget>[
                const SizedBox(height: 12),
                const Text(
                  'Select and Upload File',
                  maxLines: 4,
                  overflow: TextOverflow.ellipsis,
                  textAlign: TextAlign.center,
                  softWrap: true,
                ),
                const SizedBox(height: 24),

                Container(
                  margin: const EdgeInsets.all(10),
                  //show file name here
                  child: progress == null
                      ? const Text("Progress: 0%")
                      : Text(
                          "Progress: $progress",
                          textAlign: TextAlign.center,
                        ),
                  //show progress status here
                ),
                const SizedBox(height: 24),
                Container(
                  margin: const EdgeInsets.all(10),
                  //show file name here
                  child: file == null
                      ? const Text(
                          'Choose File',
                        )
                      : Text(
                          file!.name,
                        ),
                  //basename is from path package, to get filename from path
                  //check if file is selected, if yes then show file name
                ),
                const SizedBox(height: 24),
                ElevatedButton.icon(
                  onPressed: () async {
                    selectFile();
                  },
                  icon: const Icon(Icons.folder_open),
                  label: const Text(
                    "CHOOSE FILE",
                  ),
                ),
                const SizedBox(height: 24),

                //if selectedfile is null then show empty container
                //if file is selected then show upload button
                file == null
                    ? Container()
                    : ElevatedButton.icon(
                        onPressed: () async {
                          if (user.fileExists) {
                            _replaceExistingFile(context, user);
                          } else {
                            uploadFile(context, user);
                          }
                        },
                        icon: const Icon(Icons.upload),
                        label: const Text(
                          "UPLOAD FILE",
                        ),
                      ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  _replaceExistingFile(BuildContext context, User user) {
    bool firstPress = true;

    return showDialog<bool>(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('File Exists'),
          content: Text("Do you want to replace ${user.uploadFileName}?"),
          actions: <Widget>[
            TextButton(
              child: const Text('Cancel'),
              onPressed: () {
                {
                  Navigator.of(context).pop(false);
                }
              },
            ),
            TextButton(
              child: const Text('Replace'),
              onPressed: () async {
                if (firstPress) {
                  firstPress = false;
                  {
                    uploadFile(context, user);
                  }
                } else {}
              },
            )
          ],
        );
      },
    );
  }
}

Solution

  • It turns out that this is a Dio issue. I was able to get this to work using http. I wanted to use Dio because of the callbacks giving me a progress meter.

    The code below works. It's not a minimal working version, it's my full file upload page code.

    import 'package:flutter/material.dart';
    import 'package:logger/logger.dart';
    import 'package:file_picker/file_picker.dart';
    import 'package:provider/provider.dart';
    import 'package:myapp/ui/helpers/show_snack_bar.dart';
    import 'package:myapp/core/models/user.dart';
    import 'package:myapp/core/models/company.dart';
    import 'package:myapp/core/constants/app_constants.dart';
    import 'package:flutter_secure_storage/flutter_secure_storage.dart';
    import 'package:http/http.dart' as http;
    import 'dart:async';
    
    class FileUploadView extends StatefulWidget {
      const FileUploadView({super.key});
    
      @override
      State<FileUploadView> createState() => FileUploadViewState();
    }
    
    class FileUploadViewState extends State<FileUploadView> {
      @override
      void initState() {
        WidgetsBinding.instance.addPostFrameCallback(
          (_) => showSnackBar(context),
        );
        super.initState();
      }
    
      FilePickerResult? result;
      PlatformFile? file;
    
      String? progress;
      String success = 'Your file was uploaded successfully';
      String failure = 'Your file could not be uploaded';
      bool replaceFile = false;
      bool isLoading = false;
      bool firstPress = true;
    
      var logger = Logger(
        printer: PrettyPrinter(),
      );
    
      selectFile() async {
        result = (await FilePicker.platform
            .pickFiles(type: FileType.any, withData: true));
    
        if (result != null) {
          file = result?.files.single;
        }
    
        setState(() {});
      }
    
      Future<dynamic> uploadFile(BuildContext context, User user) async {
        final navigator = Navigator.of(context);
    
        const storage = FlutterSecureStorage();
    
        String? token = await storage.read(key: 'jwt');
    
        Map<String, String> headers = {
          "Authorization": "Bearer $token",
        };
    
        final bytes = file?.bytes;
    
        if (file == null || bytes == null) return;
    
        final multipartFile =
            http.MultipartFile.fromBytes('file', bytes, filename: file?.name);
    
        final request = http.MultipartRequest('POST', Uri.parse(user.fileUrl));
    
        request.files.add(multipartFile);
    
        request.headers.addAll(headers);
    
        user.snackBarMessage = 'Uploading file, please wait';
    
        progress = 'Uploading file, please wait';
    
        isLoading = true;
    
        setState(() {});
    
        try {
          final responseStream = await request.send();
    
          final response = await http.Response.fromStream(responseStream);
    
          if (response.statusCode == 200) {
            user.snackBarType = SnackBarType.success;
    
            user.snackBarMessage = success;
    
            navigator.pushNamedAndRemoveUntil(
                RoutePaths.matterTabs, (route) => false);
          } else {
            user.snackBarType = SnackBarType.failure;
    
            user.snackBarMessage = failure;
    
            navigator.pushNamedAndRemoveUntil(
                RoutePaths.matterTabs, (route) => false);
          }
        } on Exception catch (e) {
          logger.d(e);
        }
      }
    
      @override
      Widget build(BuildContext context) {
        User user = Provider.of<User>(context, listen: false);
    
        Company company = Provider.of<Company>(context, listen: false);
    
        return Scaffold(
          resizeToAvoidBottomInset: false,
          appBar: AppBar(
            centerTitle: true,
            title: Text(
              company.companyName,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
            automaticallyImplyLeading: false,
            leading: IconButton(
              icon: const Icon(Icons.arrow_back),
              onPressed: () => {
                Navigator.of(context).pushNamedAndRemoveUntil(
                    RoutePaths.matterTabs, (route) => false)
              },
            ),
          ),
          body: Container(
            padding: const EdgeInsets.all(12.0),
            child: SingleChildScrollView(
              child: Center(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: <Widget>[
                    const SizedBox(height: 12),
                    const Text(
                      'Select and Upload File',
                      maxLines: 4,
                      overflow: TextOverflow.ellipsis,
                      textAlign: TextAlign.center,
                      softWrap: true,
                    ),
                    const SizedBox(height: 24),
                    Container(
                      margin: const EdgeInsets.all(10),
                      child: progress == null
                          ? const Text("Progress: 0%")
                          : Text(
                              "Progress: $progress",
                              textAlign: TextAlign.center,
                            ),
                    ),
                    const SizedBox(height: 24),
                    Container(
                      margin: const EdgeInsets.all(10),
                      child: file == null
                          ? const Text(
                              'Choose File',
                            )
                          : Text(
                              file!.name,
                            ),
                    ),
                    const SizedBox(height: 24),
                    FilledButton.icon(
                      onPressed: () async {
                        selectFile();
                      },
                      icon: const Icon(Icons.folder_open),
                      label: const Text(
                        "CHOOSE FILE",
                      ),
                    ),
                    const SizedBox(height: 24),
                    file == null
                        ? Container()
                        : FilledButton.icon(
                            onPressed: () async {
                              if (firstPress) {
                                firstPress = false;
                                if (user.fileExists) {
                                  _replaceExistingFile(context, user);
                                } else {
                                  uploadFile(context, user);
                                }
                              }
                            },
                            icon: const Icon(Icons.upload),
                            label: const Text(
                              "UPLOAD FILE",
                            ),
                          ),
                    isLoading
                        ? const Center(child: CircularProgressIndicator())
                        : Container(),
                  ],
                ),
              ),
            ),
          ),
        );
      }
    
      _replaceExistingFile(BuildContext context, User user) {
        bool firstPress = true;
    
        return showDialog<bool>(
          context: context,
          builder: (context) {
            return AlertDialog(
              title: const Text('File Exists'),
              content: Text("Do you want to replace ${user.uploadFileName}?"),
              actions: <Widget>[
                FilledButton(
                  child: const Text('Cancel'),
                  onPressed: () {
                    {
                      Navigator.of(context).pop(false);
                    }
                  },
                ),
                FilledButton(
                  child: const Text('Replace'),
                  onPressed: () async {
                    if (firstPress) {
                      firstPress = false;
                      {
                        uploadFile(context, user);
                      }
                    } else {}
                  },
                )
              ],
            );
          },
        );
      }
    }
    

    edit: In view of the comment from Serhii Ptitsyn below I altered my code to read from a file stream instead of from bytes so as to ensure the whole file is not read into memory. Here is the amended code:

    final fileSize = file!.size;
    
    // Check if file size exceeds maximum allowed (e.g., 1 GB)
    const maxFileSize = 1024 * 1024 * 1024; // 1 GB
    if (fileSize > maxFileSize) {
      user.snackBarType = SnackBarType.failure;
      user.snackBarMessage = 'File size exceeds 1 GB limit';
      navigator.pushNamedAndRemoveUntil(
          RoutePaths.myTab, (route) => false);
      return;
    }
    
    // Open a stream to read the file content
    final fileStream = http.ByteStream(Stream.castFrom(file!.readStream!));
    
    final multipartFile = http.MultipartFile(
      'file',
      fileStream,
      fileSize,
      filename: file!.name,
    );
    
    request.files.add(multipartFile);
    
    request.headers.addAll(headers);