build_apk.dart 19.9 KB
Newer Older
1 2 3 4 5
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
6
import 'dart:convert' show JSON;
7 8 9 10
import 'dart:io';

import 'package:path/path.dart' as path;

11
import '../android/android_sdk.dart';
12
import '../android/gradle.dart';
yjbanov's avatar
yjbanov committed
13
import '../base/file_system.dart' show ensureDirectoryExists;
14
import '../base/logger.dart';
15
import '../base/os.dart';
16
import '../base/process.dart';
Devon Carew's avatar
Devon Carew committed
17
import '../base/utils.dart';
18
import '../build_info.dart';
19
import '../flx.dart' as flx;
20
import '../globals.dart';
21
import '../run.dart';
22
import '../services.dart';
23
import 'build_aot.dart';
24
import 'build.dart';
25

26 27
export '../android/android_device.dart' show AndroidDevice;

28
const String _kDefaultAndroidManifestPath = 'android/AndroidManifest.xml';
29
const String _kDefaultOutputPath = 'build/app.apk';
30
const String _kDefaultResourcesPath = 'android/res';
31
const String _kDefaultAssetsPath = 'android/assets';
32

33
const String _kFlutterManifestPath = 'flutter.yaml';
34
const String _kPackagesStatusPath = '.packages';
35

36 37 38 39 40
// Alias of the key provided in the Chromium debug keystore
const String _kDebugKeystoreKeyAlias = "chromiumdebugkey";

// Password for the Chromium debug keystore
const String _kDebugKeystorePassword = "chromium";
41 42 43 44 45 46 47 48 49 50 51 52 53 54

/// Copies files into a new directory structure.
class _AssetBuilder {
  final Directory outDir;

  Directory _assetDir;

  _AssetBuilder(this.outDir, String assetDirName) {
    _assetDir = new Directory('${outDir.path}/$assetDirName');
    _assetDir.createSync(recursive:  true);
  }

  void add(File asset, String relativePath) {
    String destPath = path.join(_assetDir.path, relativePath);
55
    ensureDirectoryExists(destPath);
56 57 58 59 60 61 62 63
    asset.copySync(destPath);
  }

  Directory get directory => _assetDir;
}

/// Builds an APK package using Android SDK tools.
class _ApkBuilder {
64
  final AndroidSdkVersion sdk;
65 66 67

  File _androidJar;
  File _aapt;
68
  File _dx;
69
  File _zipalign;
70 71 72 73 74 75 76 77
  File _jarsigner;

  _ApkBuilder(this.sdk) {
    _androidJar = new File(sdk.androidJarPath);
    _aapt = new File(sdk.aaptPath);
    _dx = new File(sdk.dxPath);
    _zipalign = new File(sdk.zipalignPath);
    _jarsigner = os.which('jarsigner');
78 79
  }

80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
  String checkDependencies() {
    if (!_androidJar.existsSync())
      return 'Cannot find android.jar at ${_androidJar.path}';
    if (!_aapt.existsSync())
      return 'Cannot find aapt at ${_aapt.path}';
    if (!_dx.existsSync())
      return 'Cannot find dx at ${_dx.path}';
    if (!_zipalign.existsSync())
      return 'Cannot find zipalign at ${_zipalign.path}';
    if (_jarsigner == null)
      return 'Cannot find jarsigner in PATH.';
    if (!_jarsigner.existsSync())
      return 'Cannot find jarsigner at ${_jarsigner.path}';
    return null;
  }

96
  void compileClassesDex(File classesDex, List<File> jars) {
97
    List<String> packageArgs = <String>[_dx.path,
98 99 100 101 102 103 104 105
      '--dex',
      '--force-jumbo',
      '--output', classesDex.path
    ];

    packageArgs.addAll(jars.map((File f) => f.path));

    runCheckedSync(packageArgs);
106 107
  }

108
  void package(File outputApk, File androidManifest, Directory assets, Directory artifacts, Directory resources, BuildMode buildMode) {
109
    List<String> packageArgs = <String>[_aapt.path,
110 111 112 113 114
      'package',
      '-M', androidManifest.path,
      '-A', assets.path,
      '-I', _androidJar.path,
      '-F', outputApk.path,
115
    ];
116 117
    if (buildMode == BuildMode.debug)
      packageArgs.add('--debug-mode');
118
    if (resources != null)
119
      packageArgs.addAll(<String>['-S', resources.absolute.path]);
120
    packageArgs.add(artifacts.path);
121
    runCheckedSync(packageArgs);
122 123
  }

124
  void sign(File keystore, String keystorePassword, String keyAlias, String keyPassword, File outputApk) {
125
    assert(_jarsigner != null);
126
    runCheckedSync(<String>[_jarsigner.path,
127 128
      '-keystore', keystore.path,
      '-storepass', keystorePassword,
129
      '-keypass', keyPassword,
130
      outputApk.path,
131
      keyAlias,
132 133 134 135
    ]);
  }

