run method

  1. @override
Future<void> run()
override

Runs this command.

The return value is wrapped in a Future if necessary and returned by CommandRunner.runCommand.

Implementation

@override
Future<void> run() async {
  final ci = argResults!['ci'] as bool;
  final dryRun = argResults!['dry-run'] as bool;
  AppLogger.ci = ci || Platform.environment.containsKey('CI');
  AppLogger.verbose = globalResults?['verbose'] as bool? ?? false;

  // 1. Find project root + config
  final projectRoot = AppConfig.findRoot(Directory.current.path);
  if (projectRoot == null) {
    AppLogger.error(
      'appflight.json not found. Run `appflight init` first.',
    );
    exit(ExitCodes.noProjectConfig);
  }

  final config = AppConfig.load(projectRoot)!;

  // 2. Resolve flavor → AppEntry
  final flavorArg = argResults!['flavor'] as String?;
  final isFlavored =
      !(config.apps.length == 1 && config.apps.containsKey('default'));
  final String flavorKey;
  final AppEntry entry;

  if (isFlavored) {
    // Flavored project — --flavor is required
    if (flavorArg == null) {
      AppLogger.error(
        'This project has flavors configured — specify one with --flavor.\n'
        '  Available: ${config.apps.keys.join(', ')}\n'
        '  Example: appflight upload --flavor stage',
      );
      exit(ExitCodes.usage);
    }
    if (!config.apps.containsKey(flavorArg)) {
      AppLogger.error(
        'Unknown flavor "$flavorArg".\n'
        '  Available: ${config.apps.keys.join(', ')}',
      );
      exit(ExitCodes.usage);
    }
    flavorKey = flavorArg;
    entry = config.apps[flavorArg]!;
  } else {
    // No-flavor project — upload default
    flavorKey = 'default';
    entry = config.apps['default']!;
  }

  // 3. Resolve APK file
  final fileArg = argResults!['file'] as String?;
  final apkPath = fileArg ?? p.join(projectRoot, entry.apkPath);
  final apkFile = File(apkPath);

  if (!apkFile.existsSync()) {
    final buildHint = config.projectType == 'react-native'
        ? (flavorKey == 'default'
            ? 'cd android && ./gradlew assembleRelease'
            : 'cd android && ./gradlew assemble${_capitalize(flavorKey)}Release')
        : config.projectType == 'kotlin'
            ? (flavorKey == 'default'
                ? './gradlew assembleRelease'
                : './gradlew assemble${_capitalize(flavorKey)}Release')
            : (flavorKey == 'default'
                ? 'flutter build apk --release'
                : 'flutter build apk --flavor $flavorKey --release');
    AppLogger.error('APK not found at $apkPath');
    AppLogger.error('Did you forget to run `$buildHint`?');
    exit(ExitCodes.apkNotFound);
  }
  if (!apkPath.endsWith('.apk')) {
    AppLogger.error('File must be an .apk: $apkPath');
    exit(ExitCodes.usage);
  }

  // 4. Resolve version
  final versionArg = argResults!['version'] as String?;
  final buildNumberArg = argResults!['build-number'] as String?;
  final String rawVersion;
  if ((config.projectType == 'react-native' ||
          config.projectType == 'kotlin') &&
      versionArg == null) {
    final gradlePath = GradleParser.resolvePath(
      projectRoot,
      projectType: config.projectType,
    );
    final gradleContent =
        gradlePath != null ? File(gradlePath).readAsStringSync() : '';
    final versionName = GradleParser.parseVersionName(gradleContent);
    final versionCode = GradleParser.parseVersionCode(gradleContent);
    rawVersion = '$versionName+$versionCode';
  } else {
    final pubspec = FlutterDetector.parsePubspec(projectRoot);
    rawVersion = versionArg ?? pubspec['version'] ?? '1.0.0';
  }
  final version = buildNumberArg != null
      ? '${rawVersion.split('+').first}+$buildNumberArg'
      : rawVersion;

  // 5. Load credentials
  final creds = Credentials.load();
  if (creds == null) {
    AppLogger.error(
      'Not logged in. Run `appflight login` or set APPFLIGHT_API_KEY.',
    );
    exit(ExitCodes.noCredentials);
  }

  final fileSize = await apkFile.length();
  final sizeMb = (fileSize / (1024 * 1024)).toStringAsFixed(1);

  // 7. Print summary
  AppLogger.info('');
  AppLogger.info('  Package  : ${entry.packageName}');
  AppLogger.info('  Version  : $version');
  AppLogger.info('  APK      : $apkPath ($sizeMb MB)');
  AppLogger.info('  Env      : ${Endpoints.env}');
  AppLogger.info('');

  if (dryRun) {
    AppLogger.warn('Dry run — nothing uploaded.');
    return;
  }

  final dio = ApiClient.instance.forKey(creds.apiKey);

  // 8. Request signed upload URL
  final analyticsFlavorParam = isFlavored ? flavorArg : null;
  CliAnalytics.ins.logUploadStarted(
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );
  AppLogger.info('Requesting upload URL…');
  final urlResult = await UploadService.ins.requestUploadUrl(
    dio: dio,
    packageName: entry.packageName,
    version: version,
    fileSize: fileSize,
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );

  // 9. PUT APK bytes directly to Firebase Storage signed URL
  AppLogger.info('Uploading APK…');
  await UploadService.ins.putApk(
    uploadUrl: urlResult.uploadUrl,
    apkFile: apkFile,
    fileSize: fileSize,
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );

  // 10. Register APK metadata — backend generates the permanent download URL
  AppLogger.info('Registering build…');
  final registerResult = await UploadService.ins.registerApk(
    dio: dio,
    packageName: entry.packageName,
    version: version,
    storagePath: urlResult.storagePath,
    fileSize: fileSize,
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );

  CliAnalytics.ins.logUploadCompleted(
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );
  AppLogger.info('');
  AppLogger.success(
    'v$version of ${entry.packageName} uploaded successfully.',
  );
  if (registerResult.downloadUrl.isNotEmpty) {
    AppLogger.info('Download : ${registerResult.downloadUrl}');
  }
  if (registerResult.rolledOver.isNotEmpty) {
    final rolledOver = registerResult.rolledOver;
    final dropped = rolledOver.map((r) => 'v${r['version']}').join(', ');
    AppLogger.info(
      'Rolled over oldest build${rolledOver.length > 1 ? 's' : ''} ($dropped) to stay at your plan cap.',
    );
  }
}