run method
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.',
);
}
}