Add support for recompiling generated APKs in run_on_as_app.py

Bug: 122584632
Change-Id: I391cf9f819e7dd9eb50d40de36af8b0a0df63f99
diff --git a/tools/apk-masseur.py b/tools/apk_masseur.py
similarity index 85%
rename from tools/apk-masseur.py
rename to tools/apk_masseur.py
index 4f24f0f..6013e6f 100755
--- a/tools/apk-masseur.py
+++ b/tools/apk_masseur.py
@@ -36,10 +36,6 @@
   if len(args) != 1:
     parser.error('Expected <apk> argument, got: ' + ' '.join(args))
   apk = args[0]
-  if not options.out:
-    options.out = os.path.basename(apk)
-  if not options.keystore:
-    options.keystore = findKeystore()
   return (options, apk)
 
 def findKeystore():
@@ -81,28 +77,36 @@
   subprocess.check_call(cmd)
   return signed_apk
 
-def main():
-  (options, apk) = parse_options()
+def masseur(
+    apk, dex=None, out=None, adb_options=None, keystore=None, install=False):
+  if not out:
+    out = os.path.basename(apk)
+  if not keystore:
+    keystore = findKeystore()
   with utils.TempDir() as temp:
     processed_apk = None
-    if options.dex:
-      processed_apk = repack(options.dex, apk, temp)
+    if dex:
+      processed_apk = repack(dex, apk, temp)
     else:
       print 'Signing original APK without modifying dex files'
       processed_apk = os.path.join(temp, 'processed.apk')
       shutil.copyfile(apk, processed_apk)
-    signed_apk = sign(processed_apk, options.keystore, temp)
+    signed_apk = sign(processed_apk, keystore, temp)
     aligned_apk = align(signed_apk, temp)
-    print 'Writing result to', options.out
-    shutil.copyfile(aligned_apk, options.out)
+    print 'Writing result to', out
+    shutil.copyfile(aligned_apk, out)
     adb_cmd = ['adb']
-    if options.adb_options:
+    if adb_options:
       adb_cmd.extend(
-          [option for option in options.adb_options.split(' ') if option])
-    if options.install:
-      adb_cmd.extend(['install', '-t', '-r', '-d', options.out]);
+          [option for option in adb_options.split(' ') if option])
+    if install:
+      adb_cmd.extend(['install', '-t', '-r', '-d', out]);
       utils.PrintCmd(adb_cmd)
       subprocess.check_call(adb_cmd)
+
+def main():
+  (options, apk) = parse_options()
+  masseur(apk, **vars(options))
   return 0
 
 if __name__ == '__main__':
diff --git a/tools/as_utils.py b/tools/as_utils.py
index d5db8a3..8b887bf 100644
--- a/tools/as_utils.py
+++ b/tools/as_utils.py
@@ -56,7 +56,8 @@
 
 def remove_r8_dependency(checkout_dir):
   build_file = os.path.join(checkout_dir, 'build.gradle')
-  assert os.path.isfile(build_file), 'Expected a file to be present at {}'.format(build_file)
+  assert os.path.isfile(build_file), (
+      'Expected a file to be present at {}'.format(build_file))
   with open(build_file) as f:
     lines = f.readlines()
   with open(build_file, 'w') as f:
@@ -64,6 +65,28 @@
       if (utils.R8_JAR not in line) and (utils.R8LIB_JAR not in line):
         f.write(line)
 
+def SetPrintConfigurationDirective(app, config, checkout_dir, destination):
+  proguard_config_file = FindProguardConfigurationFile(
+      app, config, checkout_dir)
+  with open(proguard_config_file) as f:
+    lines = f.readlines()
+  with open(proguard_config_file, 'w') as f:
+    for line in lines:
+      if '-printconfiguration' not in line:
+        f.write(line)
+    f.write('-printconfiguration {}\n'.format(destination))
+
+def FindProguardConfigurationFile(app, config, checkout_dir):
+  app_module = config.get('app_module', 'app')
+  candidates = ['proguard-rules.pro', 'proguard-rules.txt', 'proguard.cfg']
+  for candidate in candidates:
+    proguard_config_file = os.path.join(checkout_dir, app_module, candidate)
+    if os.path.isfile(proguard_config_file):
+      return proguard_config_file
+  # Currently assuming that the Proguard configuration file can be found at
+  # one of the predefined locations.
+  assert False
+
 def Move(src, dst):
   print('Moving `{}` to `{}`'.format(src, dst))
   dst_parent = os.path.dirname(dst)
