1#!/usr/bin/env python
2# Copyright (c) 2011 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Reports binary size metrics for an APK.
7
8More information at //docs/speed/binary_size/metrics.md.
9"""
10
11from __future__ import print_function
12
13import argparse
14import collections
15from contextlib import contextmanager
16import json
17import logging
18import os
19import posixpath
20import re
21import sys
22import tempfile
23import zipfile
24import zlib
25
26import devil_chromium
27from devil.android.sdk import build_tools
28from devil.utils import cmd_helper
29from devil.utils import lazy
30import method_count
31from pylib import constants
32from pylib.constants import host_paths
33
34_AAPT_PATH = lazy.WeakConstant(lambda: build_tools.GetPath('aapt'))
35_BUILD_UTILS_PATH = os.path.join(
36    host_paths.DIR_SOURCE_ROOT, 'build', 'android', 'gyp')
37
38with host_paths.SysPath(os.path.join(host_paths.DIR_SOURCE_ROOT, 'build')):
39  import gn_helpers  # pylint: disable=import-error
40
41with host_paths.SysPath(host_paths.BUILD_COMMON_PATH):
42  import perf_tests_results_helper  # pylint: disable=import-error
43
44with host_paths.SysPath(host_paths.TRACING_PATH):
45  from tracing.value import convert_chart_json  # pylint: disable=import-error
46
47with host_paths.SysPath(_BUILD_UTILS_PATH, 0):
48  from util import build_utils  # pylint: disable=import-error
49  from util import zipalign  # pylint: disable=import-error
50
51
52zipalign.ApplyZipFileZipAlignFix()
53
54# Captures an entire config from aapt output.
55_AAPT_CONFIG_PATTERN = r'config %s:(.*?)config [a-zA-Z-]+:'
56# Matches string resource entries from aapt output.
57_AAPT_ENTRY_RE = re.compile(
58    r'resource (?P<id>\w{10}) [\w\.]+:string/.*?"(?P<val>.+?)"', re.DOTALL)
59_BASE_CHART = {
60    'format_version': '0.1',
61    'benchmark_name': 'resource_sizes',
62    'benchmark_description': 'APK resource size information.',
63    'trace_rerun_options': [],
64    'charts': {}
65}
66# Macro definitions look like (something, 123) when
67# enable_resource_allowlist_generation=true.
68_RC_HEADER_RE = re.compile(r'^#define (?P<name>\w+).* (?P<id>\d+)\)?$')
69_RE_NON_LANGUAGE_PAK = re.compile(r'^assets/.*(resources|percent)\.pak$')
70_READELF_SIZES_METRICS = {
71    'text': ['.text'],
72    'data': ['.data', '.rodata', '.data.rel.ro', '.data.rel.ro.local'],
73    'relocations': ['.rel.dyn', '.rel.plt', '.rela.dyn', '.rela.plt'],
74    'unwind': [
75        '.ARM.extab', '.ARM.exidx', '.eh_frame', '.eh_frame_hdr',
76        '.ARM.exidxsentinel_section_after_text'
77    ],
78    'symbols': [
79        '.dynsym', '.dynstr', '.dynamic', '.shstrtab', '.got', '.plt',
80        '.got.plt', '.hash', '.gnu.hash'
81    ],
82    'other': [
83        '.init_array', '.preinit_array', '.ctors', '.fini_array', '.comment',
84        '.note.gnu.gold-version', '.note.crashpad.info', '.note.android.ident',
85        '.ARM.attributes', '.note.gnu.build-id', '.gnu.version',
86        '.gnu.version_d', '.gnu.version_r', '.interp', '.gcc_except_table'
87    ]
88}
89
90
91def _PercentageDifference(a, b):
92  if a == 0:
93    return 0
94  return float(b - a) / a
95
96
97def _RunReadelf(so_path, options, tool_prefix=''):
98  return cmd_helper.GetCmdOutput(
99      [tool_prefix + 'readelf'] + options + [so_path])
100
101
102def _ExtractLibSectionSizesFromApk(apk_path, lib_path, tool_prefix):
103  with Unzip(apk_path, filename=lib_path) as extracted_lib_path:
104    grouped_section_sizes = collections.defaultdict(int)
105    no_bits_section_sizes, section_sizes = _CreateSectionNameSizeMap(
106        extracted_lib_path, tool_prefix)
107    for group_name, section_names in _READELF_SIZES_METRICS.iteritems():
108      for section_name in section_names:
109        if section_name in section_sizes:
110          grouped_section_sizes[group_name] += section_sizes.pop(section_name)
111
112    # Consider all NOBITS sections as .bss.
113    grouped_section_sizes['bss'] = sum(
114        v for v in no_bits_section_sizes.itervalues())
115
116    # Group any unknown section headers into the "other" group.
117    for section_header, section_size in section_sizes.iteritems():
118      sys.stderr.write('Unknown elf section header: %s\n' % section_header)
119      grouped_section_sizes['other'] += section_size
120
121    return grouped_section_sizes
122
123
124def _CreateSectionNameSizeMap(so_path, tool_prefix):
125  stdout = _RunReadelf(so_path, ['-S', '--wide'], tool_prefix)
126  section_sizes = {}
127  no_bits_section_sizes = {}
128  # Matches  [ 2] .hash HASH 00000000006681f0 0001f0 003154 04   A  3   0  8
129  for match in re.finditer(r'\[[\s\d]+\] (\..*)$', stdout, re.MULTILINE):
130    items = match.group(1).split()
131    target = no_bits_section_sizes if items[1] == 'NOBITS' else section_sizes
132    target[items[0]] = int(items[4], 16)
133
134  return no_bits_section_sizes, section_sizes
135
136
137def _ParseManifestAttributes(apk_path):
138  # Check if the manifest specifies whether or not to extract native libs.
139  skip_extract_lib = False
140  output = cmd_helper.GetCmdOutput([
141      _AAPT_PATH.read(), 'd', 'xmltree', apk_path, 'AndroidManifest.xml'])
142  m = re.search(r'extractNativeLibs\(.*\)=\(.*\)(\w)', output)
143  if m:
144    skip_extract_lib = not bool(int(m.group(1)))
145
146  # Dex decompression overhead varies by Android version.
147  m = re.search(r'android:minSdkVersion\(\w+\)=\(type \w+\)(\w+)', output)
148  sdk_version = int(m.group(1), 16)
149
150  return sdk_version, skip_extract_lib
151
152
153def _NormalizeLanguagePaks(translations, factor):
154  english_pak = translations.FindByPattern(r'.*/en[-_][Uu][Ss]\.l?pak')
155  num_translations = translations.GetNumEntries()
156  ret = 0
157  if english_pak:
158    ret -= translations.ComputeZippedSize()
159    ret += int(english_pak.compress_size * num_translations * factor)
160  return ret
161
162
163def _NormalizeResourcesArsc(apk_path, num_arsc_files, num_translations,
164                            out_dir):
165  """Estimates the expected overhead of untranslated strings in resources.arsc.
166
167  See http://crbug.com/677966 for why this is necessary.
168  """
169  # If there are multiple .arsc files, use the resource packaged APK instead.
170  if num_arsc_files > 1:
171    if not out_dir:
172      return -float('inf')
173    ap_name = os.path.basename(apk_path).replace('.apk', '.ap_')
174    ap_path = os.path.join(out_dir, 'arsc/apks', ap_name)
175    if not os.path.exists(ap_path):
176      raise Exception('Missing expected file: %s, try rebuilding.' % ap_path)
177    apk_path = ap_path
178
179  aapt_output = _RunAaptDumpResources(apk_path)
180  # en-rUS is in the default config and may be cluttered with non-translatable
181  # strings, so en-rGB is a better baseline for finding missing translations.
182  en_strings = _CreateResourceIdValueMap(aapt_output, 'en-rGB')
183  fr_strings = _CreateResourceIdValueMap(aapt_output, 'fr')
184
185  # en-US and en-GB will never be translated.
186  config_count = num_translations - 2
187
188  size = 0
189  for res_id, string_val in en_strings.iteritems():
190    if string_val == fr_strings[res_id]:
191      string_size = len(string_val)
192      # 7 bytes is the per-entry overhead (not specific to any string). See
193      # https://android.googlesource.com/platform/frameworks/base.git/+/android-4.2.2_r1/tools/aapt/StringPool.cpp#414.
194      # The 1.5 factor was determined experimentally and is meant to account for
195      # other languages generally having longer strings than english.
196      size += config_count * (7 + string_size * 1.5)
197
198  return int(size)
199
200
201def _CreateResourceIdValueMap(aapt_output, lang):
202  """Return a map of resource ids to string values for the given |lang|."""
203  config_re = _AAPT_CONFIG_PATTERN % lang
204  return {entry.group('id'): entry.group('val')
205          for config_section in re.finditer(config_re, aapt_output, re.DOTALL)
206          for entry in re.finditer(_AAPT_ENTRY_RE, config_section.group(0))}
207
208
209def _RunAaptDumpResources(apk_path):
210  cmd = [_AAPT_PATH.read(), 'dump', '--values', 'resources', apk_path]
211  status, output = cmd_helper.GetCmdStatusAndOutput(cmd)
212  if status != 0:
213    raise Exception('Failed running aapt command: "%s" with output "%s".' %
214                    (' '.join(cmd), output))
215  return output
216
217
218def _ReportDfmSizes(zip_obj, report_func):
219  sizes = collections.defaultdict(int)
220  for info in zip_obj.infolist():
221    # Looks for paths like splits/vr-master.apk, splits/vr-hi.apk.
222    name_parts = info.filename.split('/')
223    if name_parts[0] == 'splits' and len(name_parts) == 2:
224      name_parts = name_parts[1].split('-')
225      if len(name_parts) == 2:
226        module_name, config_name = name_parts
227        if module_name != 'base' and config_name[:-4] in ('master', 'hi'):
228          sizes[module_name] += info.file_size
229
230  for module_name, size in sorted(sizes.iteritems()):
231    report_func('DFM_' + module_name, 'Size with hindi', size, 'bytes')
232
233
234class _FileGroup(object):
235  """Represents a category that apk files can fall into."""
236
237  def __init__(self, name):
238    self.name = name
239    self._zip_infos = []
240    self._extracted_multipliers = []
241
242  def AddZipInfo(self, zip_info, extracted_multiplier=0):
243    self._zip_infos.append(zip_info)
244    self._extracted_multipliers.append(extracted_multiplier)
245
246  def AllEntries(self):
247    return iter(self._zip_infos)
248
249  def GetNumEntries(self):
250    return len(self._zip_infos)
251
252  def FindByPattern(self, pattern):
253    return next((i for i in self._zip_infos if re.match(pattern, i.filename)),
254                None)
255
256  def FindLargest(self):
257    if not self._zip_infos:
258      return None
259    return max(self._zip_infos, key=lambda i: i.file_size)
260
261  def ComputeZippedSize(self):
262    return sum(i.compress_size for i in self._zip_infos)
263
264  def ComputeUncompressedSize(self):
265    return sum(i.file_size for i in self._zip_infos)
266
267  def ComputeExtractedSize(self):
268    ret = 0
269    for zi, multiplier in zip(self._zip_infos, self._extracted_multipliers):
270      ret += zi.file_size * multiplier
271    return ret
272
273  def ComputeInstallSize(self):
274    return self.ComputeExtractedSize() + self.ComputeZippedSize()
275
276
277def _DoApkAnalysis(apk_filename, apks_path, tool_prefix, out_dir, report_func):
278  """Analyse APK to determine size contributions of different file classes."""
279  file_groups = []
280
281  def make_group(name):
282    group = _FileGroup(name)
283    file_groups.append(group)
284    return group
285
286  def has_no_extension(filename):
287    return os.path.splitext(filename)[1] == ''
288
289  native_code = make_group('Native code')
290  java_code = make_group('Java code')
291  native_resources_no_translations = make_group('Native resources (no l10n)')
292  translations = make_group('Native resources (l10n)')
293  stored_translations = make_group('Native resources stored (l10n)')
294  icu_data = make_group('ICU (i18n library) data')
295  v8_snapshots = make_group('V8 Snapshots')
296  png_drawables = make_group('PNG drawables')
297  res_directory = make_group('Non-compiled Android resources')
298  arsc = make_group('Compiled Android resources')
299  metadata = make_group('Package metadata')
300  unknown = make_group('Unknown files')
301  notices = make_group('licenses.notice file')
302  unwind_cfi = make_group('unwind_cfi (dev and canary only)')
303
304  with zipfile.ZipFile(apk_filename, 'r') as apk:
305    apk_contents = apk.infolist()
306
307  sdk_version, skip_extract_lib = _ParseManifestAttributes(apk_filename)
308
309  # Pre-L: Dalvik - .odex file is simply decompressed/optimized dex file (~1x).
310  # L, M: ART - .odex file is compiled version of the dex file (~4x).
311  # N: ART - Uses Dalvik-like JIT for normal apps (~1x), full compilation for
312  #    shared apps (~4x).
313  # Actual multipliers calculated using "apk_operations.py disk-usage".
314  # Will need to update multipliers once apk obfuscation is enabled.
315  # E.g. with obfuscation, the 4.04 changes to 4.46.
316  speed_profile_dex_multiplier = 1.17
317  orig_filename = apks_path or apk_filename
318  is_webview = 'WebView' in orig_filename
319  is_monochrome = 'Monochrome' in orig_filename
320  is_library = 'Library' in orig_filename
321  is_shared_apk = sdk_version >= 24 and (is_monochrome or is_webview
322                                         or is_library)
323  if sdk_version < 21:
324    # JellyBean & KitKat
325    dex_multiplier = 1.16
326  elif sdk_version < 24:
327    # Lollipop & Marshmallow
328    dex_multiplier = 4.04
329  elif is_shared_apk:
330    # Oreo and above, compilation_filter=speed
331    dex_multiplier = 4.04
332  else:
333    # Oreo and above, compilation_filter=speed-profile
334    dex_multiplier = speed_profile_dex_multiplier
335
336  total_apk_size = os.path.getsize(apk_filename)
337  for member in apk_contents:
338    filename = member.filename
339    if filename.endswith('/'):
340      continue
341    if filename.endswith('.so'):
342      basename = posixpath.basename(filename)
343      should_extract_lib = not skip_extract_lib and basename.startswith('lib')
344      native_code.AddZipInfo(
345          member, extracted_multiplier=int(should_extract_lib))
346    elif filename.endswith('.dex'):
347      java_code.AddZipInfo(member, extracted_multiplier=dex_multiplier)
348    elif re.search(_RE_NON_LANGUAGE_PAK, filename):
349      native_resources_no_translations.AddZipInfo(member)
350    elif filename.endswith('.pak') or filename.endswith('.lpak'):
351      compressed = member.compress_type != zipfile.ZIP_STORED
352      bucket = translations if compressed else stored_translations
353      extracted_multiplier = 0
354      if compressed:
355        extracted_multiplier = int('en_' in filename or 'en-' in filename)
356      bucket.AddZipInfo(member, extracted_multiplier=extracted_multiplier)
357    elif filename == 'assets/icudtl.dat':
358      icu_data.AddZipInfo(member)
359    elif filename.endswith('.bin'):
360      v8_snapshots.AddZipInfo(member)
361    elif filename.startswith('res/'):
362      if (filename.endswith('.png') or filename.endswith('.webp')
363          or has_no_extension(filename)):
364        png_drawables.AddZipInfo(member)
365      else:
366        res_directory.AddZipInfo(member)
367    elif filename.endswith('.arsc'):
368      arsc.AddZipInfo(member)
369    elif filename.startswith('META-INF') or filename == 'AndroidManifest.xml':
370      metadata.AddZipInfo(member)
371    elif filename.endswith('.notice'):
372      notices.AddZipInfo(member)
373    elif filename.startswith('assets/unwind_cfi'):
374      unwind_cfi.AddZipInfo(member)
375    else:
376      unknown.AddZipInfo(member)
377
378  if apks_path:
379    # We're mostly focused on size of Chrome for non-English locales, so assume
380    # Hindi (arbitrarily chosen) locale split is installed.
381    with zipfile.ZipFile(apks_path) as z:
382      hindi_apk_info = z.getinfo('splits/base-hi.apk')
383      total_apk_size += hindi_apk_info.file_size
384      _ReportDfmSizes(z, report_func)
385
386  total_install_size = total_apk_size
387  total_install_size_android_go = total_apk_size
388  zip_overhead = total_apk_size
389
390  for group in file_groups:
391    actual_size = group.ComputeZippedSize()
392    install_size = group.ComputeInstallSize()
393    uncompressed_size = group.ComputeUncompressedSize()
394    extracted_size = group.ComputeExtractedSize()
395    total_install_size += extracted_size
396    zip_overhead -= actual_size
397
398    report_func('Breakdown', group.name + ' size', actual_size, 'bytes')
399    report_func('InstallBreakdown', group.name + ' size', int(install_size),
400                'bytes')
401    # Only a few metrics are compressed in the first place.
402    # To avoid over-reporting, track uncompressed size only for compressed
403    # entries.
404    if uncompressed_size != actual_size:
405      report_func('Uncompressed', group.name + ' size', uncompressed_size,
406                  'bytes')
407
408    if group is java_code and is_shared_apk:
409      # Updates are compiled using quicken, but system image uses speed-profile.
410      extracted_size = int(uncompressed_size * speed_profile_dex_multiplier)
411      total_install_size_android_go += extracted_size
412      report_func('InstallBreakdownGo', group.name + ' size',
413                  actual_size + extracted_size, 'bytes')
414    elif group is translations and apks_path:
415      # Assume Hindi rather than English (accounted for above in total_apk_size)
416      total_install_size_android_go += actual_size
417    else:
418      total_install_size_android_go += extracted_size
419
420  # Per-file zip overhead is caused by:
421  # * 30 byte entry header + len(file name)
422  # * 46 byte central directory entry + len(file name)
423  # * 0-3 bytes for zipalign.
424  report_func('Breakdown', 'Zip Overhead', zip_overhead, 'bytes')
425  report_func('InstallSize', 'APK size', total_apk_size, 'bytes')
426  report_func('InstallSize', 'Estimated installed size',
427              int(total_install_size), 'bytes')
428  if is_shared_apk:
429    report_func('InstallSize', 'Estimated installed size (Android Go)',
430                int(total_install_size_android_go), 'bytes')
431  transfer_size = _CalculateCompressedSize(apk_filename)
432  report_func('TransferSize', 'Transfer size (deflate)', transfer_size, 'bytes')
433
434  # Size of main dex vs remaining.
435  main_dex_info = java_code.FindByPattern('classes.dex')
436  if main_dex_info:
437    main_dex_size = main_dex_info.file_size
438    report_func('Specifics', 'main dex size', main_dex_size, 'bytes')
439    secondary_size = java_code.ComputeUncompressedSize() - main_dex_size
440    report_func('Specifics', 'secondary dex size', secondary_size, 'bytes')
441
442  main_lib_info = native_code.FindLargest()
443  native_code_unaligned_size = 0
444  for lib_info in native_code.AllEntries():
445    section_sizes = _ExtractLibSectionSizesFromApk(
446        apk_filename, lib_info.filename, tool_prefix)
447    native_code_unaligned_size += sum(
448        v for k, v in section_sizes.iteritems() if k != 'bss')
449    # Size of main .so vs remaining.
450    if lib_info == main_lib_info:
451      main_lib_size = lib_info.file_size
452      report_func('Specifics', 'main lib size', main_lib_size, 'bytes')
453      secondary_size = native_code.ComputeUncompressedSize() - main_lib_size
454      report_func('Specifics', 'other lib size', secondary_size, 'bytes')
455
456      for metric_name, size in section_sizes.iteritems():
457        report_func('MainLibInfo', metric_name, size, 'bytes')
458
459  # Main metric that we want to monitor for jumps.
460  normalized_apk_size = total_apk_size
461  # unwind_cfi exists only in dev, canary, and non-channel builds.
462  normalized_apk_size -= unwind_cfi.ComputeZippedSize()
463  # Sections within .so files get 4kb aligned, so use section sizes rather than
464  # file size. Also gets rid of compression.
465  normalized_apk_size -= native_code.ComputeZippedSize()
466  normalized_apk_size += native_code_unaligned_size
467  # Normalized dex size: Size within the zip + size on disk for Android Go
468  # devices running Android O (which ~= uncompressed dex size).
469  # Use a constant compression factor to account for fluctuations.
470  normalized_apk_size -= java_code.ComputeZippedSize()
471  normalized_apk_size += java_code.ComputeUncompressedSize()
472  # Unaligned size should be ~= uncompressed size or something is wrong.
473  # As of now, padding_fraction ~= .007
474  padding_fraction = -_PercentageDifference(
475      native_code.ComputeUncompressedSize(), native_code_unaligned_size)
476  # Ignore this check for small / no native code
477  if native_code.ComputeUncompressedSize() > 1000000:
478    assert 0 <= padding_fraction < .02, (
479        'Padding was: {} (file_size={}, sections_sum={})'.format(
480            padding_fraction, native_code.ComputeUncompressedSize(),
481            native_code_unaligned_size))
482
483  if apks_path:
484    # Locale normalization not needed when measuring only one locale.
485    # E.g. a change that adds 300 chars of unstranslated strings would cause the
486    # metric to be off by only 390 bytes (assuming a multiplier of 2.3 for
487    # Hindi).
488    pass
489  else:
490    # Avoid noise caused when strings change and translations haven't yet been
491    # updated.
492    num_translations = translations.GetNumEntries()
493    num_stored_translations = stored_translations.GetNumEntries()
494
495    if num_translations > 1:
496      # Multipliers found by looking at MonochromePublic.apk and seeing how much
497      # smaller en-US.pak is relative to the average locale.pak.
498      normalized_apk_size += _NormalizeLanguagePaks(translations, 1.17)
499    if num_stored_translations > 1:
500      normalized_apk_size += _NormalizeLanguagePaks(stored_translations, 1.43)
501    if num_translations + num_stored_translations > 1:
502      if num_translations == 0:
503        # WebView stores all locale paks uncompressed.
504        num_arsc_translations = num_stored_translations
505      else:
506        # Monochrome has more configurations than Chrome since it includes
507        # WebView (which supports more locales), but these should mostly be
508        # empty so ignore them here.
509        num_arsc_translations = num_translations
510      normalized_apk_size += _NormalizeResourcesArsc(
511          apk_filename, arsc.GetNumEntries(), num_arsc_translations, out_dir)
512
513  # It will be -Inf for .apk files with multiple .arsc files and no out_dir set.
514  if normalized_apk_size < 0:
515    sys.stderr.write('Skipping normalized_apk_size (no output directory set)\n')
516  else:
517    report_func('Specifics', 'normalized apk size', normalized_apk_size,
518                'bytes')
519  # The "file count" metric cannot be grouped with any other metrics when the
520  # end result is going to be uploaded to the perf dashboard in the HistogramSet
521  # format due to mixed units (bytes vs. zip entries) causing malformed
522  # summaries to be generated.
523  # TODO(https://crbug.com/903970): Remove this workaround if unit mixing is
524  # ever supported.
525  report_func('FileCount', 'file count', len(apk_contents), 'zip entries')
526
527  for info in unknown.AllEntries():
528    sys.stderr.write(
529        'Unknown entry: %s %d\n' % (info.filename, info.compress_size))
530
531
532def _CalculateCompressedSize(file_path):
533  CHUNK_SIZE = 256 * 1024
534  compressor = zlib.compressobj()
535  total_size = 0
536  with open(file_path, 'rb') as f:
537    for chunk in iter(lambda: f.read(CHUNK_SIZE), ''):
538      total_size += len(compressor.compress(chunk))
539  total_size += len(compressor.flush())
540  return total_size
541
542
543def _DoDexAnalysis(apk_filename, report_func):
544  sizes, total_size, num_unique_methods = method_count.ExtractSizesFromZip(
545      apk_filename)
546  cumulative_sizes = collections.defaultdict(int)
547  for classes_dex_sizes in sizes.itervalues():
548    for count_type, count in classes_dex_sizes.iteritems():
549      cumulative_sizes[count_type] += count
550  for count_type, count in cumulative_sizes.iteritems():
551    report_func('Dex', count_type, count, 'entries')
552
553  report_func('Dex', 'unique methods', num_unique_methods, 'entries')
554  report_func('DexCache', 'DexCache', total_size, 'bytes')
555
556
557@contextmanager
558def Unzip(zip_file, filename=None):
559  """Utility for temporary use of a single file in a zip archive."""
560  with build_utils.TempDir() as unzipped_dir:
561    unzipped_files = build_utils.ExtractAll(
562        zip_file, unzipped_dir, True, pattern=filename)
563    if len(unzipped_files) == 0:
564      raise Exception(
565          '%s not found in %s' % (filename, zip_file))
566    yield unzipped_files[0]
567
568
569def _ConfigOutDirAndToolsPrefix(out_dir):
570  if out_dir:
571    constants.SetOutputDirectory(out_dir)
572  else:
573    try:
574      # Triggers auto-detection when CWD == output directory.
575      constants.CheckOutputDirectory()
576      out_dir = constants.GetOutDirectory()
577    except Exception:  # pylint: disable=broad-except
578      return out_dir, ''
579  build_vars = gn_helpers.ReadBuildVars(out_dir)
580  tool_prefix = os.path.join(out_dir, build_vars['android_tool_prefix'])
581  return out_dir, tool_prefix
582
583
584def _AnalyzeInternal(apk_path, report_func, args, apks_path=None):
585  out_dir, tool_prefix = _ConfigOutDirAndToolsPrefix(args.out_dir)
586  _DoApkAnalysis(apk_path, apks_path, tool_prefix, out_dir, report_func)
587  _DoDexAnalysis(apk_path, report_func)
588
589
590def _AnalyzeApkOrApks(report_func, apk_path, args):
591  if apk_path.endswith('.apk'):
592    _AnalyzeInternal(apk_path, report_func, args)
593  elif apk_path.endswith('.apks'):
594    with tempfile.NamedTemporaryFile(suffix='.apk') as f:
595      with zipfile.ZipFile(apk_path) as z:
596        # Currently bundletool is creating two apks when .apks is created
597        # without specifying an sdkVersion. Always measure the one with an
598        # uncompressed shared library.
599        try:
600          info = z.getinfo('splits/base-master_2.apk')
601        except KeyError:
602          info = z.getinfo('splits/base-master.apk')
603        f.write(z.read(info))
604        f.flush()
605      _AnalyzeInternal(f.name, report_func, args, apks_path=apk_path)
606  else:
607    raise Exception('Unknown file type: ' + apk_path)
608
609
610class _Reporter(object):
611  def __init__(self, chartjson):
612    self._chartjson = chartjson
613    self.trace_title_prefix = ''
614    self._combined_metrics = collections.defaultdict(int)
615
616  def __call__(self, graph_title, trace_title, value, units):
617    self._combined_metrics[(graph_title, trace_title, units)] += value
618
619    perf_tests_results_helper.ReportPerfResult(
620        self._chartjson, graph_title, self.trace_title_prefix + trace_title,
621        value, units)
622
623  def SynthesizeTotals(self):
624    for tup, value in sorted(self._combined_metrics.iteritems()):
625      graph_title, trace_title, units = tup
626      perf_tests_results_helper.ReportPerfResult(
627          self._chartjson, graph_title, 'Combined_' + trace_title, value, units)
628
629
630def _ResourceSizes(args):
631  chartjson = _BASE_CHART.copy() if args.output_format else None
632  reporter = _Reporter(chartjson)
633
634  specs = [
635      ('Chrome_', args.trichrome_chrome),
636      ('WebView_', args.trichrome_webview),
637      ('Library_', args.trichrome_library),
638  ]
639  for prefix, path in specs:
640    if path:
641      reporter.trace_title_prefix = prefix
642      _AnalyzeApkOrApks(reporter, path, args)
643
644  if any(path for _, path in specs):
645    reporter.SynthesizeTotals()
646  else:
647    _AnalyzeApkOrApks(reporter, args.input, args)
648
649  if chartjson:
650    _DumpChartJson(args, chartjson)
651
652
653def _DumpChartJson(args, chartjson):
654  if args.output_file == '-':
655    json_file = sys.stdout
656  elif args.output_file:
657    json_file = open(args.output_file, 'w')
658  else:
659    results_path = os.path.join(args.output_dir, 'results-chart.json')
660    logging.critical('Dumping chartjson to %s', results_path)
661    json_file = open(results_path, 'w')
662
663  json.dump(chartjson, json_file, indent=2)
664
665  if json_file is not sys.stdout:
666    json_file.close()
667
668  # We would ideally generate a histogram set directly instead of generating
669  # chartjson then converting. However, perf_tests_results_helper is in
670  # //build, which doesn't seem to have any precedent for depending on
671  # anything in Catapult. This can probably be fixed, but since this doesn't
672  # need to be super fast or anything, converting is a good enough solution
673  # for the time being.
674  if args.output_format == 'histograms':
675    histogram_result = convert_chart_json.ConvertChartJson(results_path)
676    if histogram_result.returncode != 0:
677      raise Exception('chartjson conversion failed with error: ' +
678                      histogram_result.stdout)
679
680    histogram_path = os.path.join(args.output_dir, 'perf_results.json')
681    logging.critical('Dumping histograms to %s', histogram_path)
682    with open(histogram_path, 'w') as json_file:
683      json_file.write(histogram_result.stdout)
684
685
686def main():
687  argparser = argparse.ArgumentParser(description='Print APK size metrics.')
688  argparser.add_argument(
689      '--min-pak-resource-size',
690      type=int,
691      default=20 * 1024,
692      help='Minimum byte size of displayed pak resources.')
693  argparser.add_argument(
694      '--chromium-output-directory',
695      dest='out_dir',
696      type=os.path.realpath,
697      help='Location of the build artifacts.')
698  argparser.add_argument(
699      '--chartjson',
700      action='store_true',
701      help='DEPRECATED. Use --output-format=chartjson '
702      'instead.')
703  argparser.add_argument(
704      '--output-format',
705      choices=['chartjson', 'histograms'],
706      help='Output the results to a file in the given '
707      'format instead of printing the results.')
708  argparser.add_argument('--loadable_module', help='Obsolete (ignored).')
709
710  # Accepted to conform to the isolated script interface, but ignored.
711  argparser.add_argument(
712      '--isolated-script-test-filter', help=argparse.SUPPRESS)
713  argparser.add_argument(
714      '--isolated-script-test-perf-output',
715      type=os.path.realpath,
716      help=argparse.SUPPRESS)
717
718  output_group = argparser.add_mutually_exclusive_group()
719
720  output_group.add_argument(
721      '--output-dir', default='.', help='Directory to save chartjson to.')
722  output_group.add_argument(
723      '--output-file',
724      help='Path to output .json (replaces --output-dir). Works only for '
725      '--output-format=chartjson')
726  output_group.add_argument(
727      '--isolated-script-test-output',
728      type=os.path.realpath,
729      help='File to which results will be written in the '
730      'simplified JSON output format.')
731
732  argparser.add_argument('input', help='Path to .apk or .apks file to measure.')
733  trichrome_group = argparser.add_argument_group(
734      'Trichrome inputs',
735      description='When specified, |input| is used only as Test suite name.')
736  trichrome_group.add_argument(
737      '--trichrome-chrome', help='Path to Trichrome Chrome .apks')
738  trichrome_group.add_argument(
739      '--trichrome-webview', help='Path to Trichrome WebView .apk(s)')
740  trichrome_group.add_argument(
741      '--trichrome-library', help='Path to Trichrome Library .apk')
742  args = argparser.parse_args()
743
744  devil_chromium.Initialize(output_directory=args.out_dir)
745
746  # TODO(bsheedy): Remove this once uses of --chartjson have been removed.
747  if args.chartjson:
748    args.output_format = 'chartjson'
749
750  isolated_script_output = {'valid': False, 'failures': []}
751
752  test_name = 'resource_sizes (%s)' % os.path.basename(args.input)
753
754  if args.isolated_script_test_output:
755    args.output_dir = os.path.join(
756        os.path.dirname(args.isolated_script_test_output), test_name)
757    if not os.path.exists(args.output_dir):
758      os.makedirs(args.output_dir)
759
760  try:
761    _ResourceSizes(args)
762    isolated_script_output = {
763        'valid': True,
764        'failures': [],
765    }
766  finally:
767    if args.isolated_script_test_output:
768      results_path = os.path.join(args.output_dir, 'test_results.json')
769      with open(results_path, 'w') as output_file:
770        json.dump(isolated_script_output, output_file)
771      with open(args.isolated_script_test_output, 'w') as output_file:
772        json.dump(isolated_script_output, output_file)
773
774
775if __name__ == '__main__':
776  main()
777