application_package.dart 22.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'dart:collection';
6

7
import 'package:meta/meta.dart';
8
import 'package:process/process.dart';
Dan Field's avatar
Dan Field committed
9
import 'package:xml/xml.dart';
10

11
import 'android/android_sdk.dart';
12
import 'android/gradle.dart';
13
import 'base/common.dart';
14
import 'base/context.dart';
15
import 'base/file_system.dart';
16
import 'base/io.dart';
17
import 'base/logger.dart';
18
import 'base/process.dart';
19
import 'base/user_messages.dart';
20
import 'build_info.dart';
21
import 'fuchsia/application_package.dart';
22
import 'globals.dart' as globals;
23
import 'ios/plist_parser.dart';
24
import 'linux/application_package.dart';
25
import 'macos/application_package.dart';
26
import 'project.dart';
27
import 'tester/flutter_tester.dart';
28
import 'web/web_device.dart';
29
import 'windows/application_package.dart';
30

31
class ApplicationPackageFactory {
32 33 34 35 36 37 38 39 40 41 42 43 44
  ApplicationPackageFactory({
    @required AndroidSdk androidSdk,
    @required ProcessManager processManager,
    @required Logger logger,
    @required UserMessages userMessages,
    @required FileSystem fileSystem,
  }) : _androidSdk = androidSdk,
       _processManager = processManager,
       _logger = logger,
       _userMessages = userMessages,
       _fileSystem = fileSystem,
       _processUtils = ProcessUtils(logger: logger, processManager: processManager);

45
  static ApplicationPackageFactory get instance => context.get<ApplicationPackageFactory>();
46

47 48 49 50 51 52 53
  final AndroidSdk _androidSdk;
  final ProcessManager _processManager;
  final Logger _logger;
  final ProcessUtils _processUtils;
  final UserMessages _userMessages;
  final FileSystem _fileSystem;

54
  Future<ApplicationPackage> getPackageForPlatform(
55
    TargetPlatform platform, {
56
    BuildInfo buildInfo,
57 58
    File applicationBinary,
  }) async {
59
    switch (platform) {
60
      case TargetPlatform.android:
61 62 63 64
      case TargetPlatform.android_arm:
      case TargetPlatform.android_arm64:
      case TargetPlatform.android_x64:
      case TargetPlatform.android_x86:
65
        if (_androidSdk?.licensesAvailable == true && _androidSdk?.latestVersion == null) {
66 67
          await checkGradleDependencies();
        }
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
        if (applicationBinary == null) {
          return await AndroidApk.fromAndroidProject(
            FlutterProject.current().android,
            processManager: _processManager,
            processUtils: _processUtils,
            logger: _logger,
            androidSdk: _androidSdk,
            userMessages: _userMessages,
            fileSystem: _fileSystem,
          );
        }
        return AndroidApk.fromApk(
          applicationBinary,
          processManager: _processManager,
          logger: _logger,
          androidSdk: _androidSdk,
          userMessages: _userMessages,
        );
86 87
      case TargetPlatform.ios:
        return applicationBinary == null
88
            ? await IOSApp.fromIosProject(FlutterProject.current().ios, buildInfo)
89 90
            : IOSApp.fromPrebuiltApp(applicationBinary);
      case TargetPlatform.tester:
91
        return FlutterTesterApp.fromCurrentDirectory(globals.fs);
92
      case TargetPlatform.darwin_x64:
93
        return applicationBinary == null
94
            ? MacOSApp.fromMacOSProject(FlutterProject.current().macos)
95
            : MacOSApp.fromPrebuiltApp(applicationBinary);
96
      case TargetPlatform.web_javascript:
97 98 99
        if (!FlutterProject.current().web.existsSync()) {
          return null;
        }
100
        return WebApplicationPackage(FlutterProject.current());
101
      case TargetPlatform.linux_x64:
102
        return applicationBinary == null
103
            ? LinuxApp.fromLinuxProject(FlutterProject.current().linux)
104
            : LinuxApp.fromPrebuiltApp(applicationBinary);
105
      case TargetPlatform.windows_x64:
106
        return applicationBinary == null
107
            ? WindowsApp.fromWindowsProject(FlutterProject.current().windows)
108
            : WindowsApp.fromPrebuiltApp(applicationBinary);
109 110
      case TargetPlatform.fuchsia_arm64:
      case TargetPlatform.fuchsia_x64:
111 112 113
        return applicationBinary == null
            ? FuchsiaApp.fromFuchsiaProject(FlutterProject.current().fuchsia)
            : FuchsiaApp.fromPrebuiltApp(applicationBinary);
114 115 116 117 118 119
    }
    assert(platform != null);
    return null;
  }
}

