downloadAndExtract static method

Future<void> downloadAndExtract({
  1. required List<String> urls,
  2. required File zipFile,
  3. required Directory destinationDir,
  4. required ZipDownloadProgressCallback onProgress,
  5. Duration connectTimeout = const Duration(seconds: 20),
  6. Duration receiveTimeout = const Duration(minutes: 2),
  7. Map<String, String>? headers,
  8. int minZipSizeBytes = 1024 * 1024,
  9. String logName = 'ZipDownload',
})

Implementation

static Future<void> downloadAndExtract({
  required List<String> urls,
  required File zipFile,
  required Directory destinationDir,
  required ZipDownloadProgressCallback onProgress,
  Duration connectTimeout = const Duration(seconds: 20),
  Duration receiveTimeout = const Duration(minutes: 2),
  Map<String, String>? headers,
  int minZipSizeBytes = 1024 * 1024,
  String logName = 'ZipDownload',
}) async {
  if (urls.isEmpty) {
    throw ArgumentError('urls must not be empty');
  }

  if (!await destinationDir.exists()) {
    await destinationDir.create(recursive: true);
  }

  final dio = Dio()
    ..options.connectTimeout = connectTimeout
    ..options.receiveTimeout = receiveTimeout;

  final effectiveHeaders = <String, String>{
    'User-Agent': 'Flutter/Quran-Library',
    'Accept': '*/*',
    'Accept-Encoding': 'identity',
    ...?headers,
  };

  bool extractionSucceeded = false;

  for (final url in urls) {
    try {
      log('Attempting to download from: $url', name: logName);

      final response = await dio.get(
        url,
        options: Options(
          responseType: ResponseType.stream,
          followRedirects: true,
          sendTimeout: const Duration(seconds: 30),
          headers: effectiveHeaders,
        ),
      );

      if (response.statusCode != 200) {
        log('Failed to connect to $url: ${response.statusCode}',
            name: logName);
        continue;
      }

      final contentType =
          response.headers.value(Headers.contentTypeHeader) ?? '';
      final headerLenStr =
          response.headers.value(Headers.contentLengthHeader);
      final headerLen = int.tryParse(headerLenStr ?? '0') ?? 0;

      if (contentType.startsWith('text/') || contentType.contains('html')) {
        log(
          'Rejected $url due to suspicious content-type: $contentType',
          name: logName,
        );
        continue;
      }

      if (headerLen > 0 && headerLen < minZipSizeBytes) {
        log(
          'Rejected $url due to too small content-length: $headerLen',
          name: logName,
        );
        continue;
      }

      final sink = zipFile.openWrite();
      int downloaded = 0;
      final completer = Completer<void>();

      (response.data as ResponseBody).stream.listen(
        (chunk) {
          downloaded += chunk.length;
          sink.add(chunk);
          if (headerLen > 0) {
            onProgress(downloaded / headerLen * 100);
          }
        },
        onDone: () async {
          await sink.flush();
          await sink.close();
          completer.complete();
        },
        onError: (e) async {
          await sink.close();
          completer.completeError(e);
        },
        cancelOnError: true,
      );

      await completer.future;

      final size = await zipFile.length();
      log('Downloaded ZIP file size: $size bytes', name: logName);
      if (size < minZipSizeBytes) {
        log('Zip too small, trying next mirror...', name: logName);
        try {
          await zipFile.delete();
        } catch (_) {}
        continue;
      }

      try {
        final bytes = await zipFile.readAsBytes();
        final archive = ZipDecoder().decodeBytes(bytes);
        if (archive.isEmpty) {
          throw const FormatException(
            'Failed to extract ZIP file: Archive is empty',
          );
        }

        for (final file in archive) {
          final filename = '${destinationDir.path}/${file.name}';
          if (file.isFile) {
            final out = File(filename);
            await out.create(recursive: true);
            await out.writeAsBytes(file.content as List<int>);
          }
        }

        extractionSucceeded = true;
        break;
      } catch (e) {
        log('Failed to extract ZIP from $url: $e', name: logName);
        try {
          await zipFile.delete();
        } catch (_) {}
        continue;
      }
    } catch (e) {
      log('Download error with URL, trying next: $e', name: logName);
      try {
        if (await zipFile.exists()) await zipFile.delete();
      } catch (_) {}
      continue;
    }
  }

  if (!extractionSucceeded) {
    throw Exception(
        'All mirrors failed to provide a valid ZIP or extraction failed');
  }

  onProgress(100.0);
}