# -*- coding: utf-8 -*-
# Copyright 2010-2014, Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
#     * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following disclaimer
# in the documentation and/or other materials provided with the
# distribution.
#     * Neither the name of Google Inc. nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""Splits the specified apk file into each ABIs.

"Fat APK" contains multipul shared objects in order to run on all the ABIs.
But this means such APK is larger than "Thin" APK.
This script creates Thin APKs from Fat APK.

Version code format:
 00000BBBBB:
 B: Build number

 or

 0005BBBBBA
 A: ABI (0: Fat, 5: x86, 4: armeabi-v7a, 3: armeabi, 1:mips)
 B: Build number

Note:
- This process must be done before signing.
- Prefix 5 is introduced because of historical reason.
  Previously Build Number (B) is placed after ABI (A) but
  it's found that swpping the order is reasonable.
  Previously version code for x86 is always greater than that for armeabi.
  Therefore version-check rule like "Version code of update must be greater
  than that of previous" cannot be introduced.
"""


__author__ = "matsuzakit"

import cStringIO
import logging
import optparse
import os
import re
import shutil
import tempfile
import zipfile

from build_tools import android_binary_xml


_UNSIGNED_APK_SUFFIX = '-unsigned.apk'


class Error(Exception):
  """Base exception class."""


class UnexpectedFormatError(Error):
  pass


class IllegalArgumentError(Error):
  pass


def ParseArgs():
  parser = optparse.OptionParser()
  parser.add_option('--dir', dest='bin_dir',
                    help='Binary directory. Files of which name ends with '
                    '"-unsigned.apk" are processed.')
  options = parser.parse_args()[0]
  if not options.bin_dir:
    raise IllegalArgumentError('--dir is mandatory')
  return options


# TODO(matsuzakit): Make zip relating logics independent
#   from file-based operations.
#   Currently they are file-based for reuseabilty.
#   But file-based design is not good from the view points of
#   performance and testability
def DeleteEntriesFromZip(zip_path, delete_file_names):
  """Deletes entries from zip file.

  Args:
    zip_path: Path to zip file.
    delete_file_names: File names in archive to be deleted.
  """
  logging.info('Deleting %s from %s', delete_file_names, zip_path)
  tmp_file = cStringIO.StringIO()
  in_zip_file = zipfile.ZipFile(zip_path)
  try:
    out_zip_file = zipfile.ZipFile(tmp_file, 'w')
    try:
      for zipinfo in in_zip_file.infolist():
        if zipinfo.filename not in delete_file_names:
          # Reusing zipinfo as 1st argument is mandatory
          # because compression_type must be kept.
          out_zip_file.writestr(zipinfo,
                                in_zip_file.read(zipinfo.filename))
    finally:
      out_zip_file.close()
  finally:
    in_zip_file.close()
  with open(zip_path, 'w') as out_file:
    out_file.write(tmp_file.getvalue())


def ReplaceFilesInZip(zip_path, base_directory, file_names,
                      compress_type=zipfile.ZIP_DEFLATED):
  """Replaces files in zip file with given file_names.

  If no corresponding entries in zip file, simply appended.

  Args:
    zip_path: Path to zip file.
    base_directory: Base direcotry of file_names.
    file_names: File names to be appended.
    compress_type: zipfile.ZIP_DEFLATED or zipfile.ZIP_STORED.
  """
  DeleteEntriesFromZip(zip_path, file_names)
  logging.info('Appending %s to %s', file_names, zip_path)
  zip_file = zipfile.ZipFile(zip_path, 'a')
  try:
    for file_name in file_names:
      zip_file.write(os.path.join(base_directory, file_name),
                     file_name, compress_type)
  finally:
    zip_file.close()


def UnzipFiles(zip_path, file_names, out_dir):
  """Extracts files from zip file.

  Args:
    zip_path: Path to zip file.
    file_names: File names to be extracted.
    out_dir: Destination directory.
  Returns:
    Paths of extracted files.
  """
  logging.info('Extracting %s from %s', file_names, zip_path)
  result = []
  zip_file = zipfile.ZipFile(zip_path)
  try:
    for zip_info in zip_file.infolist():
      if zip_info.filename in file_names:
        out_path = os.path.join(out_dir, zip_info.filename)
        if not os.path.isdir(os.path.dirname(out_path)):
          os.makedirs(os.path.dirname(out_path))
        with open(out_path, 'w') as out_file:
          out_file.write(zip_file.read(zip_info.filename))
          result.append(out_path)
  finally:
    zip_file.close()
  return result


def GetVersionCode(base_version_code, abi):
  """Gets version code based on base version code and abi."""
  # armeabi-v7a's version code must be greater than armeabi's.
  # By this v7a's apk is prioritized on the Play.
  # Without this all the ARM devices download armeabi version
  # because armeabi can be run on all of them (including v7a).
  if abi == 'x86':
    abi_code = 5
  elif abi == 'armeabi-v7a':
    abi_code = 4
  elif abi == 'armeabi':
    abi_code = 3
  elif abi == 'mips':
    abi_code = 1
  else:
    raise IllegalArgumentError('Unexpected ABI; %s' % abi)
  if base_version_code >= 10000:
    raise IllegalArgumentError('Version code is greater than 10000. '
                               'It is time to revisit version code scheme.')
  return int('5%05d%d' % (base_version_code, abi_code))


def ModifyAndroidManifestFile(apk_path, abi):
  """Modifies given apk file to make it thin apk.

  After the execution of this method,
  unneeded .so files (evaluated by given abi name)
  are removed and AndroidManifest.xml file's
  version code is modified.

  Args:
    apk_path: the path to the apk file to be modified.
    abi: the ABI name.
  Raises:
    UnexpectedFormatError: manifest element must be only one.
  """
  logging.info('Modifing %s to ABI %s', apk_path, abi)
  temp_dir_in = tempfile.mkdtemp()
  temp_dir_out = tempfile.mkdtemp()
  original_file_paths = UnzipFiles(apk_path,
                                   'AndroidManifest.xml', temp_dir_in)
  if len(original_file_paths) != 1:
    raise UnexpectedFormatError(
        'AndroidManifest.xml file is expected to be only one.')
  original_file_path = original_file_paths[0]
  document = android_binary_xml.AndroidBinaryXml(original_file_path)
  manifest_elements = document.FindElements(None, 'manifest')
  if len(manifest_elements) != 1:
    raise UnexpectedFormatError('manifest element is expected to be only one.')
  manifest_element = manifest_elements[0]
  version_code_attribute = manifest_element.GetAttribute(
      'http://schemas.android.com/apk/res/android', 'versionCode')
  base_version_code = version_code_attribute.GetIntValue()
  logging.info('new ver code %s', GetVersionCode(base_version_code, abi))
  version_code_attribute.SetIntValue(GetVersionCode(base_version_code, abi))
  document.Write(os.path.join(temp_dir_out, 'AndroidManifest.xml'))
  ReplaceFilesInZip(apk_path, temp_dir_out, ['AndroidManifest.xml'])


def GetUnneededFiles(abi_to_files, abi):
  unneeded_files = []
  for entry_abi, entry_files in abi_to_files.iteritems():
    if entry_abi != abi:
      unneeded_files.extend(entry_files)
  logging.info('Unneeded files are %s', unneeded_files)
  return unneeded_files


def CreateCopyFile(original_file, abi_name):
  # Original : Mozc-unsigned.apk
  # Copy : Mozc-x86-unsigned.apk
  copied_file = ''.join(
      [original_file[:original_file.find(_UNSIGNED_APK_SUFFIX)],
       '-', abi_name, _UNSIGNED_APK_SUFFIX])
  logging.info('Copying from %s to %s', original_file, copied_file)
  shutil.copyfile(original_file, copied_file)
  return copied_file


def CreateAbiToFileMapping(file_name):
  zip_file = zipfile.ZipFile(file_name)
  try:
    abi_to_files = {}
    for zip_info in zip_file.infolist():
      m = re.match(r'lib/(.+?)/.*', zip_info.filename)
      if m:
        files = abi_to_files.setdefault(m.group(1), [])
        files.append(zip_info.filename)
    logging.info('ABIs are: %s', abi_to_files.keys())
  finally:
    zip_file.close()
  return abi_to_files


def main():
  # Enable logging.info.
  logging.getLogger().setLevel(logging.INFO)
  options = ParseArgs()

  for apk_file in [os.path.join(options.bin_dir, f)
                   for f in os.listdir(options.bin_dir)
                   if f.endswith(_UNSIGNED_APK_SUFFIX)]:
    logging.info('Processing %s', apk_file)
    abi_to_files = CreateAbiToFileMapping(apk_file)

    for abi in abi_to_files:
      logging.info('Processing ABI: %s', abi)
      copied_file = CreateCopyFile(apk_file, abi)
      unneeded_files = GetUnneededFiles(abi_to_files, abi)
      DeleteEntriesFromZip(copied_file, unneeded_files)
      ModifyAndroidManifestFile(copied_file, abi)


if __name__ == '__main__':
  main()