120
abstract class ApplicationPackage {
121 122
  ApplicationPackage({ @required this.id })
    : assert(id != null);
123

124 125 126
  /// Package ID from the Android Manifest or equivalent.
  final String id;

127 128
  String get name;

129 130
  String get displayName => name;

131
  File get packagesFile => null;
132

133
  @override
134
  String toString() => displayName ?? id;
135 136
}

137
/// An application package created from an already built Android APK.
138
class AndroidApk extends ApplicationPackage {
139
  AndroidApk({
Adam Barth's avatar
Adam Barth committed
140
    String id,
141
    @required this.file,
142
    @required this.versionCode,
143
    @required this.launchActivity,
144
  }) : assert(file != null),
145 146
       assert(launchActivity != null),
       super(id: id);
147

148
  /// Creates a new AndroidApk from an existing APK.
149 150 151 152 153 154 155 156 157 158 159
  ///
  /// Returns `null` if the APK was invalid or any required tooling was missing.
  factory AndroidApk.fromApk(File apk, {
    @required AndroidSdk androidSdk,
    @required ProcessManager processManager,
    @required UserMessages userMessages,
    @required Logger logger,
  }) {
    final String aaptPath = androidSdk?.latestVersion?.aaptPath;
    if (aaptPath == null || !processManager.canRun(aaptPath)) {
      logger.printError(userMessages.aaptNotFound);
160 161 162
      return null;
    }

163 164
    String apptStdout;
    try {
165
      apptStdout = globals.processUtils.runSync(
166 167 168 169 170 171 172 173 174 175
        <String>[
          aaptPath,
          'dump',
          'xmltree',
          apk.path,
          'AndroidManifest.xml',
        ],
        throwOnError: true,
      ).stdout.trim();
    } on ProcessException catch (error) {
176
      globals.printError('Failed to extract manifest from APK: $error.');
177 178 179
      return null;
    }

180
    final ApkManifestData data = ApkManifestData.parseFromXmlDump(apptStdout, logger);
181 182

    if (data == null) {
183
      logger.printError('Unable to read manifest info from ${apk.path}.');
184 185 186 187
      return null;
    }

    if (data.packageName == null || data.launchableActivityName == null) {
188
      logger.printError('Unable to read manifest info from ${apk.path}.');
189 190 191
      return null;
    }

192
    return AndroidApk(
193
      id: data.packageName,
194
      file: apk,
195
      versionCode: int.tryParse(data.versionCode),
196
      launchActivity: '${data.packageName}/${data.launchableActivityName}',
197 198 199
    );
  }

200 201 202 203 204 205
  /// Path to the actual apk file.
  final File file;

