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')
        : (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' && versionArg == null) {
    final gradlePath = GradleParser.resolvePath(projectRoot);
    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 String uploadUrl;
  final String storagePath;
  String downloadUrl;

  try {
    final resp = await dio.post(
      Endpoints.uploadUrl,
      data: {
        'packageName': entry.packageName,
        'version': version,
        'size': fileSize,
      },
    );
    final data = resp.data['data'] as Map<String, dynamic>;
    uploadUrl = data['uploadUrl'] as String;
    storagePath = data['storagePath'] as String;
  } on DioException catch (e) {
    CliAnalytics.ins.logUploadFailed(
      errorCode: 'upload_url_error',
      projectType: config.projectType,
      flavor: analyticsFlavorParam,
    );
    _handleUploadUrlError(e);
    return; // unreachable — _handleUploadUrlError always exits
  }

  // 9. PUT APK bytes directly to Firebase Storage signed URL
  AppLogger.info('Uploading APK…');
  try {
    final storageDio =
        Dio(); // No auth header — signed URL is self-authenticating
    await storageDio.put(
      uploadUrl,
      data: apkFile.openRead(),
      options: Options(
        headers: {
          'Content-Type': 'application/vnd.android.package-archive',
          'Content-Length': fileSize,
        },
        // Disable Dio's default content-type so our header wins
        contentType: 'application/vnd.android.package-archive',
      ),
      onSendProgress: AppLogger.ci
          ? null
          : (sent, total) {
              if (total <= 0) return;
              final pct = (sent / total * 100).round();
              stdout.write(
                '\r  [${'=' * (pct ~/ 5)}${' ' * (20 - pct ~/ 5)}] $pct%  ',
              );
            },
    );
    if (!AppLogger.ci) stdout.writeln('');
  } on DioException catch (e) {
    CliAnalytics.ins.logUploadFailed(
      errorCode: 'storage_failed',
      projectType: config.projectType,
      flavor: analyticsFlavorParam,
    );
    AppLogger.error('Upload to Firebase Storage failed: ${e.message}');
    exit(ExitCodes.storageFailed);
  }

  // 10. Register APK metadata — backend generates the permanent download URL
  AppLogger.info('Registering build…');
  var rolledOver = const <Map<String, dynamic>>[];
  try {
    final regResp = await dio.post(
      Endpoints.uploadApk,
      data: {
        'packageName': entry.packageName,
        'version': version,
        'storagePath': storagePath,
        'size': fileSize,
      },
    );
    final data = regResp.data['data'] as Map<String, dynamic>?;
    downloadUrl = (data?['downloadUrl'] as String?) ?? '';
    final rolled = data?['rolledOver'] as List?;
    if (rolled != null) {
      rolledOver = rolled
          .whereType<Map>()
          .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
          .toList();
    }
  } on DioException catch (e) {
    final status = e.response?.statusCode;
    final code = e.response?.data?['code'] as String?;
    final msg = e.response?.data?['message'] as String? ??
        e.message ??
        'Unknown error';
    if (status == 403 && code == 'PLAN_LIMIT') {
      CliAnalytics.ins.logPlanLimitHit(projectType: config.projectType);
      AppLogger.error(
        'Upload blocked — you\'ve reached your APK limit for this app.',
      );
      AppLogger.error(
        'Free plan: 5 APKs per app. Upgrade in the AppFlight mobile app.',
      );
      AppLogger.error('Settings → Subscription → Upgrade to First Class');
      AppLogger.debug('Raw response: ${e.response?.data}');
      exit(ExitCodes.planLimit);
    }
    CliAnalytics.ins.logUploadFailed(
      errorCode: 'metadata_failed',
      projectType: config.projectType,
      flavor: analyticsFlavorParam,
    );
    AppLogger.error('Metadata registration failed: $msg');
    exit(ExitCodes.storageFailed);
  }

  CliAnalytics.ins.logUploadCompleted(
    projectType: config.projectType,
    flavor: analyticsFlavorParam,
  );
  AppLogger.info('');
  AppLogger.success(
    'v$version of ${entry.packageName} uploaded successfully.',
  );
  if (downloadUrl.isNotEmpty) AppLogger.info('Download : $downloadUrl');
  if (rolledOver.isNotEmpty) {
    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.',
    );
  }
}