  void align(File unalignedApk, File outputApk) {
136
    runCheckedSync(<String>[_zipalign.path, '-f', '4', unalignedApk.path, outputApk.path]);
137 138 139
  }
}

140 141 142
class _ApkComponents {
  File manifest;
  File icuData;
143
  List<File> jars;
144
  List<Map<String, String>> services = <Map<String, String>>[];
145
  File libSkyShell;
146
  File debugKeystore;
147
  Directory resources;
148
  Map<String, File> extraFiles;
149 150
}

151
class ApkKeystoreInfo {
152 153 154
  ApkKeystoreInfo({ this.keystore, this.password, this.keyAlias, this.keyPassword }) {
    assert(keystore != null);
  }
155

156 157 158 159
  final String keystore;
  final String password;
  final String keyAlias;
  final String keyPassword;
160 161
}

162
class BuildApkCommand extends BuildSubCommand {
163 164
  BuildApkCommand() {
    usesTargetOption();
165
    addBuildModeFlags();
166 167
    usesPubOption();

168
    argParser.addOption('manifest',
169 170 171
      abbr: 'm',
      defaultsTo: _kDefaultAndroidManifestPath,
      help: 'Android manifest XML file.');
172
    argParser.addOption('resources',
173 174
      abbr: 'r',
      help: 'Resources directory path.');
175
    argParser.addOption('output-file',
176 177 178
      abbr: 'o',
      defaultsTo: _kDefaultOutputPath,
      help: 'Output APK file.');
179
    argParser.addOption('flx',
180 181
      abbr: 'f',
      help: 'Path to the FLX file. If this is not provided, an FLX will be built.');
182 183 184
    argParser.addOption('aot-path',
      help: 'Path to the ahead-of-time compiled snapshot directory.\n'
            'If this is not provided, an AOT snapshot will be built.');
185
    argParser.addOption('keystore',
186
      help: 'Path to the keystore used to sign the app.');
187
    argParser.addOption('keystore-password',
188
      help: 'Password used to access the keystore.');
189
    argParser.addOption('keystore-key-alias',
190
      help: 'Alias of the entry within the keystore.');
191
    argParser.addOption('keystore-key-password',
192
      help: 'Password for the entry within the keystore.');
193 194
  }

195 196 197 198
  @override
  final String name = 'apk';

  @override
199
  final String description = 'Build an Android APK file from your app.\n\n'
200 201
    'This command can build debug and release versions of your application. \'debug\' builds support\n'
    'debugging and a quick development cycle. \'release\' builds don\'t support debugging and are\n'
202
    'suitable for deploying to app stores.';
203

204 205
  @override
  Future<int> runInProject() async {
206
    await super.runInProject();
207 208 209 210
    if (isProjectUsingGradle()) {
      return await buildAndroidWithGradle(
        TargetPlatform.android_arm,
        getBuildMode(),
211
        target: argResults['target']
212 213 214 215 216 217 218 219 220 221
      );
    } else {
      // TODO(devoncarew): This command should take an arg for the output type (arm / x64).
      return await buildAndroid(
        TargetPlatform.android_arm,
        getBuildMode(),
        force: true,
        manifest: argResults['manifest'],
        resources: argResults['resources'],
        outputFile: argResults['output-file'],
222
        target: argResults['target'],
223 224 225 226 227 228 229 230 231 232
        flxPath: argResults['flx'],
        aotPath: argResults['aot-path'],
        keystore: (argResults['keystore'] ?? '').isEmpty ? null : new ApkKeystoreInfo(
          keystore: argResults['keystore'],
          password: argResults['keystore-password'],
          keyAlias: argResults['keystore-key-alias'],
          keyPassword: argResults['keystore-key-password']
        )
      );
    }
233 234
  }
}
235