  /// The path to the activity that should be launched.
  final String launchActivity;

206 207 208
  /// The version code of the APK.
  final int versionCode;

209
  /// Creates a new AndroidApk based on the information in the Android manifest.
210 211 212 213 214 215 216 217
  static Future<AndroidApk> fromAndroidProject(AndroidProject androidProject, {
    @required AndroidSdk androidSdk,
    @required ProcessManager processManager,
    @required UserMessages userMessages,
    @required ProcessUtils processUtils,
    @required Logger logger,
    @required FileSystem fileSystem,
  }) async {
218
    File apkFile;
219

220
    if (androidProject.isUsingGradle) {
221 222
      apkFile = await getGradleAppOut(androidProject);
      if (apkFile.existsSync()) {
223 224
        // Grab information from the .apk. The gradle build script might alter
        // the application Id, so we need to look at what was actually built.
225 226 227 228 229 230 231
        return AndroidApk.fromApk(
          apkFile,
          androidSdk: androidSdk,
          processManager: processManager,
          logger: logger,
          userMessages: userMessages,
        );
232 233 234 235
      }
      // The .apk hasn't been built yet, so we work with what we have. The run
      // command will grab a new AndroidApk after building, to get the updated
      // IDs.
236
    } else {
237
      apkFile = fileSystem.file(fileSystem.path.join(getAndroidBuildDirectory(), 'app.apk'));
238 239
    }

240
    final File manifest = androidProject.appManifestFile;
241

242
    if (!manifest.existsSync()) {
243 244
      logger.printError('AndroidManifest.xml could not be found.');
      logger.printError('Please check ${manifest.path} for errors.');
245
      return null;
246
    }
247

248
    final String manifestString = manifest.readAsStringSync();
Dan Field's avatar
Dan Field committed
249
    XmlDocument document;
250
    try {
Dan Field's avatar
Dan Field committed
251 252
      document = XmlDocument.parse(manifestString);
    } on XmlParserException catch (exception) {
253 254
      String manifestLocation;
      if (androidProject.isUsingGradle) {
255
        manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'app', 'src', 'main', 'AndroidManifest.xml');
256
      } else {
257
        manifestLocation = fileSystem.path.join(androidProject.hostAppGradleRoot.path, 'AndroidManifest.xml');
258
      }
259 260
      logger.printError('AndroidManifest.xml is not a valid XML document.');
      logger.printError('Please check $manifestLocation for errors.');
261 262
      throwToolExit('XML Parser error message: ${exception.toString()}');
    }
263

Dan Field's avatar
Dan Field committed
264
    final Iterable<XmlElement> manifests = document.findElements('manifest');
265
    if (manifests.isEmpty) {
266 267
      logger.printError('AndroidManifest.xml has no manifest element.');
      logger.printError('Please check ${manifest.path} for errors.');
268
      return null;
269
    }
270
    final String packageId = manifests.first.getAttribute('package');
271 272

    String launchActivity;
Dan Field's avatar
Dan Field committed
273
    for (final XmlElement activity in document.findAllElements('activity')) {
274 275 276 277 278
      final String enabled = activity.getAttribute('android:enabled');
      if (enabled != null && enabled == 'false') {
        continue;
      }

Dan Field's avatar
Dan Field committed
279
      for (final XmlElement element in activity.findElements('intent-filter')) {
280 281
        String actionName = '';
        String categoryName = '';
Dan Field's avatar
Dan Field committed
282 283
        for (final XmlNode node in element.children) {
          if (node is! XmlElement) {
284 285
            continue;
          }
Dan Field's avatar
Dan Field committed
286
          final XmlElement xmlElement = node as XmlElement;
287 288 289 290 291 292 293 294
          final String name = xmlElement.getAttribute('android:name');
          if (name == 'android.intent.action.MAIN') {
            actionName = name;
          } else if (name == 'android.intent.category.LAUNCHER') {
            categoryName = name;
          }
        }
        if (actionName.isNotEmpty && categoryName.isNotEmpty) {
295 296 297 298
          final String activityName = activity.getAttribute('android:name');
          launchActivity = '$packageId/$activityName';
          break;
        }
299 300
      }
    }
301

302
    if (packageId == null || launchActivity == null) {
303 304
      logger.printError('package identifier or launch activity not found.');
      logger.printError('Please check ${manifest.path} for errors.');
305
      return null;
306
    }
307

308
    return AndroidApk(
309
      id: packageId,
310
      file: apkFile,
311
      versionCode: null,
312
      launchActivity: launchActivity,
313
    );
314
  }
315

316
  @override
317
  File get packagesFile => file;
318

319
  @override
320
  String get name => file.basename;
321 322
}

323
/// Tests whether a [Directory] is an iOS bundle directory.
324
bool _isBundleDirectory(Directory dir) => dir.path.endsWith('.app');
325 326

