downloadMaterial method

  1. @override
Future<MaterialFileDto> downloadMaterial(
  1. MaterialRefDto material,
  2. String directory, {
  3. CancelToken? cancelToken,
  4. ProgressCallback? onReceiveProgress,
})
override

Resolves fresh access information and downloads a file into directory.

Keeps course selection locked until the transfer and partial-file cleanup finish. Preserves the required Referer and authentication cookies. Throws UnsupportedError for iStream resources. The caller owns cleanup of successfully downloaded files.

Implementation

@override
Future<MaterialFileDto> downloadMaterial(
  MaterialRefDto material,
  String directory, {
  CancelToken? cancelToken,
  ProgressCallback? onReceiveProgress,
}) => _withSelectedCourse(material.course, () async {
  if (cancelToken?.cancelError case final error?) throw error;
  final access = await _getMaterial(material, cancelToken: cancelToken);
  if (access.streamable || _isStream(access.downloadUrl)) {
    throw UnsupportedError('iStream downloads are not supported');
  }
  if (!['http', 'https'].contains(access.downloadUrl.scheme) ||
      access.downloadUrl.host.isEmpty) {
    throw StateError('Material download URL must be HTTP(S)');
  }
  var uri = access.downloadUrl;
  for (var redirects = 0; redirects <= 10; redirects++) {
    if (_isStream(uri)) {
      throw UnsupportedError('iStream downloads are not supported');
    }
    if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty) {
      throw StateError('Material download URL must be HTTP(S)');
    }
    final response = await _iSchoolPlusDio.getUri<ResponseBody>(
      uri,
      cancelToken: cancelToken,
      options: Options(
        responseType: .stream,
        validateStatus: (status) =>
            status != null &&
            ((status >= 200 && status < 300) ||
                [301, 302, 303, 307, 308].contains(status)),
        followRedirects: false,
        extra: {RedirectInterceptor.followRedirects: false},
        receiveTimeout: const Duration(minutes: 2),
        headers: {HttpHeaders.refererHeader: ?access.referer},
      ),
    );
    final body = response.data!;
    if ([301, 302, 303, 307, 308].contains(response.statusCode)) {
      await body.stream.listen((_) {}).cancel();
      final location = response.headers.value(HttpHeaders.locationHeader);
      if (location == null || redirects == 10) {
        throw StateError('Invalid material redirect');
      }
      uri = response.realUri.resolve(location);
      continue;
    }
    var fileName = materialFileName(
      disposition: response.headers.value('content-disposition'),
      title: material.title,
      contentType: response.headers.value(HttpHeaders.contentTypeHeader),
    );
    var file = File(p.join(directory, fileName));
    final iterator = StreamIterator(body.stream);
    cancelToken?.whenCancel.then((_) => iterator.cancel());
    RandomAccessFile? output;
    var completed = false;
    try {
      output = await file.open(mode: .write);
      var received = 0;
      // Keep only the signature, even when it spans multiple stream chunks.
      final leadingBytes = <int>[];
      final total =
          int.tryParse(
            response.headers.value(HttpHeaders.contentLengthHeader) ?? '',
          ) ??
          -1;
      while (await iterator.moveNext()) {
        if (cancelToken?.cancelError case final error?) throw error;
        final bytes = iterator.current;
        if (leadingBytes.length < 8) {
          leadingBytes.addAll(bytes.take(8 - leadingBytes.length));
        }
        await output.writeFrom(bytes);
        received += bytes.length;
        onReceiveProgress?.call(received, total);
      }
      if (cancelToken?.cancelError case final error?) throw error;
      await output.close();
      output = null;
      final resolvedName = materialFileName(
        disposition: response.headers.value('content-disposition'),
        title: material.title,
        contentType: response.headers.value(HttpHeaders.contentTypeHeader),
        leadingBytes: leadingBytes,
      );
      if (resolvedName != fileName) {
        file = await file.rename(p.join(directory, resolvedName));
        fileName = resolvedName;
      }
      if (cancelToken?.cancelError case final error?) throw error;
      completed = true;
      return (path: file.path, fileName: fileName);
    } finally {
      await iterator.cancel();
      await output?.close();
      if (!completed && await file.exists()) await file.delete();
    }
  }
  throw StateError('Too many material redirects');
}, cancelToken: cancelToken);