copyFiles static method

Future<String?> copyFiles(
  1. String source,
  2. String target, {
  3. List<String> fileType = const [],
  4. String mode = "release",
  5. String? flavor,
})

Copies files from source directory to target directory with optional filtering.

This method recursively copies files from the source directory to the target directory. It can filter files by file type and supports different build modes.

Parameters:

  • source - The source directory path to copy from
  • target - The target directory path to copy to
  • fileType - List of file extensions to filter by (without dots, e.g., ['apk', 'aab'])
  • mode - Build mode filter (defaults to "release")

Returns the path of the first copied file.

Throws an Exception if:

  • No files are found in the source directory
  • No files match the specified file type filter

Implementation

static Future<String?> copyFiles(
  String source,
  String target, {
  List<String> fileType = const [],
  String mode = "release",
  String? flavor,
}) async {
  final sourceDir = Directory(source);
  if (!sourceDir.existsSync()) {
    throw Exception("No files found in ${sourceDir.path}");
  }

  final targetDir = Directory(target);
  if (!targetDir.existsSync()) {
    await targetDir.create(recursive: true);
  }

  final entities = await sourceDir.list(recursive: true).toList();
  final extensions = fileType.map((value) => value.toLowerCase()).toSet();

  final candidates = <_FileCandidate>[];
  for (final entity in entities) {
    if (entity is! File) continue;
    if (extensions.isNotEmpty) {
      final extension = path.extension(entity.path).toLowerCase();
      if (!extensions.contains(extension.replaceFirst('.', ''))) {
        continue;
      }
    }

    final sourcePath = entity.path.toLowerCase();
    final score = _artifactScore(sourcePath, mode: mode, flavor: flavor);
    final modifiedAt = entity.lastModifiedSync();
    candidates.add(
      _FileCandidate(path: entity.path, score: score, modifiedAt: modifiedAt),
    );
  }

  if (candidates.isEmpty) {
    throw Exception(
      "Does not contain any files with the specified type: $fileType",
    );
  }

  candidates.sort((a, b) {
    final scoreComparison = b.score.compareTo(a.score);
    if (scoreComparison != 0) return scoreComparison;
    return b.modifiedAt.compareTo(a.modifiedAt);
  });

  final logger = ColorizeLogger();

  // Scoring is relative, so a directory holding nothing but `app-debug.apk`
  // makes that file the best candidate and ships it as the release. A build
  // mode that is named in the path but is not the one asked for is a
  // different artifact, not a lower-ranked one.
  if (mode.isNotEmpty) {
    final wrongMode = candidates
        .where((candidate) => _namesAnotherMode(candidate.path, mode))
        .toList();
    if (wrongMode.length == candidates.length) {
      throw Exception(
        "No $mode artifact found in ${sourceDir.path}. "
        "The closest match is ${path.basename(candidates.first.path)}, which "
        "is not a $mode build — run the build for this mode first.",
      );
    }
    for (final dropped in wrongMode) {
      logger.logDebug(
        "Ignoring ${path.basename(dropped.path)}: it is not a $mode build",
      );
    }
    candidates.removeWhere(wrongMode.contains);
  }

  // Keep only the best matching tier. A `--split-per-abi` build legitimately
  // produces several artifacts, and those all score identically - but a stale
  // `app-debug.apk` from an earlier run scores lower, and copying it into the
  // release output directory is how a debug binary ends up in a release.
  final bestScore = candidates.first.score;
  final selected =
      candidates.where((candidate) => candidate.score == bestScore).toList();

  for (final dropped in candidates.where((c) => c.score != bestScore)) {
    logger.logDebug(
      "Ignoring ${path.basename(dropped.path)}: does not match "
      "mode '$mode'${flavor == null ? '' : " / flavor '$flavor'"}",
    );
  }

  final output = <String>[];
  final keptNames = <String>{};
  for (final candidate in selected) {
    final fileName = path.basename(candidate.path);
    final targetPath = path.join(target, fileName);
    keptNames.add(fileName);
    output.add(targetPath);

    // Pointing `output:` at the directory the build already writes to makes
    // source and target the same file. Deleting the target first and then
    // copying "from" it destroyed the artifact outright, and the caller only
    // reported a failed copy.
    if (_isSameFile(candidate.path, targetPath)) {
      logger.logDebug("$fileName is already in place; leaving it alone");
      continue;
    }

    if (File(targetPath).existsSync()) {
      await File(targetPath).delete();
    }
    await File(candidate.path).copy(targetPath);
  }

  await _pruneStaleArtifacts(targetDir, extensions, keptNames, logger);

  return output.first;
}