diff --git a/tools/run_on_as_app.py b/tools/run_on_as_app.py
index 26907a0..deea382 100755
--- a/tools/run_on_as_app.py
+++ b/tools/run_on_as_app.py
@@ -3,12 +3,14 @@
 # for details. All rights reserved. Use of this source code is governed by a
 # BSD-style license that can be found in the LICENSE file.
 
+import apk_masseur
 import apk_utils
 import gradle
 import os
 import optparse
 import subprocess
 import sys
+import tempfile
 import time
 import utils
 import zipfile
@@ -128,6 +130,25 @@
   subprocess.check_call(
       ['adb', '-s', emulator_id, 'install', '-r', '-d', apk_dest])
 
+def UninstallApkOnEmulator(app, config):
+  app_id = config.get('app_id')
+  process = subprocess.Popen(
+      ['adb', 'uninstall', app_id],
+      stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+  stdout, stderr = process.communicate()
+
+  if stdout.strip() == 'Success':
+    # Successfully uninstalled
+    return
+
+  if 'Unknown package: {}'.format(app_id) in stderr:
+    # Application not installed
+    return
+
+  raise Exception(
+      'Unexpected result from `adb uninstall {}\nStdout: {}\nStderr: {}'.format(
+          app_id, stdout, stderr))
+
 def WaitForEmulator():
   stdout = subprocess.check_output(['adb', 'devices'])
   if '{}\tdevice'.format(emulator_id) in stdout:
@@ -190,8 +211,8 @@
       apk_dest = None
       result = {}
       try:
-        (apk_dest, profile_dest_dir) = BuildAppWithShrinker(
-          app, config, shrinker, checkout_dir, options)
+        (apk_dest, profile_dest_dir, proguard_config_file) = \
+            BuildAppWithShrinker(app, config, shrinker, checkout_dir, options)
         dex_size = ComputeSizeOfDexFilesInApk(apk_dest)
         result['apk_dest'] = apk_dest,
         result['build_status'] = 'success'
@@ -203,11 +224,36 @@
           print('Error: ' + str(e))
         result['build_status'] = 'failed'
 
-      if options.monkey:
-        if result.get('build_status') == 'success':
+      if result.get('build_status') == 'success':
+        if options.monkey:
           result['monkey_status'] = 'success' if RunMonkey(
               app, config, options, apk_dest) else 'failed'
 
+        if 'r8' in shrinker and options.r8_compilation_steps > 1:
+          recompilation_results = []
+          previous_apk = apk_dest
+          for i in range(1, options.r8_compilation_steps):
+            try:
+              recompiled_apk_dest = os.path.join(
+                  checkout_dir, 'out', shrinker, 'app-release-{}.apk'.format(i))
+              RebuildAppWithShrinker(
+                  previous_apk, recompiled_apk_dest, proguard_config_file, shrinker)
+              recompilation_result = {
+                'apk_dest': recompiled_apk_dest,
+                'build_status': 'success',
+                'dex_size': ComputeSizeOfDexFilesInApk(recompiled_apk_dest)
+              }
+              if options.monkey:
+                recompilation_result['monkey_status'] = 'success' if RunMonkey(
+                    app, config, options, recompiled_apk_dest) else 'failed'
+              recompilation_results.append(recompilation_result)
+              previous_apk = recompiled_apk_dest
+            except Exception as e:
+              warn('Failed to recompile {} with {}'.format(app, shrinker))
+              recompilation_results.append({ 'build_status': 'failed' })
+              break
+          result['recompilation_results'] = recompilation_results
+
       result_per_shrinker[shrinker] = result
 
     if IsTrackedByGit('gradle.properties'):
@@ -218,6 +264,7 @@
 def BuildAppWithShrinker(app, config, shrinker, checkout_dir, options):
   print('Building {} with {}'.format(app, shrinker))
 
+  # Add/remove 'r8.jar' from top-level build.gradle.
   if options.disable_tot:
     as_utils.remove_r8_dependency(checkout_dir)
   else:
@@ -227,7 +274,7 @@
   archives_base_name = config.get(' archives_base_name', app_module)
   flavor = config.get('flavor')
 
-  # Ensure that gradle.properties are not modified before modifying it to
+  # Ensure that gradle.properties is not modified before modifying it to
   # select shrinker.
   if IsTrackedByGit('gradle.properties'):
     GitCheckout('gradle.properties')
@@ -244,6 +291,12 @@
   if not os.path.exists(out):
     os.makedirs(out)
 
+  # Set -printconfiguration in Proguard rules.
+  proguard_config_dest = os.path.abspath(
+      os.path.join(out, 'proguard-rules.pro'))
+  as_utils.SetPrintConfigurationDirective(
+      app, config, checkout_dir, proguard_config_dest)
+
   env = os.environ.copy()
   env['ANDROID_HOME'] = android_home
   env['JAVA_OPTS'] = '-ea'
@@ -307,12 +360,33 @@
   profile_dest_dir = os.path.join(out, 'profile')
   as_utils.MoveProfileReportTo(profile_dest_dir, stdout)
 
-  return (apk_dest, profile_dest_dir)
+  return (apk_dest, profile_dest_dir, proguard_config_dest)
+
+def RebuildAppWithShrinker(apk, apk_dest, proguard_config_file, shrinker):
+  assert 'r8' in shrinker
+  assert apk_dest.endswith('.apk')
+  
+  # Compile given APK with shrinker to temporary zip file.
+  api = 28 # TODO(christofferqa): Should be the one from build.gradle
+  android_jar = os.path.join(utils.REPO_ROOT, utils.ANDROID_JAR.format(api=api))
+  r8_jar = utils.R8LIB_JAR if IsMinifiedR8(shrinker) else utils.R8_JAR
+  zip_dest = apk_dest[:-3] + '.zip'
+
+  cmd = ['java', '-ea', '-jar', r8_jar, '--release', '--pg-conf',
+      proguard_config_file, '--lib', android_jar, '--output', zip_dest, apk]
+  utils.PrintCmd(cmd)
+
+  subprocess.check_output(cmd)
+
+  # Make a copy of the given APK, move the newly generated dex files into the
+  # copied APK, and then sign the APK.
+  apk_masseur.masseur(apk, dex=zip_dest, out=apk_dest)
 
 def RunMonkey(app, config, options, apk_dest):
   if not WaitForEmulator():
     return False
 
+  UninstallApkOnEmulator(app, config)
   InstallApkOnEmulator(apk_dest)
 
   app_id = config.get('app_id')
@@ -371,6 +445,21 @@
             warn('    monkey: {}'.format(monkey_status))
           else:
             success('    monkey: {}'.format(monkey_status))
+        recompilation_results = result.get('recompilation_results', [])
+        i = 1
+        for recompilation_result in recompilation_results:
+          build_status = recompilation_result.get('build_status')
+          if build_status != 'success':
+            print('    recompilation #{}: {}'.format(i, build_status))
+          else:
+            dex_size = recompilation_result.get('dex_size')
+            print('    recompilation #{}'.format(i))
+            print('      dex size: {}'.format(dex_size))
+            if options.monkey:
+              monkey_status = recompilation_result.get('monkey_status')
+              msg = '      monkey: {}'.format(monkey_status)
+              success(msg) if monkey_status == 'success' else warn(msg)
+          i += 1
 
 def ParseOptions(argv):
   result = optparse.OptionParser()
@@ -396,6 +485,10 @@
   result.add_option('--shrinker',
                     help='The shrinkers to use (by default, all are run)',
                     action='append')
+  result.add_option('--r8-compilation-steps', '--r8_compilation_steps',
+                    help='Number of times R8 should be run on each app',
+                    default=2,
+                    type=int)
   result.add_option('--disable-tot', '--disable_tot',
                     help='Whether to disable the use of the ToT version of R8',
                     default=False,
@@ -405,6 +498,9 @@
                     default=False,
                     action='store_true')
   (options, args) = result.parse_args(argv)
+  if options.disable_tot:
+    # r8.jar is required for recompiling the generated APK
+    options.r8_compilation_steps = 1
   if options.shrinker:
     for shrinker in options.shrinker:
       assert shrinker in SHRINKERS