downloadFile method

Future<Map<String, dynamic>> downloadFile(
  1. String uuid, {
  2. String? savePath,
  3. int maxConcurrentChunks = kDefaultDownloadConcurrency,
  4. ChunkSemaphore? globalChunkSlots,
  5. dynamic onProgress(
    1. int bytesDownloaded,
    2. int totalBytes
    )?,
})

Download a single file by UUID.

Implementation

Future<Map<String, dynamic>> downloadFile(
  String uuid, {
  String? savePath,
  int maxConcurrentChunks = kDefaultDownloadConcurrency,
  ChunkSemaphore? globalChunkSlots,
  Function(int bytesDownloaded, int totalBytes)? onProgress,
}) async {
  api.log('Downloading file: $uuid');

  final info = await api.post('/v3/file', {'uuid': uuid});
  final d = info['data'];
  final metaStr = await crypto.tryDecrypt(d['metadata'], masterKeys);
  final meta = json.decode(metaStr);
  final keyBytes = crypto.decodeUniversalKey(meta['key']);
  final chunks = int.parse(d['chunks'].toString());
  final host = 'https://egest.filen.io';

  final filename = meta['name'] ?? 'file';
  final fileSize = meta['size'] ?? 0;
  final modificationTime = meta['lastModified'];

  if (onProgress == null) {
    print('   📄 File: $filename (${formatSize(fileSize)})');
  }

  final targetPath = savePath ?? filename;

  // Tiny files keep the simple sequential streaming path.
  final useConcurrency =
      maxConcurrentChunks > 1 && chunks > kSequentialDownloadChunkThreshold;

  int bytesDownloaded = 0;

  if (!useConcurrency) {
    final sink = File(targetPath).openWrite();
    try {
      for (var i = 0; i < chunks; i++) {
        // Shared batch budget (Step 2): one permit per chunk in flight across
        // the batch. No-op for a lone file (globalChunkSlots == null).
        if (globalChunkSlots != null) await globalChunkSlots.acquire();
        Uint8List decrypted;
        try {
          decrypted = await _fetchChunk(host, d, uuid, i, keyBytes);
        } finally {
          if (globalChunkSlots != null) globalChunkSlots.release();
        }
        sink.add(decrypted);
        bytesDownloaded += decrypted.length;
        if (onProgress != null) onProgress(bytesDownloaded, fileSize);
      }
    } finally {
      await sink.close();
    }
  } else {
    // Fetch N chunks concurrently and write each at its fixed offset (every
    // plaintext chunk is exactly 1 MB except the last), so out-of-order
    // completion still reassembles byte-exactly. A 1-permit lock serialises
    // the seek+write critical section. At most N decrypted chunks are live.
    final raf = await File(targetPath).open(mode: FileMode.write);
    try {
      if (fileSize is int && fileSize > 0) {
        await raf.truncate(fileSize);
      }
      final sem = ChunkSemaphore(maxConcurrentChunks);
      final writeLock = ChunkSemaphore(1);
      final inflight = <Future<void>>[];
      Object? firstError;

      for (var i = 0; i < chunks; i++) {
        if (firstError != null) break;
        await sem.acquire();
        if (firstError != null) {
          sem.release();
          break;
        }
        final idx = i;
        inflight.add(() async {
          try {
            // Shared batch budget (Step 2): bound chunk fetches in flight
            // across the WHOLE batch, not just this file.
            if (globalChunkSlots != null) await globalChunkSlots.acquire();
            Uint8List decrypted;
            try {
              decrypted = await _fetchChunk(host, d, uuid, idx, keyBytes);
            } finally {
              if (globalChunkSlots != null) globalChunkSlots.release();
            }
            await writeLock.acquire();
            try {
              await raf.setPosition(idx * _kDownloadChunkSize);
              await raf.writeFrom(decrypted);
            } finally {
              writeLock.release();
            }
            bytesDownloaded += decrypted.length;
            if (onProgress != null) onProgress(bytesDownloaded, fileSize);
          } catch (e) {
            firstError ??= e;
          } finally {
            sem.release();
          }
        }());
      }

      await Future.wait(inflight);
      if (firstError != null) throw firstError!;
    } finally {
      await raf.close();
    }
  }

  return {
    'data': await File(targetPath).readAsBytes(),
    'filename': filename,
    'modificationTime': modificationTime,
  };
}