236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
// Return the directory name within the APK that is used for native code libraries
// on the given platform.
String getAbiDirectory(TargetPlatform platform) {
  switch (platform) {
    case TargetPlatform.android_arm:
      return 'armeabi-v7a';
    case TargetPlatform.android_x64:
      return 'x86_64';
    case TargetPlatform.android_x86:
      return 'x86';
    default:
      throw new Exception('Unsupported platform.');
  }
}

251
Future<_ApkComponents> _findApkComponents(
252
  TargetPlatform platform,
253
  BuildMode buildMode,
254
  String manifest,
255 256
  String resources,
  Map<String, File> extraFiles
257
) async {
Devon Carew's avatar
Devon Carew committed
258 259 260
  _ApkComponents components = new _ApkComponents();
  components.manifest = new File(manifest);
  components.resources = resources == null ? null : new Directory(resources);
261
  components.extraFiles = extraFiles != null ? extraFiles : <String, File>{};
262

Devon Carew's avatar
Devon Carew committed
263
  if (tools.isLocalEngine) {
264
    String abiDir = getAbiDirectory(platform);
Devon Carew's avatar
Devon Carew committed
265
    String enginePath = tools.engineSrcPath;
266
    String buildDir = tools.getEngineArtifactsDirectory(platform, buildMode).path;
Devon Carew's avatar
Devon Carew committed
267 268 269 270

    components.icuData = new File('$enginePath/third_party/icu/android/icudtl.dat');
    components.jars = <File>[
      new File('$buildDir/gen/sky/shell/shell/classes.dex.jar')
271
    ];
Devon Carew's avatar
Devon Carew committed
272 273
    components.libSkyShell = new File('$buildDir/gen/sky/shell/shell/shell/libs/$abiDir/libsky_shell.so');
    components.debugKeystore = new File('$enginePath/build/android/ant/chromium-debug.keystore');
274
  } else {
275
    Directory artifacts = tools.getEngineArtifactsDirectory(platform, buildMode);
Devon Carew's avatar
Devon Carew committed
276 277 278 279

    components.icuData = new File(path.join(artifacts.path, 'icudtl.dat'));
    components.jars = <File>[
      new File(path.join(artifacts.path, 'classes.dex.jar'))
280
    ];
Devon Carew's avatar
Devon Carew committed
281 282
    components.libSkyShell = new File(path.join(artifacts.path, 'libsky_shell.so'));
    components.debugKeystore = new File(path.join(artifacts.path, 'chromium-debug.keystore'));
283
  }
284

285
  await parseServiceConfigs(components.services, jars: components.jars);
286

Devon Carew's avatar
Devon Carew committed
287
  List<File> allFiles = <File>[
288
    components.manifest, components.icuData, components.libSkyShell, components.debugKeystore
289 290
  ]..addAll(components.jars)
   ..addAll(components.extraFiles.values);
Devon Carew's avatar
Devon Carew committed
291 292

  for (File file in allFiles) {
293 294
    if (!file.existsSync()) {
      printError('Cannot locate file: ${file.path}');
295 296 297
      return null;
    }
  }
298

299 300
  return components;
}
301

