• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (c) 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""Module containing utilities for apk packages."""
5
6import contextlib
7import logging
8import os
9import re
10import shutil
11import tempfile
12import zipfile
13
14import six
15
16from devil import base_error
17from devil.android.ndk import abis
18from devil.android.sdk import aapt
19from devil.android.sdk import bundletool
20from devil.android.sdk import split_select
21from devil.utils import cmd_helper
22
23_logger = logging.getLogger(__name__)
24
25_MANIFEST_ATTRIBUTE_RE = re.compile(r'\s*A: ([^\(\)= ]*)(?:\([^\(\)= ]*\))?='
26                                    r'(?:"(.*)" \(Raw: .*\)|\(type.*?\)(.*))$')
27_MANIFEST_ELEMENT_RE = re.compile(r'\s*(?:E|N): (\S*) .*$')
28_BASE_APK_APKS_RE = re.compile(r'^splits/base-master.*\.apk$')
29
30
31class ApkHelperError(base_error.BaseError):
32  """Exception for APK helper failures."""
33
34  def __init__(self, message):
35    super(ApkHelperError, self).__init__(message)
36
37
38@contextlib.contextmanager
39def _DeleteHelper(files, to_delete):
40  """Context manager that returns |files| and deletes |to_delete| on exit."""
41  try:
42    yield files
43  finally:
44    paths = to_delete if isinstance(to_delete, list) else [to_delete]
45    for path in paths:
46      if os.path.isfile(path):
47        os.remove(path)
48      elif os.path.isdir(path):
49        shutil.rmtree(path)
50      else:
51        raise ApkHelperError('Cannot delete %s' % path)
52
53
54@contextlib.contextmanager
55def _NoopFileHelper(files):
56  """Context manager that returns |files|."""
57  yield files
58
59
60def GetPackageName(apk_path):
61  """Returns the package name of the apk."""
62  return ToHelper(apk_path).GetPackageName()
63
64
65# TODO(jbudorick): Deprecate and remove this function once callers have been
66# converted to ApkHelper.GetInstrumentationName
67def GetInstrumentationName(apk_path):
68  """Returns the name of the Instrumentation in the apk."""
69  return ToHelper(apk_path).GetInstrumentationName()
70
71
72def ToHelper(path_or_helper):
73  """Creates an ApkHelper unless one is already given."""
74  if not isinstance(path_or_helper, six.string_types):
75    return path_or_helper
76  elif path_or_helper.endswith('.apk'):
77    return ApkHelper(path_or_helper)
78  elif path_or_helper.endswith('.apks'):
79    return ApksHelper(path_or_helper)
80  elif path_or_helper.endswith('_bundle'):
81    return BundleScriptHelper(path_or_helper)
82
83  raise ApkHelperError('Unrecognized APK format %s' % path_or_helper)
84
85
86def ToSplitHelper(path_or_helper, split_apks):
87  if isinstance(path_or_helper, SplitApkHelper):
88    if sorted(path_or_helper.split_apk_paths) != sorted(split_apks):
89      raise ApkHelperError('Helper has different split APKs')
90    return path_or_helper
91  elif (isinstance(path_or_helper, six.string_types)
92        and path_or_helper.endswith('.apk')):
93    return SplitApkHelper(path_or_helper, split_apks)
94
95  raise ApkHelperError(
96      'Unrecognized APK format %s, %s' % (path_or_helper, split_apks))
97
98
99# To parse the manifest, the function uses a node stack where at each level of
100# the stack it keeps the currently in focus node at that level (of indentation
101# in the xmltree output, ie. depth in the tree). The height of the stack is
102# determinded by line indentation. When indentation is increased so is the stack
103# (by pushing a new empty node on to the stack). When indentation is decreased
104# the top of the stack is popped (sometimes multiple times, until indentation
105# matches the height of the stack). Each line parsed (either an attribute or an
106# element) is added to the node at the top of the stack (after the stack has
107# been popped/pushed due to indentation).
108def _ParseManifestFromApk(apk_path):
109  aapt_output = aapt.Dump('xmltree', apk_path, 'AndroidManifest.xml')
110  parsed_manifest = {}
111  node_stack = [parsed_manifest]
112  indent = '  '
113
114  if aapt_output[0].startswith('N'):
115    # if the first line is a namespace then the root manifest is indented, and
116    # we need to add a dummy namespace node, then skip the first line (we dont
117    # care about namespaces).
118    node_stack.insert(0, {})
119    output_to_parse = aapt_output[1:]
120  else:
121    output_to_parse = aapt_output
122
123  for line in output_to_parse:
124    if len(line) == 0:
125      continue
126
127    # If namespaces are stripped, aapt still outputs the full url to the
128    # namespace and appends it to the attribute names.
129    line = line.replace('http://schemas.android.com/apk/res/android:',
130                        'android:')
131
132    indent_depth = 0
133    while line[(len(indent) * indent_depth):].startswith(indent):
134      indent_depth += 1
135
136    # Pop the stack until the height of the stack is the same is the depth of
137    # the current line within the tree.
138    node_stack = node_stack[:indent_depth + 1]
139    node = node_stack[-1]
140
141    # Element nodes are a list of python dicts while attributes are just a dict.
142    # This is because multiple elements, at the same depth of tree and the same
143    # name, are all added to the same list keyed under the element name.
144    m = _MANIFEST_ELEMENT_RE.match(line[len(indent) * indent_depth:])
145    if m:
146      manifest_key = m.group(1)
147      if manifest_key in node:
148        node[manifest_key] += [{}]
149      else:
150        node[manifest_key] = [{}]
151      node_stack += [node[manifest_key][-1]]
152      continue
153
154    m = _MANIFEST_ATTRIBUTE_RE.match(line[len(indent) * indent_depth:])
155    if m:
156      manifest_key = m.group(1)
157      if manifest_key in node:
158        raise ApkHelperError(
159            "A single attribute should have one key and one value: {}".format(
160                line))
161      else:
162        node[manifest_key] = m.group(2) or m.group(3)
163      continue
164
165  return parsed_manifest
166
167
168def _ParseNumericKey(obj, key, default=0):
169  val = obj.get(key)
170  if val is None:
171    return default
172  return int(val, 0)
173
174
175def _SplitLocaleString(locale):
176  split_locale = locale.split('-')
177  if len(split_locale) != 2:
178    raise ApkHelperError('Locale has incorrect format: {}'.format(locale))
179  return tuple(split_locale)
180
181
182class _ExportedActivity(object):
183  def __init__(self, name):
184    self.name = name
185    self.actions = set()
186    self.categories = set()
187    self.schemes = set()
188
189
190def _IterateExportedActivities(manifest_info):
191  app_node = manifest_info['manifest'][0]['application'][0]
192  activities = app_node.get('activity', []) + app_node.get('activity-alias', [])
193  for activity_node in activities:
194    # Presence of intent filters make an activity exported by default.
195    has_intent_filter = 'intent-filter' in activity_node
196    if not _ParseNumericKey(
197        activity_node, 'android:exported', default=has_intent_filter):
198      continue
199
200    activity = _ExportedActivity(activity_node.get('android:name'))
201    # Merge all intent-filters into a single set because there is not
202    # currently a need to keep them separate.
203    for intent_filter in activity_node.get('intent-filter', []):
204      for action in intent_filter.get('action', []):
205        activity.actions.add(action.get('android:name'))
206      for category in intent_filter.get('category', []):
207        activity.categories.add(category.get('android:name'))
208      for data in intent_filter.get('data', []):
209        activity.schemes.add(data.get('android:scheme'))
210    yield activity
211
212
213class BaseApkHelper(object):
214  """Abstract base class representing an installable Android app."""
215
216  def __init__(self):
217    self._manifest = None
218
219  @property
220  def path(self):
221    raise NotImplementedError()
222
223  def __repr__(self):
224    return '%s(%s)' % (self.__class__.__name__, self.path)
225
226  def _GetBaseApkPath(self):
227    """Returns context manager providing path to this app's base APK.
228
229    Must be implemented by subclasses.
230    """
231    raise NotImplementedError()
232
233  def GetActivityName(self):
234    """Returns the name of the first launcher Activity in the apk."""
235    manifest_info = self._GetManifest()
236    for activity in _IterateExportedActivities(manifest_info):
237      if ('android.intent.action.MAIN' in activity.actions
238          and 'android.intent.category.LAUNCHER' in activity.categories):
239        return self._ResolveName(activity.name)
240    return None
241
242  def GetViewActivityName(self):
243    """Returns name of the first action=View Activity that can handle http."""
244    manifest_info = self._GetManifest()
245    for activity in _IterateExportedActivities(manifest_info):
246      if ('android.intent.action.VIEW' in activity.actions
247          and 'http' in activity.schemes):
248        return self._ResolveName(activity.name)
249    return None
250
251  def GetInstrumentationName(self,
252                             default='android.test.InstrumentationTestRunner'):
253    """Returns the name of the Instrumentation in the apk."""
254    all_instrumentations = self.GetAllInstrumentations(default=default)
255    if len(all_instrumentations) != 1:
256      raise ApkHelperError(
257          'There is more than one instrumentation. Expected one.')
258    else:
259      return self._ResolveName(all_instrumentations[0]['android:name'])
260
261  def GetAllInstrumentations(self,
262                             default='android.test.InstrumentationTestRunner'):
263    """Returns a list of all Instrumentations in the apk."""
264    try:
265      return self._GetManifest()['manifest'][0]['instrumentation']
266    except KeyError:
267      return [{'android:name': default}]
268
269  def GetPackageName(self):
270    """Returns the package name of the apk."""
271    manifest_info = self._GetManifest()
272    try:
273      return manifest_info['manifest'][0]['package']
274    except KeyError:
275      raise ApkHelperError('Failed to determine package name of %s' % self.path)
276
277  def GetPermissions(self):
278    manifest_info = self._GetManifest()
279    try:
280      return [
281          p['android:name']
282          for p in manifest_info['manifest'][0]['uses-permission']
283      ]
284    except KeyError:
285      return []
286
287  def GetSplitName(self):
288    """Returns the name of the split of the apk."""
289    manifest_info = self._GetManifest()
290    try:
291      return manifest_info['manifest'][0]['split']
292    except KeyError:
293      return None
294
295  def HasIsolatedProcesses(self):
296    """Returns whether any services exist that use isolatedProcess=true."""
297    manifest_info = self._GetManifest()
298    try:
299      application = manifest_info['manifest'][0]['application'][0]
300      services = application['service']
301      return any(
302          _ParseNumericKey(s, 'android:isolatedProcess') for s in services)
303    except KeyError:
304      return False
305
306  def GetAllMetadata(self):
307    """Returns a list meta-data tags as (name, value) tuples."""
308    manifest_info = self._GetManifest()
309    try:
310      application = manifest_info['manifest'][0]['application'][0]
311      metadata = application['meta-data']
312      return [(x.get('android:name'), x.get('android:value')) for x in metadata]
313    except KeyError:
314      return []
315
316  def GetVersionCode(self):
317    """Returns the versionCode as an integer, or None if not available."""
318    manifest_info = self._GetManifest()
319    try:
320      version_code = manifest_info['manifest'][0]['android:versionCode']
321      return int(version_code, 16)
322    except KeyError:
323      return None
324
325  def GetVersionName(self):
326    """Returns the versionName as a string."""
327    manifest_info = self._GetManifest()
328    try:
329      version_name = manifest_info['manifest'][0]['android:versionName']
330      return version_name
331    except KeyError:
332      return ''
333
334  def GetMinSdkVersion(self):
335    """Returns the minSdkVersion as a string, or None if not available.
336
337    Note: this cannot always be cast to an integer."""
338    manifest_info = self._GetManifest()
339    try:
340      uses_sdk = manifest_info['manifest'][0]['uses-sdk'][0]
341      min_sdk_version = uses_sdk['android:minSdkVersion']
342      try:
343        # The common case is for this to be an integer. Convert to decimal
344        # notation (rather than hexadecimal) for readability, but convert back
345        # to a string for type consistency with the general case.
346        return str(int(min_sdk_version, 16))
347      except ValueError:
348        # In general (ex. apps with minSdkVersion set to pre-release Android
349        # versions), minSdkVersion can be a string (usually, the OS codename
350        # letter). For simplicity, don't do any validation on the value.
351        return min_sdk_version
352    except KeyError:
353      return None
354
355  def GetTargetSdkVersion(self):
356    """Returns the targetSdkVersion as a string, or None if not available.
357
358    Note: this cannot always be cast to an integer. If this application targets
359    a pre-release SDK, this returns the SDK codename instead (ex. "R").
360    """
361    manifest_info = self._GetManifest()
362    try:
363      uses_sdk = manifest_info['manifest'][0]['uses-sdk'][0]
364      target_sdk_version = uses_sdk['android:targetSdkVersion']
365      try:
366        # The common case is for this to be an integer. Convert to decimal
367        # notation (rather than hexadecimal) for readability, but convert back
368        # to a string for type consistency with the general case.
369        return str(int(target_sdk_version, 16))
370      except ValueError:
371        # In general (ex. apps targeting pre-release Android versions),
372        # targetSdkVersion can be a string (usually, the OS codename letter).
373        # For simplicity, don't do any validation on the value.
374        return target_sdk_version
375    except KeyError:
376      return None
377
378  def _GetManifest(self):
379    if not self._manifest:
380      with self._GetBaseApkPath() as base_apk_path:
381        self._manifest = _ParseManifestFromApk(base_apk_path)
382    return self._manifest
383
384  def _ResolveName(self, name):
385    name = name.lstrip('.')
386    if '.' not in name:
387      return '%s.%s' % (self.GetPackageName(), name)
388    return name
389
390  def _ListApkPaths(self):
391    with self._GetBaseApkPath() as base_apk_path:
392      with zipfile.ZipFile(base_apk_path) as z:
393        return z.namelist()
394
395  def GetAbis(self):
396    """Returns a list of ABIs in the apk (empty list if no native code)."""
397    # Use lib/* to determine the compatible ABIs.
398    libs = set()
399    for path in self._ListApkPaths():
400      path_tokens = path.split('/')
401      if len(path_tokens) >= 2 and path_tokens[0] == 'lib':
402        libs.add(path_tokens[1])
403    lib_to_abi = {
404        abis.ARM: [abis.ARM, abis.ARM_64],
405        abis.ARM_64: [abis.ARM_64],
406        abis.X86: [abis.X86, abis.X86_64],
407        abis.X86_64: [abis.X86_64]
408    }
409    try:
410      output = set()
411      for lib in libs:
412        for abi in lib_to_abi[lib]:
413          output.add(abi)
414      return sorted(output)
415    except KeyError:
416      raise ApkHelperError('Unexpected ABI in lib/* folder.')
417
418  def GetApkPaths(self,
419                  device,
420                  modules=None,
421                  allow_cached_props=False,
422                  additional_locales=None):
423    """Returns context manager providing list of split APK paths for |device|.
424
425    The paths may be deleted when the context manager exits. Must be implemented
426    by subclasses.
427
428    args:
429      device: The device for which to return split APKs.
430      modules: Extra feature modules to install.
431      allow_cached_props: Allow using cache when querying propery values from
432        |device|.
433    """
434    # pylint: disable=unused-argument
435    raise NotImplementedError()
436
437  @staticmethod
438  def SupportsSplits():
439    return False
440
441
442class ApkHelper(BaseApkHelper):
443  """Represents a single APK Android app."""
444
445  def __init__(self, apk_path):
446    super(ApkHelper, self).__init__()
447    self._apk_path = apk_path
448
449  @property
450  def path(self):
451    return self._apk_path
452
453  def _GetBaseApkPath(self):
454    return _NoopFileHelper(self._apk_path)
455
456  def GetApkPaths(self,
457                  device,
458                  modules=None,
459                  allow_cached_props=False,
460                  additional_locales=None):
461    if modules:
462      raise ApkHelperError('Cannot install modules when installing single APK')
463    return _NoopFileHelper([self._apk_path])
464
465
466class SplitApkHelper(BaseApkHelper):
467  """Represents a multi APK Android app."""
468
469  def __init__(self, base_apk_path, split_apk_paths):
470    super(SplitApkHelper, self).__init__()
471    self._base_apk_path = base_apk_path
472    self._split_apk_paths = split_apk_paths
473
474  @property
475  def path(self):
476    return self._base_apk_path
477
478  @property
479  def split_apk_paths(self):
480    return self._split_apk_paths
481
482  def __repr__(self):
483    return '%s(%s, %s)' % (self.__class__.__name__, self.path,
484                           self.split_apk_paths)
485
486  def _GetBaseApkPath(self):
487    return _NoopFileHelper(self._base_apk_path)
488
489  def GetApkPaths(self,
490                  device,
491                  modules=None,
492                  allow_cached_props=False,
493                  additional_locales=None):
494    if modules:
495      raise ApkHelperError('Cannot install modules when installing single APK')
496    splits = split_select.SelectSplits(
497        device,
498        self.path,
499        self.split_apk_paths,
500        allow_cached_props=allow_cached_props)
501    if len(splits) == 1:
502      _logger.warning('split-select did not select any from %s', splits)
503    return _NoopFileHelper([self._base_apk_path] + splits)
504
505  #override
506  @staticmethod
507  def SupportsSplits():
508    return True
509
510
511class BaseBundleHelper(BaseApkHelper):
512  """Abstract base class representing an Android app bundle."""
513
514  def _GetApksPath(self):
515    """Returns context manager providing path to the bundle's APKS archive.
516
517    Must be implemented by subclasses.
518    """
519    raise NotImplementedError()
520
521  def _GetBaseApkPath(self):
522    try:
523      base_apk_path = tempfile.mkdtemp()
524      with self._GetApksPath() as apks_path:
525        with zipfile.ZipFile(apks_path) as z:
526          base_apks = [s for s in z.namelist() if _BASE_APK_APKS_RE.match(s)]
527          if len(base_apks) < 1:
528            raise ApkHelperError('Cannot find base APK in %s' % self.path)
529          z.extract(base_apks[0], base_apk_path)
530          return _DeleteHelper(
531              os.path.join(base_apk_path, base_apks[0]), base_apk_path)
532    except:
533      shutil.rmtree(base_apk_path)
534      raise
535
536  def GetApkPaths(self,
537                  device,
538                  modules=None,
539                  allow_cached_props=False,
540                  additional_locales=None):
541    locales = [device.GetLocale()]
542    if additional_locales:
543      locales.extend(_SplitLocaleString(l) for l in additional_locales)
544    with self._GetApksPath() as apks_path:
545      try:
546        split_dir = tempfile.mkdtemp()
547        # TODO(tiborg): Support all locales.
548        bundletool.ExtractApks(split_dir, apks_path,
549                               device.product_cpu_abis, locales,
550                               device.GetFeatures(), device.pixel_density,
551                               device.build_version_sdk, modules)
552        splits = [os.path.join(split_dir, p) for p in os.listdir(split_dir)]
553        return _DeleteHelper(splits, split_dir)
554      except:
555        shutil.rmtree(split_dir)
556        raise
557
558  #override
559  @staticmethod
560  def SupportsSplits():
561    return True
562
563
564class ApksHelper(BaseBundleHelper):
565  """Represents a bundle's APKS archive."""
566
567  def __init__(self, apks_path):
568    super(ApksHelper, self).__init__()
569    self._apks_path = apks_path
570
571  @property
572  def path(self):
573    return self._apks_path
574
575  def _GetApksPath(self):
576    return _NoopFileHelper(self._apks_path)
577
578
579class BundleScriptHelper(BaseBundleHelper):
580  """Represents a bundle install script."""
581
582  def __init__(self, bundle_script_path):
583    super(BundleScriptHelper, self).__init__()
584    self._bundle_script_path = bundle_script_path
585
586  @property
587  def path(self):
588    return self._bundle_script_path
589
590  def _GetApksPath(self):
591    apks_path = None
592    try:
593      fd, apks_path = tempfile.mkstemp(suffix='.apks')
594      os.close(fd)
595      cmd = [
596          self._bundle_script_path,
597          'build-bundle-apks',
598          '--output-apks',
599          apks_path,
600      ]
601      status, stdout, stderr = cmd_helper.GetCmdStatusOutputAndError(cmd)
602      if status != 0:
603        raise ApkHelperError('Failed running {} with output\n{}\n{}'.format(
604            ' '.join(cmd), stdout, stderr))
605      return _DeleteHelper(apks_path, apks_path)
606    except:
607      if apks_path:
608        os.remove(apks_path)
609      raise
610