Skip to content

openFile doesn't correctly open epub files #550

@Aulig

Description

@Aulig

Describe the bug
Using await FileDownloader().openFile on downloaded .epub files has various issues. In the simulator (and some physical devices) it opens the share file dialog, but "Save to Files" only brings up a blank dialog that instantly closes. On other devices (my iPad 5th gen with iOS 16.7.11) nothing happens at all when calling openFile - but it does return true.

This might also affect other file types - I see the same thing in the simulator when trying to download a sql database. I thought maybe it's just a weird issue with file types where no suitable app is installed, but that doesn't explain why it doesn't work with the Apple Books app installed that can open the same epub file no problem when you go through the files app. I looked at the swift implementation (although I'm not very familiar with swift) but couldn't figure out why this might be happening. Maybe this is an iOS bug since this seems to all be delegated to the OS? I'm interested in hearing your thoughts.

To Reproduce
Steps to reproduce the behavior:

  1. Run the code sample
  2. Click "Load, open and add"
  3. Either nothing happens, or if a dialog comes up, click "Save to Files"
  4. You can't save the file.
bug.mp4

Expected behavior
The epub should be opened in the Books application if available and/or saving to files should work normally.

Code
This is the example app code with a modified processLoadAndOpen function to download and open an epub file

import 'dart:async';
import 'dart:io';
import 'dart:math';

import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