302
int _buildApk(
Devon Carew's avatar
Devon Carew committed
303
  TargetPlatform platform,
304
  BuildMode buildMode,
Devon Carew's avatar
Devon Carew committed
305 306 307 308
  _ApkComponents components,
  String flxPath,
  ApkKeystoreInfo keystore,
  String outputFile
309
) {
310
  assert(platform != null);
311
  assert(buildMode != null);
312

313
  Directory tempDir = Directory.systemTemp.createTempSync('flutter_tools');
Devon Carew's avatar
Devon Carew committed
314

315
  printTrace('Building APK; buildMode: ${getModeName(buildMode)}.');
316

317
  try {
318
    _ApkBuilder builder = new _ApkBuilder(androidSdk.latestVersion);
319 320 321 322 323
    String error = builder.checkDependencies();
    if (error != null) {
      printError(error);
      return 1;
    }
324

325 326
    File classesDex = new File('${tempDir.path}/classes.dex');
    builder.compileClassesDex(classesDex, components.jars);
327

328
    File servicesConfig =
329
        generateServiceDefinitions(tempDir.path, components.services);
330

331 332 333 334
    _AssetBuilder assetBuilder = new _AssetBuilder(tempDir, 'assets');
    assetBuilder.add(components.icuData, 'icudtl.dat');
    assetBuilder.add(new File(flxPath), 'app.flx');
    assetBuilder.add(servicesConfig, 'services.json');
335

336 337
    _AssetBuilder artifactBuilder = new _AssetBuilder(tempDir, 'artifacts');
    artifactBuilder.add(classesDex, 'classes.dex');
338
    String abiDir = getAbiDirectory(platform);
Devon Carew's avatar
Devon Carew committed
339
    artifactBuilder.add(components.libSkyShell, 'lib/$abiDir/libsky_shell.so');
340

341 342 343
    for (String relativePath in components.extraFiles.keys)
      artifactBuilder.add(components.extraFiles[relativePath], relativePath);

344
    File unalignedApk = new File('${tempDir.path}/app.apk.unaligned');
345 346
    builder.package(
      unalignedApk, components.manifest, assetBuilder.directory,
347
      artifactBuilder.directory, components.resources, buildMode
348
    );
349

350 351 352
    int signResult = _signApk(builder, components, unalignedApk, keystore);
    if (signResult != 0)
      return signResult;
353

354 355 356
    File finalApk = new File(outputFile);
    ensureDirectoryExists(finalApk.path);
    builder.align(unalignedApk, finalApk);
357

358
    printTrace('calculateSha: $outputFile');
Devon Carew's avatar
Devon Carew committed
359 360 361
    File apkShaFile = new File('$outputFile.sha1');
    apkShaFile.writeAsStringSync(calculateSha(finalApk));

362 363 364 365 366 367 368 369 370 371 372 373 374 375 376
    return 0;
  } finally {
    tempDir.deleteSync(recursive: true);
  }
}

int _signApk(
  _ApkBuilder builder, _ApkComponents components, File apk, ApkKeystoreInfo keystoreInfo
) {
  File keystore;
  String keystorePassword;
  String keyAlias;
  String keyPassword;

  if (keystoreInfo == null) {
Devon Carew's avatar
Devon Carew committed
377
    printStatus('Warning: signing the APK using the debug keystore.');
378 379 380 381 382 383
    keystore = components.debugKeystore;
    keystorePassword = _kDebugKeystorePassword;
    keyAlias = _kDebugKeystoreKeyAlias;
    keyPassword = _kDebugKeystorePassword;
  } else {
    keystore = new File(keystoreInfo.keystore);
384 385
    keystorePassword = keystoreInfo.password ?? '';
    keyAlias = keystoreInfo.keyAlias ?? '';
386 387 388
    if (keystorePassword.isEmpty || keyAlias.isEmpty) {
      printError('Must provide a keystore password and a key alias.');
      return 1;
389
    }
390
    keyPassword = keystoreInfo.keyPassword ?? '';
391 392
    if (keyPassword.isEmpty)
      keyPassword = keystorePassword;
393 394
  }

395 396 397 398 399 400
  builder.sign(keystore, keystorePassword, keyAlias, keyPassword, apk);

  return 0;
}

// Returns true if the apk is out of date and needs to be rebuilt.
401 402 403 404 405 406 407
bool _needsRebuild(
  String apkPath,
  String manifest,
  TargetPlatform platform,
  BuildMode buildMode,
  Map<String, File> extraFiles
) {
408 409 410 411
  FileStat apkStat = FileStat.statSync(apkPath);
  // Note: This list of dependencies is imperfect, but will do for now. We
  // purposely don't include the .dart files, because we can load those
  // over the network without needing to rebuild (at least on Android).
412
  List<String> dependencies = <String>[
413 414
    manifest,
    _kFlutterManifestPath,
415
    _kPackagesStatusPath
416 417 418 419
  ];
  dependencies.addAll(extraFiles.values.map((File file) => file.path));
  Iterable<FileStat> dependenciesStat =
    dependencies.map((String path) => FileStat.statSync(path));
420 421 422

  if (apkStat.type == FileSystemEntityType.NOT_FOUND)
    return true;
423

424
  for (FileStat dep in dependenciesStat) {
Devon Carew's avatar
Devon Carew committed
425
    if (dep.modified == null || dep.modified.isAfter(apkStat.modified))
426 427
      return true;
  }
428 429 430 431

  if (!FileSystemEntity.isFileSync('$apkPath.sha1'))
    return true;

432 433 434 435 436
  String lastBuildType = _readBuildMeta(path.dirname(apkPath))['targetBuildType'];
  String targetBuildType = _getTargetBuildTypeToken(platform, buildMode, new File(apkPath));
  if (lastBuildType != targetBuildType)
    return true;

437 438 439
  return false;
}