abstract class IOSApp extends ApplicationPackage {
327
  IOSApp({@required String projectBundleId}) : super(id: projectBundleId);
328

329
  /// Creates a new IOSApp from an existing app bundle or IPA.
330
  factory IOSApp.fromPrebuiltApp(FileSystemEntity applicationBinary) {
331
    final FileSystemEntityType entityType = globals.fs.typeSync(applicationBinary.path);
332
    if (entityType == FileSystemEntityType.notFound) {
333
      globals.printError(
334
          'File "${applicationBinary.path}" does not exist. Use an app bundle or an ipa.');
335 336
      return null;
    }
337
    Directory bundleDir;
338
    if (entityType == FileSystemEntityType.directory) {
339
      final Directory directory = globals.fs.directory(applicationBinary);
340
      if (!_isBundleDirectory(directory)) {
341
        globals.printError('Folder "${applicationBinary.path}" is not an app bundle.');
342 343
        return null;
      }
344
      bundleDir = globals.fs.directory(applicationBinary);
345 346
    } else {
      // Try to unpack as an ipa.
347
      final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_app.');
348
      shutdownHooks.addShutdownHook(() async {
349 350
        await tempDir.delete(recursive: true);
      }, ShutdownStage.STILL_RECORDING);
351
      globals.os.unzip(globals.fs.file(applicationBinary), tempDir);
352 353
      final Directory payloadDir = globals.fs.directory(
        globals.fs.path.join(tempDir.path, 'Payload'),
354 355
      );
      if (!payloadDir.existsSync()) {
356
        globals.printError(
357 358 359 360
            'Invalid prebuilt iOS ipa. Does not contain a "Payload" directory.');
        return null;
      }
      try {
361
        bundleDir = payloadDir.listSync().whereType<Directory>().singleWhere(_isBundleDirectory);
362
      } on StateError {
363
        globals.printError(
364 365 366
            'Invalid prebuilt iOS ipa. Does not contain a single app bundle.');
        return null;
      }
367
    }
368 369 370
    final String plistPath = globals.fs.path.join(bundleDir.path, 'Info.plist');
    if (!globals.fs.file(plistPath).existsSync()) {
      globals.printError('Invalid prebuilt iOS app. Does not contain Info.plist.');
371 372
      return null;
    }
373
    final String id = globals.plistParser.getValueFromFile(
374
      plistPath,
375
      PlistParser.kCFBundleIdentifierKey,
376 377
    );
    if (id == null) {
378
      globals.printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier');
379
      return null;
380
    }
381

382
    return PrebuiltIOSApp(
383
      bundleDir: bundleDir,
384
      bundleName: globals.fs.path.basename(bundleDir.path),
385 386 387
      projectBundleId: id,
    );
  }
388

389
  static Future<IOSApp> fromIosProject(IosProject project, BuildInfo buildInfo) {
390
    if (!globals.platform.isMacOS) {
391
      return null;
392 393
    }
    if (!project.exists) {
394
      // If the project doesn't exist at all the current hint to run flutter
395 396 397 398
      // create is accurate.
      return null;
    }
    if (!project.xcodeProject.existsSync()) {
399
      globals.printError('Expected ios/Runner.xcodeproj but this file is missing.');
400 401
      return null;
    }
402
    if (!project.xcodeProjectInfoFile.existsSync()) {
403
      globals.printError('Expected ios/Runner.xcodeproj/project.pbxproj but this file is missing.');
404 405
      return null;
    }
406
    return BuildableIOSApp.fromProject(project, buildInfo);
407
  }
408

409
  @override
410
  String get displayName => id;
411

412 413 414 415 416 417
  String get simulatorBundlePath;

  String get deviceBundlePath;
}

class BuildableIOSApp extends IOSApp {
418 419 420
  BuildableIOSApp(this.project, String projectBundleId, String hostAppBundleName)
    : _hostAppBundleName = hostAppBundleName,
      super(projectBundleId: projectBundleId);
421

422 423 424
  static Future<BuildableIOSApp> fromProject(IosProject project, BuildInfo buildInfo) async {
    final String projectBundleId = await project.productBundleIdentifier(buildInfo);
    final String hostAppBundleName = await project.hostAppBundleName(buildInfo);
425
    return BuildableIOSApp(project, projectBundleId, hostAppBundleName);
426
  }
427

428
  final IosProject project;
429

430 431
  final String _hostAppBundleName;

432
  @override
433
  String get name => _hostAppBundleName;
434 435

  @override
436 437
  String get simulatorBundlePath => _buildAppPath('iphonesimulator');

438
  @override
439 440
  String get deviceBundlePath => _buildAppPath('iphoneos');

441 442 443 444 445 446
  // Xcode uses this path for the final archive bundle location,
  // not a top-level output directory.
  // Specifying `build/ios/archive/Runner` will result in `build/ios/archive/Runner.xcarchive`.
  String get archiveBundlePath
    => globals.fs.path.join(getIosBuildDirectory(), 'archive', globals.fs.path.withoutExtension(_hostAppBundleName));

447 448 449 450 451 452 453
  // The output xcarchive bundle path `build/ios/archive/Runner.xcarchive`.
  String get archiveBundleOutputPath =>
      globals.fs.path.setExtension(archiveBundlePath, '.xcarchive');

