verifyAndroidDelivery function

Future<DeliveryVerificationReport> verifyAndroidDelivery({
  1. required String aabPath,
  2. required String outputDirectory,
  3. String? deviceSpecPath,
  4. String? bundletoolPath,
  5. String? serial,
  6. bool install = false,
  7. bool launch = false,
  8. String? packageName,
  9. String? activity,
  10. String? adbPath,
  11. String? keystorePath,
  12. bool verbose = false,
  13. String? reportPath,
  14. Future<String> keystore()?,
  15. Future<BundletoolCommandResult> runBundletool(
    1. List<String> args
    )?,
  16. Future<void> installApks(
    1. List<String> apks
    )?,
  17. Future<void> launchApp()?,
})

Runs the offline/online portions of release verification. The callback seams make the report testable without Java, bundletool, adb, or a device.

Implementation

Future<DeliveryVerificationReport> verifyAndroidDelivery({
  required String aabPath,
  required String outputDirectory,
  String? deviceSpecPath,
  String? bundletoolPath,
  String? serial,
  bool install = false,
  bool launch = false,
  String? packageName,
  String? activity,
  String? adbPath,
  String? keystorePath,
  bool verbose = false,
  String? reportPath,
  Future<String> Function()? keystore,
  Future<BundletoolCommandResult> Function(List<String> args)? runBundletool,
  Future<void> Function(List<String> apks)? installApks,
  Future<void> Function()? launchApp,
}) async {
  final file = File(aabPath);
  // Read the bundle first: the gate below must be observable (a missing
  // artifact yields a failed report, not an exception).
  final List<int> bytes;
  try {
    bytes = await file.readAsBytes();
  } on FileSystemException catch (error) {
    return DeliveryVerificationReport(
      artifact: aabPath,
      artifactSha256: '',
      artifactBytes: 0,
      abis: const [],
      gates: {'artifact_exists': false},
      metadata: {'error': error.message},
    );
  }
  final archive = ZipDecoder().decodeBytes(bytes);
  final abis = <String>{};
  final entries = <String>[];
  for (final entry in archive) {
    entries.add(entry.name);
    final match = RegExp('(?:^|/)lib/([^/]+)/').firstMatch(entry.name);
    if (match != null) abis.add(match.group(1)!);
  }
  final gates = <String, bool>{'artifact_exists': true};
  final sha = sha256.convert(bytes).toString();
  final output = Directory(outputDirectory);
  await output.create(recursive: true);
  final tool = await findBundletool(explicitPath: bundletoolPath);
  Future<BundletoolCommandResult> run(List<String> args) async {
    if (runBundletool != null) return runBundletool(args);
    if (tool == null) {
      return const BundletoolCommandResult(
        exitCode: 127,
        stdout: '',
        stderr: 'bundletool not found',
      );
    }
    final command = tool.endsWith('.jar')
        ? ['java', '-jar', tool, ...args]
        : [tool, ...args];
    if (verbose) print('   Running: ${command.join(' ')}');
    final result = await Process.run(command.first, command.sublist(1));
    return BundletoolCommandResult(
      exitCode: result.exitCode,
      stdout: '${result.stdout}',
      stderr: '${result.stderr}',
    );
  }

  final validate = await run(['validate', '--bundle=$aabPath']);
  gates['bundletool_validate'] = validate.exitCode == 0;
  String? spec = deviceSpecPath;
  if (spec == null && tool != null && (install || launch)) {
    spec = p.join(outputDirectory, 'device-spec.json');
    final result = await run(['get-device-spec', '--output=$spec']);
    gates['device_spec'] = result.exitCode == 0 && File(spec).existsSync();
  } else if (spec != null) {
    gates['device_spec'] = File(spec).existsSync();
  }

  List<String>? generated;
  if (spec != null && File(spec).existsSync()) {
    final apksPath = p.join(outputDirectory, 'device.apks');
    // An injected runner is commonly used for offline tests and does not
    // need a host Android SDK/debug keystore.
    final ks =
        keystorePath ??
        (runBundletool == null ? await (keystore ?? debugKeystore)() : '');
    final result = await run([
      'build-apks',
      '--bundle=$aabPath',
      '--output=$apksPath',
      '--device-spec=$spec',
      '--ks=$ks',
      '--ks-key-alias=androiddebugkey',
      '--ks-pass=pass:android',
      '--overwrite',
    ]);
    gates['device_specific_apks'] =
        result.exitCode == 0 && File(apksPath).existsSync();
    if (gates['device_specific_apks']!) {
      final decoded = ZipDecoder().decodeBytes(
        await File(apksPath).readAsBytes(),
      );
      generated = [];
      for (final entry in decoded) {
        if (!entry.isFile || !entry.name.endsWith('.apk')) continue;
        final destination = p.join(outputDirectory, p.basename(entry.name));
        await File(destination).writeAsBytes(entry.content as List<int>);
        generated.add(destination);
      }
      gates['apk_extracted'] = generated.isNotEmpty;
    }
  }
  final apkSizes = <String, int>{
    for (final apk in generated ?? <String>[]) apk: File(apk).lengthSync(),
  };
  var installAttempted = false;
  var launchAttempted = false;
  if (install && generated != null && generated.isNotEmpty) {
    installAttempted = true;
    try {
      if (installApks != null) {
        await installApks(generated);
      } else {
        final adb = AdbTool(adbPath: adbPath ?? 'adb', serial: serial);
        // install-multiple is intentionally kept in the device helper rather
        // than reimplementing adb invocation in the CLI.
        await adb.installMultiple(generated);
      }
      gates['install'] = true;
    } catch (_) {
      gates['install'] = false;
    }
  }
  if (launch) {
    launchAttempted = true;
    try {
      if (launchApp != null) {
        await launchApp();
      } else if (packageName != null && activity != null) {
        await AdbTool(
          adbPath: adbPath ?? 'adb',
          serial: serial,
        ).launch(packageName, activity);
      } else {
        throw ArgumentError('packageName and activity are required for launch');
      }
      gates['launch'] = true;
    } catch (_) {
      gates['launch'] = false;
    }
  }
  final report = DeliveryVerificationReport(
    artifact: aabPath,
    artifactSha256: sha,
    artifactBytes: bytes.length,
    abis: (abis.toList()..sort()),
    gates: gates,
    deviceSpec: spec,
    generatedApks: generated,
    installAttempted: installAttempted,
    launchAttempted: launchAttempted,
    metadata: {'zip_entries': entries.length, 'apk_sizes': apkSizes},
  );
  if (reportPath != null) {
    final reportFile = File(reportPath);
    await reportFile.parent.create(recursive: true);
    await reportFile.writeAsString(report.toJsonString());
  }
  return report;
}