Devon Carew's avatar
Devon Carew committed
440
Future<int> buildAndroid(
441
  TargetPlatform platform,
442
  BuildMode buildMode, {
443 444
  bool force: false,
  String manifest: _kDefaultAndroidManifestPath,
445
  String resources,
446
  String outputFile: _kDefaultOutputPath,
Devon Carew's avatar
Devon Carew committed
447
  String target,
448
  String flxPath,
449
  String aotPath,
450 451
  ApkKeystoreInfo keystore
}) async {
452 453
  // Validate that we can find an android sdk.
  if (androidSdk == null) {
454
    printError('No Android SDK found. Try setting the ANDROID_HOME environment variable.');
455 456 457
    return 1;
  }

458 459 460
  List<String> validationResult = androidSdk.validateSdkWellFormed();
  if (validationResult.isNotEmpty) {
    validationResult.forEach(printError);
461 462 463 464
    printError('Try re-installing or updating your Android SDK.');
    return 1;
  }

465 466 467 468 469 470 471 472
  Map<String, File> extraFiles = <String, File>{};
  if (FileSystemEntity.isDirectorySync(_kDefaultAssetsPath)) {
    Directory assetsDir = new Directory(_kDefaultAssetsPath);
    for (FileSystemEntity entity in assetsDir.listSync(recursive: true)) {
      if (entity is File) {
        String targetPath = entity.path.substring(assetsDir.path.length);
        extraFiles["assets/$targetPath"] = entity;
      }
pq's avatar
pq committed
473
    }
474 475
  }

476 477 478
  // In debug (JIT) mode, the snapshot lives in the FLX, and we can skip the APK
  // rebuild if none of the resources in the APK are stale.
  // In AOT modes, the snapshot lives in the APK, so the APK must be rebuilt.
479 480 481
  if (!isAotBuildMode(buildMode) &&
      !force &&
      !_needsRebuild(outputFile, manifest, platform, buildMode, extraFiles)) {
Devon Carew's avatar
Devon Carew committed
482
    printTrace('APK up to date; skipping build step.');
483 484 485
    return 0;
  }

486 487 488 489 490 491 492 493 494 495
  if (resources != null) {
    if (!FileSystemEntity.isDirectorySync(resources)) {
      printError('Resources directory "$resources" not found.');
      return 1;
    }
  } else {
    if (FileSystemEntity.isDirectorySync(_kDefaultResourcesPath))
      resources = _kDefaultResourcesPath;
  }

496
  _ApkComponents components = await _findApkComponents(platform, buildMode, manifest, resources, extraFiles);
Devon Carew's avatar
Devon Carew committed
497

498
  if (components == null) {
Devon Carew's avatar
Devon Carew committed
499
    printError('Failure building APK: unable to find components.');
500 501 502
    return 1;
  }

503
  String typeName = path.basename(tools.getEngineArtifactsDirectory(platform, buildMode).path);
504
  Status status = logger.startProgress('Building APK in ${getModeName(buildMode)} mode ($typeName)...');
505

506
  if (flxPath != null && flxPath.isNotEmpty) {
507 508 509
    if (!FileSystemEntity.isFileSync(flxPath)) {
      printError('FLX does not exist: $flxPath');
      printError('(Omit the --flx option to build the FLX automatically)');
510 511
      return 1;
    }
512
  } else {
513 514 515
    // Build the FLX.
    flxPath = await flx.buildFlx(
      mainPath: findMainDartFile(target),
516
      precompiledSnapshot: isAotBuildMode(buildMode),
517
      includeRobotoFonts: false);
518 519 520

    if (flxPath == null)
      return 1;
521
  }
522

523
  // Build an AOT snapshot if needed.
524
  if (isAotBuildMode(buildMode) && aotPath == null) {
525
    aotPath = await buildAotSnapshot(findMainDartFile(target), platform, buildMode);
526 527 528 529
    if (aotPath == null) {
      printError('Failed to build AOT snapshot');
      return 1;
    }
530
  }
531 532

  if (aotPath != null) {
533
    if (!isAotBuildMode(buildMode)) {
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550
      printError('AOT snapshot can not be used in build mode $buildMode');
      return 1;
    }
    if (!FileSystemEntity.isDirectorySync(aotPath)) {
      printError('AOT snapshot does not exist: $aotPath');
      return 1;
    }
    for (String aotFilename in kAotSnapshotFiles) {
      String aotFilePath = path.join(aotPath, aotFilename);
      if (!FileSystemEntity.isFileSync(aotFilePath)) {
        printError('Missing AOT snapshot file: $aotFilePath');
        return 1;
      }
      components.extraFiles['assets/$aotFilename'] = new File(aotFilePath);
    }
  }

551
  int result = _buildApk(platform, buildMode, components, flxPath, keystore, outputFile);
552 553
  status.stop(showElapsedTime: true);

554
  if (result == 0) {
555 556 557
    File apkFile = new File(outputFile);
    printStatus('Built $outputFile (${getSizeAsMB(apkFile.lengthSync())}).');

558 559 560 561 562 563
    _writeBuildMetaEntry(
      path.dirname(outputFile),
      'targetBuildType',
      _getTargetBuildTypeToken(platform, buildMode, new File(outputFile))
    );
  }
564

565
  return result;
566
}
567