  String get ipaOutputPath =>
      globals.fs.path.join(getIosBuildDirectory(), 'ipa');

454
  String _buildAppPath(String type) {
455
    return globals.fs.path.join(getIosBuildDirectory(), type, _hostAppBundleName);
456
  }
457 458
}

459 460 461 462
class PrebuiltIOSApp extends IOSApp {
  PrebuiltIOSApp({
    this.bundleDir,
    this.bundleName,
463
    @required String projectBundleId,
464 465
  }) : super(projectBundleId: projectBundleId);

466 467 468
  final Directory bundleDir;
  final String bundleName;

469 470 471 472 473 474 475 476 477 478 479 480
  @override
  String get name => bundleName;

  @override
  String get simulatorBundlePath => _bundlePath;

  @override
  String get deviceBundlePath => _bundlePath;

  String get _bundlePath => bundleDir.path;
}

481 482 483 484 485 486 487 488 489 490 491 492 493 494 495
class _Entry {
  _Element parent;
  int level;
}

class _Element extends _Entry {
  _Element.fromLine(String line, _Element parent) {
    //      E: application (line=29)
    final List<String> parts = line.trimLeft().split(' ');
    name = parts[1];
    level = line.length - line.trimLeft().length;
    this.parent = parent;
    children = <_Entry>[];
  }

496 497 498
  List<_Entry> children;
  String name;

499 500 501 502 503
  void addChild(_Entry child) {
    children.add(child);
  }

  _Attribute firstAttribute(String name) {
504 505
    return children.whereType<_Attribute>().firstWhere(
        (_Attribute e) => e.key.startsWith(name),
506 507 508 509 510
        orElse: () => null,
    );
  }

  _Element firstElement(String name) {
511 512
    return children.whereType<_Element>().firstWhere(
        (_Element e) => e.name.startsWith(name),
513 514 515 516
        orElse: () => null,
    );
  }

517 518
  Iterable<_Element> allElements(String name) {
    return children.whereType<_Element>().where((_Element e) => e.name.startsWith(name));
519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
  }
}

class _Attribute extends _Entry {
  _Attribute.fromLine(String line, _Element parent) {
    //     A: android:label(0x01010001)="hello_world" (Raw: "hello_world")
    const String attributePrefix = 'A: ';
    final List<String> keyVal = line
        .substring(line.indexOf(attributePrefix) + attributePrefix.length)
        .split('=');
    key = keyVal[0];
    value = keyVal[1];
    level = line.length - line.trimLeft().length;
    this.parent = parent;
  }
534 535 536

  String key;
  String value;
537 538
}

