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')
: (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(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',
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',
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();
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',
flavor: analyticsFlavorParam,
);
AppLogger.error('Metadata registration failed: $msg');
exit(ExitCodes.storageFailed);
}
CliAnalytics.ins.logUploadCompleted(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.',
);
}
}