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