539 540 541
class ApkManifestData {
  ApkManifestData._(this._data);

542 543
  static bool isAttributeWithValuePresent(_Element baseElement,
      String childElement, String attributeName, String attributeValue) {
544
    final Iterable<_Element> allElements = baseElement.allElements(childElement);
545
    for (final _Element oneElement in allElements) {
546 547 548 549 550 551 552 553 554 555 556
      final String elementAttributeValue = oneElement
          ?.firstAttribute(attributeName)
          ?.value;
      if (elementAttributeValue != null &&
          elementAttributeValue.startsWith(attributeValue)) {
        return true;
      }
    }
    return false;
  }

557
  static ApkManifestData parseFromXmlDump(String data, Logger logger) {
558
    if (data == null || data.trim().isEmpty) {
559
      return null;
560
    }
561

562 563
    final List<String> lines = data.split('\n');
    assert(lines.length > 3);
564

565 566
    final int manifestLine = lines.indexWhere((String line) => line.contains('E: manifest'));
    final _Element manifest = _Element.fromLine(lines[manifestLine], null);
567
    _Element currentElement = manifest;
568

569
    for (final String line in lines.skip(manifestLine)) {
570 571
      final String trimLine = line.trimLeft();
      final int level = line.length - trimLine.length;
572

573
      // Handle level out
574
      while (currentElement.parent != null && level <= currentElement.level) {
575 576
        currentElement = currentElement.parent;
      }
577

578 579 580 581
      if (level > currentElement.level) {
        switch (trimLine[0]) {
          case 'A':
            currentElement
582
                .addChild(_Attribute.fromLine(line, currentElement));
583 584
            break;
          case 'E':
585
            final _Element element = _Element.fromLine(line, currentElement);
586 587
            currentElement.addChild(element);
            currentElement = element;
588 589 590 591
        }
      }
    }

592
    final _Element application = manifest.firstElement('application');
593 594 595
    if (application == null) {
      return null;
    }
596

597
    final Iterable<_Element> activities = application.allElements('activity');
598 599

    _Element launchActivity;
600
    for (final _Element activity in activities) {
601
      final _Attribute enabled = activity.firstAttribute('android:enabled');
602
      final Iterable<_Element> intentFilters = activity.allElements('intent-filter');
603 604 605 606 607 608
      final bool isEnabledByDefault = enabled == null;
      final bool isExplicitlyEnabled = enabled != null && enabled.value.contains('0xffffffff');
      if (!(isEnabledByDefault || isExplicitlyEnabled)) {
        continue;
      }

609
      for (final _Element element in intentFilters) {
610 611 612 613 614 615 616 617 618 619
        final bool isMainAction = isAttributeWithValuePresent(
            element, 'action', 'android:name', '"android.intent.action.MAIN"');
        if (!isMainAction) {
          continue;
        }
        final bool isLauncherCategory = isAttributeWithValuePresent(
            element, 'category', 'android:name',
            '"android.intent.category.LAUNCHER"');
        if (!isLauncherCategory) {
          continue;
620
        }
621 622
        launchActivity = activity;
        break;
623 624
      }
      if (launchActivity != null) {
625 626 627 628 629 630 631 632 633
        break;
      }
    }

    final _Attribute package = manifest.firstAttribute('package');
    // "io.flutter.examples.hello_world" (Raw: "io.flutter.examples.hello_world")
    final String packageName = package.value.substring(1, package.value.indexOf('" '));

    if (launchActivity == null) {
634
      logger.printError('Error running $packageName. Default activity not found');
635 636 637 638 639 640 641 642
      return null;
    }

    final _Attribute nameAttribute = launchActivity.firstAttribute('android:name');
    // "io.flutter.examples.hello_world.MainActivity" (Raw: "io.flutter.examples.hello_world.MainActivity")
    final String activityName = nameAttribute
        .value.substring(1, nameAttribute.value.indexOf('" '));

643 644 645
    // Example format: (type 0x10)0x1
    final _Attribute versionCodeAttr = manifest.firstAttribute('android:versionCode');
    if (versionCodeAttr == null) {
646
      logger.printError('Error running $packageName. Manifest versionCode not found');
647 648 649
      return null;
    }
    if (!versionCodeAttr.value.startsWith('(type 0x10)')) {
650
      logger.printError('Error running $packageName. Manifest versionCode invalid');
651 652 653 654
      return null;
    }
    final int versionCode = int.tryParse(versionCodeAttr.value.substring(11));
    if (versionCode == null) {
655
      logger.printError('Error running $packageName. Manifest versionCode invalid');
656 657 658
      return null;
    }

659 660
    final Map<String, Map<String, String>> map = <String, Map<String, String>>{};
    map['package'] = <String, String>{'name': packageName};
661
    map['version-code'] = <String, String>{'name': versionCode.toString()};
662 663
    map['launchable-activity'] = <String, String>{'name': activityName};

664
    return ApkManifestData._(map);
665 666 667 668
  }

  final Map<String, Map<String, String>> _data;

669 670
  @visibleForTesting
  Map<String, Map<String, String>> get data =>
671
      UnmodifiableMapView<String, Map<String, String>>(_data);
672

673 674
  String get packageName => _data['package'] == null ? null : _data['package']['name'];

675 676
  String get versionCode => _data['version-code'] == null ? null : _data['version-code']['name'];

677 678 679 680 681 682 683
  String get launchableActivityName {
    return _data['launchable-activity'] == null ? null : _data['launchable-activity']['name'];
  }

  @override
  String toString() => _data.toString();
}