void main() {
  Logger.root.onRecord.listen((LogRecord rec) {
    debugPrint(
        '${rec.loggerName}>${rec.level.name}: ${rec.time}: ${rec.message}');
  });

  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final log = Logger('ExampleApp');
  final buttonTexts = ['Download', 'Cancel', 'Pause', 'Resume', 'Reset'];

  ButtonState buttonState = ButtonState.download;
  bool downloadWithError = false;
  TaskStatus? downloadTaskStatus;
  DownloadTask? backgroundDownloadTask;
  StreamController<TaskProgressUpdate> progressUpdateStream =
  StreamController();

  bool loadAndOpenInProgress = false;
  bool loadABunchInProgress = false;
  bool loadBackgroundInProgress = false;
  String? loadBackgroundResult;

  @override
  void initState() {
    super.initState();
    // By default the downloader uses a modified version of the Localstore package
    // to persistently store data. You can provide an alternative persistent
    // storage backing that implements the [PersistentStorage] interface. You
    // must initialize the FileDownloader by passing that alternative storage
    // object on the first call to FileDownloader.
    // For example, add a dependency for background_downloader_sql to
    // pubspec.yaml which adds [SqlitePersistentStorage].
    // To try that SQLite version, uncomment the following line, which
    // will initialize the downloader with the SQLite storage solution.
    // FileDownloader(persistentStorage: SqlitePersistentStorage());

    // optional: configure the downloader with platform specific settings,
    // see CONFIG.md - some examples shown here
    FileDownloader().configure(globalConfig: [
      (Config.requestTimeout, const Duration(seconds: 100)),
    ], androidConfig: [
      (Config.useCacheDir, Config.whenAble),
    ], iOSConfig: [
      (Config.localize, {'Cancel': 'StopIt'}),
    ]).then((result) => debugPrint('Configuration result = $result'));

    // Registering a callback and configure notifications
    FileDownloader()
        .registerCallbacks(
        taskNotificationTapCallback: myNotificationTapCallback)
        .configureNotificationForGroup(FileDownloader.defaultGroup,
        // For the main download button
        // which uses 'enqueue' and a default group
        running: const TaskNotification('Download {filename}',
            'File: {filename} - {progress} - speed {networkSpeed} and {timeRemaining} remaining'),
        complete: const TaskNotification(
            '{displayName} download {filename}', 'Download complete'),
        error: const TaskNotification(
            'Download {filename}', 'Download failed'),
        paused: const TaskNotification(
            'Download {filename}', 'Paused with metadata {metadata}'),
        canceled: const TaskNotification('Download {filename}', 'Canceled'),
        progressBar: true)
        .configureNotificationForGroup('bunch',
        running: const TaskNotification(
            '{numFinished} out of {numTotal}', 'Progress = {progress}'),
        complete:
        const TaskNotification("Done!", "Loaded {numTotal} files"),
        error: const TaskNotification(
            'Error', '{numFailed}/{numTotal} failed'),
        progressBar: false,
        groupNotificationId: 'notGroup')
        .configureNotification(
      // for the 'Download & Open' dog picture
      // which uses 'download' which is not the .defaultGroup
      // but the .await group so won't use the above config
        complete: const TaskNotification(
            'Download {filename}', 'Download complete'),
        tapOpensFile: true); // dog can also open directly from tap

    // Listen to updates and process
    FileDownloader().updates.listen((update) {
      switch (update) {
        case TaskStatusUpdate():
          if (update.task == backgroundDownloadTask) {
            buttonState = switch (update.status) {
              TaskStatus.running || TaskStatus.enqueued => ButtonState.pause,
              TaskStatus.paused => ButtonState.resume,
              _ => ButtonState.reset
            };
            setState(() {
              downloadTaskStatus = update.status;
            });
          }

        case TaskProgressUpdate():
          progressUpdateStream.add(update); // pass on to widget for indicator
      }
    });
    // Start the FileDownloader. Default start means database tracking and
    // proper handling of events that happened while the app was suspended,
    // and rescheduling of tasks that were killed by the user.
    // Start behavior can be configured with parameters
    FileDownloader().start();
  }

  /// Process the user tapping on a notification by printing a message
  void myNotificationTapCallback(Task task, NotificationType notificationType) {
    debugPrint(
        'Tapped notification $notificationType for taskId ${task.taskId}');
  }

  @override
  Widget build(BuildContext context) {
    final onMobile = Platform.isAndroid || Platform.isIOS;
    return MaterialApp(
      theme: ThemeData(
        useMaterial3: true,

        // Define the default brightness and colors.
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.purple,
          brightness: Brightness.light,
        ),
      ),
      home: Scaffold(
          appBar: AppBar(
            title: const Text('background_downloader example app'),
          ),
          body: Center(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Padding(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        children: [
                          Text('RequireWiFi setting',
                              style: Theme.of(context).textTheme.titleLarge),
                          const RequireWiFiChoice(),
                        ],
                      ),
                    ),
                    Padding(
                      padding: const EdgeInsets.all(16),
                      child: Row(
                        children: [
                          Expanded(
                              child: Text('Force error',
                                  style: Theme.of(context).textTheme.titleLarge)),
                          Switch(
                              value: downloadWithError,
                              onChanged: (value) {
                                setState(() {
                                  downloadWithError = value;
                                });
                              })
                        ],
                      ),
                    ),
                    Center(
                        child: ElevatedButton(
                          onPressed: processButtonPress,
                          child: Text(
                            buttonTexts[buttonState.index],
                          ),
                        )),
                    Padding(
                      padding: const EdgeInsets.all(16.0),
                      child: Row(
                        children: [
                          const Expanded(child: Text('File download status:')),
                          Text('${downloadTaskStatus ?? "undefined"}')
                        ],
                      ),
                    ),
                    const Divider(
                      height: 30,
                      thickness: 5,
                      color: Colors.blueGrey,
                    ),
                    Center(
                        child: ElevatedButton(
                            onPressed:
                            loadAndOpenInProgress ? null : processLoadAndOpen,
                            child: Text(
                              Platform.isIOS
                                  ? 'Load, open and add'
                                  : Platform.isAndroid
                                  ? 'Load, open and move'
                                  : 'Load & Open',
                            ))),
                    Center(
                        child: Text(
                          loadAndOpenInProgress ? 'Busy' : '',
                        )),
                    const Divider(
                      height: 30,
                      thickness: 5,
                      color: Colors.blueGrey,
                    ),
                    Center(
                        child: ElevatedButton(
                            onPressed:
                            loadABunchInProgress ? null : processLoadABunch,
                            child: const Text('Load a bunch'))),
                    Center(child: Text(loadABunchInProgress ? 'Enqueueing' : '')),
                    const Divider(
                      height: 30,
                      thickness: 5,
                      color: Colors.blueGrey,
                    ),
                    Center(
                      child: ElevatedButton(
                        onPressed:
                        loadBackgroundInProgress ? null : processLoadBackground,
                        child: const Text(
                          'Load in background',
                        ),
                      ),
                    ),
                    Center(
                      child: Text(
                        loadBackgroundInProgress
                            ? 'Working...'
                            : loadBackgroundResult ?? '',
                      ),
                    ),
                    if (onMobile)
                      const Divider(
                        height: 30,
                        thickness: 5,
                        color: Colors.blueGrey,
                      ),
                    if (onMobile)
                      Center(
                        child: ElevatedButton(
                          onPressed: processPickDirectory,
                          child: const Text(
                            'Pick destination',
                          ),
                        ),
                      ),
                  ],
                ),
              )),
          bottomSheet: DownloadProgressIndicator(progressUpdateStream.stream,
              showPauseButton: true,
              showCancelButton: true,
              backgroundColor: Colors.grey,
              maxExpandable: 3)),
    );
  }

  /// Process center button press (initially 'Download' but the text changes
  /// based on state)
  Future<void> processButtonPress() async {
    switch (buttonState) {
      case ButtonState.download:
      // start download
        await getPermission(PermissionType.notifications);
        backgroundDownloadTask = DownloadTask(
            url: downloadWithError
                ? 'https://avmaps-dot-bbflightserver-hrd.appspot.com/public/get_current_app_data' // returns 403 status code
                : 'https://storage.googleapis.com/approachcharts/test/5MB-test.ZIP',
            filename: 'zipfile.zip',
            directory: 'my/directory',
            baseDirectory: BaseDirectory.applicationDocuments,
            updates: Updates.statusAndProgress,
            retries: 3,
            allowPause: true,
            metaData: '<example metaData>',
            displayName: 'My display name');
        await FileDownloader().enqueue(backgroundDownloadTask!);
        break;
      case ButtonState.cancel:
      // cancel download
        if (backgroundDownloadTask != null) {
          await FileDownloader()
              .cancelTasksWithIds([backgroundDownloadTask!.taskId]);
        }
        break;
      case ButtonState.reset:
        downloadTaskStatus = null;
        buttonState = ButtonState.download;
        break;
      case ButtonState.pause:
        if (backgroundDownloadTask != null) {
          await FileDownloader().pause(backgroundDownloadTask!);
        }
        break;
      case ButtonState.resume:
        if (backgroundDownloadTask != null) {
          await FileDownloader().resume(backgroundDownloadTask!);
        }
        break;
    }
    if (mounted) {
      setState(() {});
    }
  }

  /// Process 'Load & Open' button
  ///
  /// Loads a JPG of a dog and launches viewer using [openFile]
  Future<void> processLoadAndOpen() async {
    if (!loadAndOpenInProgress) {
      await getPermission(PermissionType.notifications);
      var task = DownloadTask(
          url:
          'https://dl.daisy.org/samples/epub/valentin-hauy.epub',
          baseDirectory: BaseDirectory.applicationSupport,
          filename: 'test.epub');
      setState(() {
        loadAndOpenInProgress = true;
      });
      await FileDownloader().download(task);
      print(await FileDownloader().openFile(task: task));

      setState(() {
        loadAndOpenInProgress = false;
      });
    }
  }

  Future<void> processLoadABunch() async {
    if (!loadABunchInProgress) {
      setState(() {
        loadABunchInProgress = true;
      });
      await getPermission(PermissionType.notifications);
      for (var i = 0; i < 5; i++) {
        await FileDownloader().enqueue(DownloadTask(
            url:
            'https://storage.googleapis.com/approachcharts/test/5MB-test.ZIP',
            filename: 'File_${Random().nextInt(1000)}',
            group: 'bunch',
            updates: Updates.progress)); // must provide progress updates!
        await Future.delayed(const Duration(milliseconds: 500));
      }
      setState(() {
        loadABunchInProgress = false;
      });
    }
  }

  Future<void> processLoadBackground() async {

  }

  Future<void> processPickDirectory() async {
    final uri = await FileDownloader().uri.pickDirectory();
    if (uri == null) {
      log.warning('Could not get a URI');
      return;
    }
    log.fine('Uri = $uri');
    final task = UriDownloadTask(
        url:
        'https://i2.wp.com/www.skiptomylou.org/wp-content/uploads/2019/06/dog-drawing.jpg',
        directoryUri: uri,
        filename: '?');
    final result = await FileDownloader().download(task);
    final resultTask = result.task as UriDownloadTask;
    log.info('Download to URI completed with taskStatus ${result.status}');
    log.info('Downloaded file is at ${resultTask.fileUri}');
    log.info('Downloaded file name is ${resultTask.filename}');
  }

  /// Attempt to get permissions if not already granted
  Future<void> getPermission(PermissionType permissionType) async {
    var status = await FileDownloader().permissions.status(permissionType);
    if (status != PermissionStatus.granted) {
      if (await FileDownloader()
          .permissions
          .shouldShowRationale(permissionType)) {
        debugPrint('Showing some rationale');
      }
      status = await FileDownloader().permissions.request(permissionType);
      debugPrint('Permission for $permissionType was $status');
    }
  }
}