568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
Future<int> buildAndroidWithGradle(
  TargetPlatform platform,
  BuildMode buildMode, {
  bool force: false,
  String target
}) async {
  // Validate that we can find an android sdk.
  if (androidSdk == null) {
    printError('No Android SDK found. Try setting the ANDROID_HOME environment variable.');
    return 1;
  }

  List<String> validationResult = androidSdk.validateSdkWellFormed();
  if (validationResult.isNotEmpty) {
    validationResult.forEach(printError);
    printError('Try re-installing or updating your Android SDK.');
    return 1;
  }

  return buildGradleProject(buildMode);
}

590
Future<int> buildApk(
591
  TargetPlatform platform, {
592
  String target,
593
  BuildMode buildMode: BuildMode.debug
594
}) async {
595 596 597 598 599 600 601 602 603 604 605 606
  if (isProjectUsingGradle()) {
    return await buildAndroidWithGradle(
      platform,
      buildMode,
      force: false,
      target: target
    );
  } else {
    if (!FileSystemEntity.isFileSync(_kDefaultAndroidManifestPath)) {
      printError('Cannot build APK: missing $_kDefaultAndroidManifestPath.');
      return 1;
    }
607

608 609 610 611 612 613 614
    return await buildAndroid(
      platform,
      buildMode,
      force: false,
      target: target
    );
  }
615
}
616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638

Map<String, dynamic> _readBuildMeta(String buildDirectoryPath) {
  File buildMetaFile = new File(path.join(buildDirectoryPath, 'build_meta.json'));
  if (buildMetaFile.existsSync())
    return JSON.decode(buildMetaFile.readAsStringSync());
  return <String, dynamic>{};
}

void _writeBuildMetaEntry(String buildDirectoryPath, String key, dynamic value) {
  Map<String, dynamic> meta = _readBuildMeta(buildDirectoryPath);
  meta[key] = value;
  File buildMetaFile = new File(path.join(buildDirectoryPath, 'build_meta.json'));
  buildMetaFile.writeAsStringSync(toPrettyJson(meta));
}

String _getTargetBuildTypeToken(TargetPlatform platform, BuildMode buildMode, File outputBinary) {
  String buildType = getNameForTargetPlatform(platform) + '-' + getModeName(buildMode);
  if (tools.isLocalEngine)
    buildType += ' [${tools.engineBuildPath}]';
  if (outputBinary.existsSync())
    buildType += ' [${outputBinary.lastModifiedSync().millisecondsSinceEpoch}]';
  return buildType;
}