Implement size analyzer to unzip & parse APK and AOT size snapshot to generate analysis json (#62495)

* Implement size analyzer to unzip & parse APK and AOT size snapshot to generate analysis json
diff --git a/packages/flutter_tools/lib/src/base/analyze_size.dart b/packages/flutter_tools/lib/src/base/analyze_size.dart
new file mode 100644
index 0000000..aa60c53
--- /dev/null
+++ b/packages/flutter_tools/lib/src/base/analyze_size.dart
@@ -0,0 +1,370 @@
+// Copyright 2014 The Flutter 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 'package:meta/meta.dart';
+import 'package:vm_snapshot_analysis/treemap.dart';
+
+import '../base/file_system.dart';
+import '../convert.dart';
+import 'logger.dart';
+import 'process.dart';
+import 'terminal.dart';
+
+/// A class to analyze APK and AOT snapshot and generate a breakdown of the data.
+class SizeAnalyzer {
+  SizeAnalyzer({
+    @required this.fileSystem,
+    @required this.logger,
+    @required this.processUtils,
+  });
+
+  final FileSystem fileSystem;
+  final Logger logger;
+  final ProcessUtils processUtils;
+
+  static const String aotSnapshotFileName = 'aot-snapshot.json';
+
+  static const int tableWidth = 80;
+
+  /// Analyzes [apk] and [aotSnapshot] to output a [Map] object that includes
+  /// the breakdown of the both files, where the breakdown of [aotSnapshot] is placed
+  /// under 'lib/arm64-v8a/libapp.so'.
+  ///
+  /// The [aotSnapshot] can be either instruction sizes snapshot or v8 snapshot.
+  Future<Map<String, dynamic>> analyzeApkSizeAndAotSnapshot({
+    @required File apk,
+    @required File aotSnapshot,
+  }) async {
+    logger.printStatus('▒' * tableWidth);
+    _printEntitySize(
+      '${apk.basename} (total compressed)',
+      byteSize: apk.lengthSync(),
+      level: 0,
+      showColor: false,
+    );
+    logger.printStatus('━' * tableWidth);
+    final Directory tempApkContent = fileSystem.systemTempDirectory.createTempSync('flutter_tools.');
+    // TODO(peterdjlee): Implement a way to unzip the APK for Windows. See issue #62603.
+    String unzipOut;
+    try {
+      // TODO(peterdjlee): Use zipinfo instead of unzip.
+      unzipOut = (await processUtils.run(<String>[
+        'unzip',
+        '-o',
+        '-v',
+        apk.path,
+        '-d',
+        tempApkContent.path
+      ])).stdout;
+    } on Exception catch (e) {
+      logger.printError(e.toString());
+    } finally {
+      // We just want the the stdout printout. We don't need the files.
+      tempApkContent.deleteSync(recursive: true);
+    }
+
+    final _SymbolNode apkAnalysisRoot = _parseUnzipFile(unzipOut);
+
+    // Convert an AOT snapshot file into a map.
+    final Map<String, dynamic> processedAotSnapshotJson = treemapFromJson(
+      json.decode(aotSnapshot.readAsStringSync()),
+    );
+    final _SymbolNode aotSnapshotJsonRoot = _parseAotSnapshot(processedAotSnapshotJson);
+
+    for (final _SymbolNode firstLevelPath in apkAnalysisRoot.children) {
+      _printEntitySize(
+        firstLevelPath.name,
+        byteSize: firstLevelPath.byteSize,
+        level: 1,
+      );
+      // Print the expansion of lib directory to show more info for libapp.so.
+      if (firstLevelPath.name == 'lib') {
+        _printLibChildrenPaths(firstLevelPath, '', aotSnapshotJsonRoot);
+      }
+    }
+
+    logger.printStatus('▒' * tableWidth);
+
+    Map<String, dynamic> apkAnalysisJson = apkAnalysisRoot.toJson();
+
+    apkAnalysisJson['type'] = 'apk';
+
+    // TODO(peterdjlee): Add aot snapshot for all platforms.
+    apkAnalysisJson = _addAotSnapshotDataToApkAnalysis(
+      apkAnalysisJson: apkAnalysisJson,
+      path: 'lib/arm64-v8a/libapp.so (Dart AOT)'.split('/'), // Pass in a list of paths by splitting with '/'.
+      aotSnapshotJson: processedAotSnapshotJson,
+    );
+
+    return apkAnalysisJson;
+  }
+
+
+  // Expression to match 'Size' column to group 1 and 'Name' column to group 2.
+  final RegExp _parseUnzipOutput = RegExp(r'^\s*\d+\s+[\w|:]+\s+(\d+)\s+.*  (.+)$');
+
+  // Parse the output of unzip -v which shows the zip's contents' compressed sizes.
+  // Example output of unzip -v:
+  //  Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
+  // --------  ------  ------- ---- ---------- ----- --------  ----
+  //    11708  Defl:N     2592  78% 00-00-1980 00:00 07733eef  AndroidManifest.xml
+  //     1399  Defl:N     1092  22% 00-00-1980 00:00 f53d952a  META-INF/CERT.RSA
+  //    46298  Defl:N    14530  69% 00-00-1980 00:00 17df02b8  META-INF/CERT.SF
+  _SymbolNode _parseUnzipFile(String unzipOut) {
+    final Map<List<String>, int> pathsToSize = <List<String>, int>{};
+
+    // Parse each path into pathsToSize so that the key is a list of
+    // path parts and the value is the size.
+    // For example:
+    // 'path/to/file' where file = 1500 => pathsToSize[['path', 'to', 'file']] = 1500
+    for (final String line in const LineSplitter().convert(unzipOut)) {
+      final RegExpMatch match = _parseUnzipOutput.firstMatch(line);
+      if (match == null) {
+        continue;
+      }
+      const int sizeGroupIndex = 1;
+      const int nameGroupIndex = 2;
+      pathsToSize[match.group(nameGroupIndex).split('/')] = int.parse(match.group(sizeGroupIndex));
+    }
+
+    final _SymbolNode rootNode = _SymbolNode('Root');
+
+    _SymbolNode currentNode = rootNode;
+    for (final List<String> paths in pathsToSize.keys) {
+      for (final String path in paths) {
+        _SymbolNode childWithPathAsName = currentNode.childByName(path);
+
+        if (childWithPathAsName == null) {
+          childWithPathAsName = _SymbolNode(path);
+          if (path.endsWith('libapp.so')) {
+            childWithPathAsName.name += ' (Dart AOT)';
+          } else if (path.endsWith('libflutter.so')) {
+            childWithPathAsName.name += ' (Flutter Engine)';
+          }
+          currentNode.addChild(childWithPathAsName);
+        }
+        childWithPathAsName.addSize(pathsToSize[paths]);
+        currentNode = childWithPathAsName;
+      }
+      currentNode = rootNode;
+    }
+
+    return rootNode;
+  }
+
+  /// Prints all children paths for the lib/ directory in an APK.
+  ///
+  /// A brief summary of aot snapshot is printed under 'lib/arm64-v8a/libapp.so'.
+  void _printLibChildrenPaths(
+    _SymbolNode currentNode,
+    String totalPath,
+    _SymbolNode aotSnapshotJsonRoot,
+  ) {
+    totalPath += currentNode.name;
+
+    if (currentNode.children.isNotEmpty && !currentNode.name.contains('libapp.so')) {
+      for (final _SymbolNode child in currentNode.children) {
+        _printLibChildrenPaths(child, '$totalPath/', aotSnapshotJsonRoot);
+      }
+    } else {
+      // Print total path and size if currentNode does not have any chilren.
+      _printEntitySize(totalPath, byteSize: currentNode.byteSize, level: 2);
+
+      // We picked this file because arm64-v8a is likely the most popular
+      // architecture. ther architecture sizes should be similar.
+      const String libappPath = 'lib/arm64-v8a/libapp.so';
+      // TODO(peterdjlee): Analyze aot size for all platforms.
+      if (totalPath.contains(libappPath)) {
+        _printAotSnapshotSummary(aotSnapshotJsonRoot);
+      }
+    }
+  }
+
+  /// Go through the AOT gen snapshot size JSON and print out a collapsed summary
+  /// for the first package level.
+  void _printAotSnapshotSummary(_SymbolNode aotSnapshotRoot, {int maxDirectoriesShown = 10}) {
+    _printEntitySize(
+      'Dart AOT symbols accounted decompressed size',
+      byteSize: aotSnapshotRoot.byteSize,
+      level: 3,
+    );
+
+    final List<_SymbolNode> sortedSymbols = aotSnapshotRoot.children.toList()
+      ..sort((_SymbolNode a, _SymbolNode b) => b.byteSize.compareTo(a.byteSize));
+    for (final _SymbolNode node in sortedSymbols.take(maxDirectoriesShown)) {
+      _printEntitySize(node.name, byteSize: node.byteSize, level: 4);
+    }
+  }
+
+  /// Adds breakdown of aot snapshot data as the children of the node at the given path.
+  Map<String, dynamic> _addAotSnapshotDataToApkAnalysis({
+    @required Map<String, dynamic> apkAnalysisJson,
+    @required List<String> path,
+    @required Map<String, dynamic> aotSnapshotJson,
+  }) {
+    Map<String, dynamic> currentLevel = apkAnalysisJson;
+    while (path.isNotEmpty) {
+      final List<Map<String, dynamic>> children = currentLevel['children'] as List<Map<String, dynamic>>;
+      final Map<String, dynamic> childWithPathAsName = children.firstWhere(
+        (Map<String, dynamic> child) => child['n'] as String == path.first,
+      );
+      path.removeAt(0);
+      currentLevel = childWithPathAsName;
+    }
+    currentLevel['children'] = aotSnapshotJson['children'];
+    return apkAnalysisJson;
+  }
+
+  /// Print an entity's name with its size on the same line.
+  void _printEntitySize(
+    String entityName, {
+    @required int byteSize,
+    @required int level,
+    bool showColor = true,
+  }) {
+    final bool emphasis = level <= 1;
+    final String formattedSize = _prettyPrintBytes(byteSize);
+
+    TerminalColor color = TerminalColor.green;
+    if (formattedSize.endsWith('MB')) {
+      color = TerminalColor.cyan;
+    } else if (formattedSize.endsWith('KB')) {
+      color = TerminalColor.yellow;
+    }
+
+    final int spaceInBetween = tableWidth - level * 2 - entityName.length - formattedSize.length;
+    logger.printStatus(
+      entityName + ' ' * spaceInBetween,
+      newline: false,
+      emphasis: emphasis,
+      indent: level * 2,
+    );
+    logger.printStatus(formattedSize, color: showColor ? color : null);
+  }
+
+  String _prettyPrintBytes(int numBytes) {
+    const int kB = 1024;
+    const int mB = kB * 1024;
+    if (numBytes < kB) {
+      return '$numBytes B';
+    } else if (numBytes < mB) {
+      return '${(numBytes / kB).round()} KB';
+    } else {
+      return '${(numBytes / mB).round()} MB';
+    }
+  }
+
+  _SymbolNode _parseAotSnapshot(Map<String, dynamic> aotSnapshotJson) {
+    final bool isLeafNode = aotSnapshotJson['children'] == null;
+    if (!isLeafNode) {
+      return _buildNodeWithChildren(aotSnapshotJson);
+    } else {
+      // TODO(peterdjlee): Investigate why there are leaf nodes with size of null.
+      final int byteSize = aotSnapshotJson['value'] as int;
+      if (byteSize == null) {
+        return null;
+      }
+      return _buildNode(aotSnapshotJson, byteSize);
+    }
+  }
+
+  _SymbolNode _buildNode(
+    Map<String, dynamic> aotSnapshotJson,
+    int byteSize, {
+    List<_SymbolNode> children = const <_SymbolNode>[],
+  }) {
+    final String name = aotSnapshotJson['n'] as String;
+    final Map<String, _SymbolNode> childrenMap = <String, _SymbolNode>{};
+
+    for (final _SymbolNode child in children) {
+      childrenMap[child.name] = child;
+    }
+
+    return _SymbolNode(
+      name,
+      byteSize: byteSize,
+    )..addAllChildren(children);
+  }
+
+  /// Builds a node by recursively building all of its children first
+  /// in order to calculate the sum of its children's sizes.
+  _SymbolNode _buildNodeWithChildren(Map<String, dynamic> aotSnapshotJson) {
+    final List<dynamic> rawChildren = aotSnapshotJson['children'] as List<dynamic>;
+    final List<_SymbolNode> symbolNodeChildren = <_SymbolNode>[];
+    int totalByteSize = 0;
+
+    // Given a child, build its subtree.
+    for (final dynamic child in rawChildren) {
+      final _SymbolNode childTreemapNode = _parseAotSnapshot(child as Map<String, dynamic>);
+      symbolNodeChildren.add(childTreemapNode);
+      totalByteSize += childTreemapNode.byteSize;
+    }
+
+    // If none of the children matched the diff tree type
+    if (totalByteSize == 0) {
+      return null;
+    } else {
+      return _buildNode(
+        aotSnapshotJson,
+        totalByteSize,
+        children: symbolNodeChildren,
+      );
+    }
+  }
+}
+
+/// A node class that represents a single symbol for AOT size snapshots.
+class _SymbolNode {
+  _SymbolNode(
+    this.name, {
+    this.byteSize = 0,
+  })  : assert(name != null),
+        assert(byteSize != null),
+        _children = <String, _SymbolNode>{};
+
+  /// The human friendly identifier for this node.
+  String name;
+
+  int byteSize;
+  void addSize(int sizeToBeAdded) {
+    byteSize += sizeToBeAdded;
+  }
+
+  _SymbolNode get parent => _parent;
+  _SymbolNode _parent;
+
+  Iterable<_SymbolNode> get children => _children.values;
+  final Map<String, _SymbolNode> _children;
+
+  _SymbolNode childByName(String name) => _children[name];
+
+  _SymbolNode addChild(_SymbolNode child) {
+    assert(child.parent == null);
+    assert(!_children.containsKey(child.name),
+        'Cannot add duplicate child key ${child.name}');
+
+    child._parent = this;
+    _children[child.name] = child;
+    return child;
+  }
+
+  void addAllChildren(List<_SymbolNode> children) {
+    children.forEach(addChild);
+  }
+
+  Map<String, dynamic> toJson() {
+    final Map<String, dynamic> json = <String, dynamic>{
+      'n': name,
+      'value': byteSize
+    };
+    final List<Map<String, dynamic>> childrenAsJson = <Map<String, dynamic>>[];
+    for (final _SymbolNode child in children) {
+      childrenAsJson.add(child.toJson());
+    }
+    if (childrenAsJson.isNotEmpty) {
+      json['children'] = childrenAsJson;
+    }
+    return json;
+  }
+}
diff --git a/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart b/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart
new file mode 100644
index 0000000..df554e9
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/base/analyze_size_test.dart
@@ -0,0 +1,167 @@
+// Copyright 2014 The Flutter 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 'package:file/memory.dart';
+import 'package:flutter_tools/src/base/analyze_size.dart';
+import 'package:flutter_tools/src/base/file_system.dart';
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/base/process.dart';
+
+import '../../src/common.dart';
+import '../../src/context.dart';
+
+const FakeCommand unzipCommmand = FakeCommand(
+  command: <String>[
+    'unzip',
+    '-o',
+    '-v',
+    'test.apk',
+    '-d',
+    '/.tmp_rand0/flutter_tools.rand0'
+  ],
+  stdout: '''
+Length   Method    Size  Cmpr    Date    Time   CRC-32   Name
+--------  ------  ------- ---- ---------- ----- --------  ----
+11708  Defl:N     2592  78% 00-00-1980 00:00 07733eef  AndroidManifest.xml
+1399  Defl:N     1092  22% 00-00-1980 00:00 f53d952a  META-INF/CERT.RSA
+46298  Defl:N    14530  69% 00-00-1980 00:00 17df02b8  META-INF/CERT.SF
+46298  Defl:N    14530  69% 00-00-1980 00:00 17df02b8  lib/arm64-v8a/libapp.so
+46298  Defl:N    14530  69% 00-00-1980 00:00 17df02b8  lib/arm64-v8a/libflutter.so
+''',
+);
+
+const String aotSizeOutput = '''[
+    {
+        "l": "dart:_internal",
+        "c": "SubListIterable",
+        "n": "[Optimized] skip",
+        "s": 2400
+    },
+    {
+        "l": "dart:_internal",
+        "c": "SubListIterable",
+        "n": "[Optimized] new SubListIterable.",
+        "s": 3560
+    },
+    {
+        "l": "dart:core",
+        "c": "RangeError",
+        "n": "[Optimized] new RangeError.range",
+        "s": 3920
+    },
+    {
+        "l": "dart:core",
+        "c": "ArgumentError",
+        "n": "[Stub] Allocate ArgumentError",
+        "s": 4650
+    }
+]
+''';
+
+void main() {
+  MemoryFileSystem fileSystem;
+  BufferLogger logger;
+  FakeProcessManager processManager;
+
+  setUp(() {
+    fileSystem = MemoryFileSystem.test();
+    logger = BufferLogger.test();
+    processManager = FakeProcessManager.list(<FakeCommand>[unzipCommmand]);
+  });
+
+  test('builds APK analysis correctly', () async {
+    final SizeAnalyzer sizeAnalyzer = SizeAnalyzer(
+      fileSystem: fileSystem,
+      logger: logger,
+      processUtils: ProcessUtils(
+        processManager: processManager,
+        logger: logger,
+      ),
+    );
+
+    final File apk = fileSystem.file('test.apk')..createSync();
+    final File aotSizeJson = fileSystem.file('test.json')
+      ..createSync()
+      ..writeAsStringSync(aotSizeOutput);
+    final Map<String, dynamic> result = await sizeAnalyzer.analyzeApkSizeAndAotSnapshot(apk: apk, aotSnapshot: aotSizeJson);
+
+    expect(result['type'], contains('apk'));
+
+    final Map<String, dynamic> androidManifestMap = result['children'][0] as Map<String, dynamic>;
+    expect(androidManifestMap['n'], equals('AndroidManifest.xml'));
+    expect(androidManifestMap['value'], equals(2592));
+
+    final Map<String, dynamic> metaInfMap = result['children'][1] as Map<String, dynamic>;
+    expect(metaInfMap['n'], equals('META-INF'));
+    expect(metaInfMap['value'], equals(15622));
+    final Map<String, dynamic> certRsaMap = metaInfMap['children'][0] as Map<String, dynamic>;
+    expect(certRsaMap['n'], equals('CERT.RSA'));
+    expect(certRsaMap['value'], equals(1092));
+    final Map<String, dynamic> certSfMap = metaInfMap['children'][1] as Map<String, dynamic>;
+    expect(certSfMap['n'], equals('CERT.SF'));
+    expect(certSfMap['value'], equals(14530));
+
+    final Map<String, dynamic> libMap = result['children'][2] as Map<String, dynamic>;
+    expect(libMap['n'], equals('lib'));
+    expect(libMap['value'], equals(29060));
+    final Map<String, dynamic> arm64Map = libMap['children'][0] as Map<String, dynamic>;
+    expect(arm64Map['n'], equals('arm64-v8a'));
+    expect(arm64Map['value'], equals(29060));
+    final Map<String, dynamic> libAppMap = arm64Map['children'][0] as Map<String, dynamic>;
+    expect(libAppMap['n'], equals('libapp.so (Dart AOT)'));
+    expect(libAppMap['value'], equals(14530));
+    expect(libAppMap['children'].length, equals(3));
+    final Map<String, dynamic> internalMap = libAppMap['children'][0] as Map<String, dynamic>;
+    final Map<String, dynamic> skipMap = internalMap['children'][0] as Map<String, dynamic>;
+    expect(skipMap['n'], 'skip');
+    expect(skipMap['value'], 2400);
+    final Map<String, dynamic> subListIterableMap = internalMap['children'][1] as Map<String, dynamic>;
+    expect(subListIterableMap['n'], 'new SubListIterable.');
+    expect(subListIterableMap['value'], 3560);
+    final Map<String, dynamic> coreMap = libAppMap['children'][1] as Map<String, dynamic>;
+    final Map<String, dynamic> rangeErrorMap = coreMap['children'][0] as Map<String, dynamic>;
+    expect(rangeErrorMap['n'], 'new RangeError.range');
+    expect(rangeErrorMap['value'], 3920);
+    final Map<String, dynamic> stubsMap = libAppMap['children'][2] as Map<String, dynamic>;
+    final Map<String, dynamic> allocateMap = stubsMap['children'][0] as Map<String, dynamic>;
+    expect(allocateMap['n'], 'Allocate ArgumentError');
+    expect(allocateMap['value'], 4650);
+    final Map<String, dynamic> libFlutterMap = arm64Map['children'][1] as Map<String, dynamic>;
+    expect(libFlutterMap['n'], equals('libflutter.so (Flutter Engine)'));
+    expect(libFlutterMap['value'], equals(14530));
+  });
+
+  test('outputs summary to command line correctly', () async {
+    final SizeAnalyzer sizeAnalyzer = SizeAnalyzer(
+      fileSystem: fileSystem,
+      logger: logger,
+      processUtils: ProcessUtils(
+        processManager: processManager,
+        logger: logger,
+      ),
+    );
+
+    final File apk = fileSystem.file('test.apk')..createSync();
+    final File aotSizeJson = fileSystem.file('test.json')
+      ..createSync()
+      ..writeAsStringSync(aotSizeOutput);
+    await sizeAnalyzer.analyzeApkSizeAndAotSnapshot(apk: apk, aotSnapshot: aotSizeJson);
+
+    final List<String> stdout = logger.statusText.split('\n');
+    expect(
+      stdout,
+      containsAll(<String>[
+        '  AndroidManifest.xml                                                       3 KB',
+        '  META-INF                                                                 15 KB',
+        '  lib                                                                      28 KB',
+        '    lib/arm64-v8a/libapp.so (Dart AOT)                                     14 KB',
+        '      Dart AOT symbols accounted decompressed size                         14 KB',
+        '        dart:_internal/SubListIterable                                      6 KB',
+        '        @stubs/allocation-stubs/dart:core/ArgumentError                     5 KB',
+        '        dart:core/RangeError                                                4 KB',
+        '    lib/arm64-v8a/libflutter.so (Flutter Engine)                           14 KB',
+      ]),
+    );
+  });
+}