/// Segmented button with WiFi requirement states
class RequireWiFiChoice extends StatefulWidget {
  const RequireWiFiChoice({super.key});

  @override
  State<RequireWiFiChoice> createState() => _RequireWiFiChoiceState();
}

class _RequireWiFiChoiceState extends State<RequireWiFiChoice> {
  RequireWiFi requireWiFi = RequireWiFi.asSetByTask;

  @override
  void initState() {
    super.initState();
    FileDownloader().getRequireWiFiSetting().then((value) {
      setState(() {
        requireWiFi = value;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return SegmentedButton<RequireWiFi>(
      segments: const <ButtonSegment<RequireWiFi>>[
        ButtonSegment<RequireWiFi>(
            value: RequireWiFi.asSetByTask, label: Text('Task')),
        ButtonSegment<RequireWiFi>(
            value: RequireWiFi.forAllTasks, label: Text('All')),
        ButtonSegment<RequireWiFi>(
          value: RequireWiFi.forNoTasks,
          label: Text('None'),
        ),
      ],
      selected: <RequireWiFi>{requireWiFi},
      onSelectionChanged: (Set<RequireWiFi> newSelection) {
        setState(() {
          // By default there is only a single segment that can be
          // selected at one time, so its value is always the first
          // item in the selected set.
          requireWiFi = newSelection.first;
          unawaited(FileDownloader()
              .requireWiFi(requireWiFi, rescheduleRunningTasks: true));
        });
      },
    );
  }
}

enum ButtonState { download, cancel, pause, resume, reset }

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions