Add com.numix.calculator to run_on_as_app.py

In addition to adding com.numix.calculator to the list of test apps, this CL also updates the run_on_as_app.py script to support multiple apps per repository, since the repository https://github.com/numixproject/android-suite contains multiple apps.

Change-Id: I488a79f001b4b659f52f492cde52facbc2ae3965
diff --git a/tools/as_utils.py b/tools/as_utils.py
index 363e1b9..aa160a1 100644
--- a/tools/as_utils.py
+++ b/tools/as_utils.py
@@ -66,14 +66,12 @@
       if ('/r8.jar' not in line) and ('/r8lib.jar' not in line):
         f.write(line)
 
-def GetMinAndCompileSdk(app, config, checkout_dir, apk_reference):
-
-  compile_sdk = config.get('compile_sdk', None)
-  min_sdk = config.get('min_sdk', None)
+def GetMinAndCompileSdk(app, checkout_dir, apk_reference):
+  compile_sdk = app.compile_sdk
+  min_sdk = app.min_sdk
 
   if not compile_sdk or not min_sdk:
-    app_module = config.get('app_module', 'app')
-    build_gradle_file = os.path.join(checkout_dir, app_module, 'build.gradle')
+    build_gradle_file = os.path.join(checkout_dir, app.module, 'build.gradle')
     assert os.path.isfile(build_gradle_file), (
         'Expected to find build.gradle file at {}'.format(build_gradle_file))
 
@@ -82,11 +80,11 @@
       for line in f.readlines():
         stripped = line.strip()
         if stripped.startswith('compileSdkVersion '):
-          if 'compile_sdk' not in config:
+          if not app.compile_sdk:
             assert not compile_sdk
             compile_sdk = int(stripped[len('compileSdkVersion '):])
         elif stripped.startswith('minSdkVersion '):
-          if 'min_sdk' not in config:
+          if not app.min_sdk:
             assert not min_sdk
             min_sdk = int(stripped[len('minSdkVersion '):])
 
@@ -123,9 +121,8 @@
       or 'transformClassesWithDexBuilderFor' in x
       or 'transformDexArchiveWithDexMergerFor' in x)
 
-def SetPrintConfigurationDirective(app, config, checkout_dir, destination):
-  proguard_config_file = FindProguardConfigurationFile(
-      app, config, checkout_dir)
+def SetPrintConfigurationDirective(app, checkout_dir, destination):
+  proguard_config_file = FindProguardConfigurationFile(app, checkout_dir)
   with open(proguard_config_file) as f:
     lines = f.readlines()
   with open(proguard_config_file, 'w') as f:
@@ -137,11 +134,10 @@
       f.write('\n')
     f.write('-printconfiguration {}\n'.format(destination))
 
-def FindProguardConfigurationFile(app, config, checkout_dir):
-  app_module = config.get('app_module', 'app')
+def FindProguardConfigurationFile(app, checkout_dir):
   candidates = ['proguard-rules.pro', 'proguard-rules.txt', 'proguard.cfg']
   for candidate in candidates:
-    proguard_config_file = os.path.join(checkout_dir, app_module, candidate)
+    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
diff --git a/tools/run_on_as_app.py b/tools/run_on_as_app.py
index aa7ed29..65c8923 100755
--- a/tools/run_on_as_app.py
+++ b/tools/run_on_as_app.py
@@ -29,141 +29,306 @@
     and os.path.isdir(os.environ['R8_BENCHMARK_DIR'])):
   WORKING_DIR = os.environ['R8_BENCHMARK_DIR']
 
-# For running on Golem all APPS are bundled as an x20-dependency and then copied
-# to WORKING_DIR. To update the app-bundle use 'run_on_as_app_x20_packager.py'.
-APPS = {
-  # 'app-name': {
-  #     'git_repo': ...
+class Repo(object):
+  def __init__(self, fields):
+    self.__dict__ = fields
+
+    # If there is only one app in this repository, then give the app the same
+    # name as the repository, if it does not already have one.
+    if len(self.apps) == 1:
+      app = self.apps[0]
+      if not app.name:
+        app.name = self.name
+
+class App(object):
+  def __init__(self, fields):
+    module = fields.get('module', 'app')
+    defaults = {
+      'archives_base_name': module,
+      'build_dir': 'build',
+      'compile_sdk': None,
+      'dir': '.',
+      'flavor': None,
+      'main_dex_rules': None,
+      'module': module,
+      'min_sdk': None,
+      'name': None,
+      'releaseTarget': None,
+      'signed_apk_name': None,
+      'skip': False
+    }
+    self.__dict__ = dict(defaults.items() + fields.items())
+
+# For running on Golem all third-party repositories are bundled as an x20-
+# dependency and then copied to WORKING_DIR. To update the app-bundle use
+# 'run_on_as_app_x20_packager.py'.
+APP_REPOSITORIES = [
+  # ...
+  # Repo({
+  #     'name': ...,
+  #     'url': ...,
   #     'revision': ...,
-  #     'app_module': ... (default app)
-  #     'archives_base_name': ... (default same as app_module)
-  #     'flavor': ... (default no flavor)
-  #     'releaseTarget': ... (default <app_module>:assemble<flavor>Release
-  # },
-  'AnExplorer': {
-      'app_id': 'dev.dworks.apps.anexplorer.pro',
-      'git_repo': 'https://github.com/christofferqa/AnExplorer',
+  #     'apps': [
+  #         {
+  #             'id': ...,
+  #             'dir': ...,
+  #             'module': ... (default app)
+  #             'name': ...,
+  #             'archives_base_name': ... (default same as module)
+  #             'flavor': ... (default no flavor)
+  #             'releaseTarget': ... (default <module>:assemble<flavor>Release
+  #         },
+  #         ...
+  #     ]
+  # }),
+  # ...
+  Repo({
+      'name': 'android-suite',
+      'url': 'https://github.com/christofferqa/android-suite',
+      'revision': '46c96f214711cf6cdcb72cc0c94520ef418e3739',
+      'apps': [
+          App({
+              'id': 'com.numix.calculator',
+              'dir': 'Calculator',
+              'name': 'numix-calculator'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'AnExplorer',
+      'url': 'https://github.com/christofferqa/AnExplorer',
       'revision': '365927477b8eab4052a1882d5e358057ae3dee4d',
-      'flavor': 'googleMobilePro',
-      'signed-apk-name': 'AnExplorer-googleMobileProRelease-4.0.3.apk',
-      'min_sdk': 17
-  },
-  'AntennaPod': {
-      'app_id': 'de.danoeh.antennapod',
-      'git_repo': 'https://github.com/christofferqa/AntennaPod.git',
+      'apps': [
+          App({
+              'id': 'dev.dworks.apps.anexplorer.pro',
+              'flavor': 'googleMobilePro',
+              'signed_apk_name': 'AnExplorer-googleMobileProRelease-4.0.3.apk',
+              'min_sdk': 17
+          })
+      ]
+  }),
+  Repo({
+      'name': 'AntennaPod',
+      'url': 'https://github.com/christofferqa/AntennaPod.git',
       'revision': '77e94f4783a16abe9cc5b78dc2d2b2b1867d8c06',
-      'flavor': 'play',
-      'min_sdk': 14,
-      'compile_sdk': 26
-  },
-  'apps-android-wikipedia': {
-      'app_id': 'org.wikipedia',
-      'git_repo': 'https://github.com/christofferqa/apps-android-wikipedia',
+      'apps': [
+          App({
+              'id': 'de.danoeh.antennapod',
+              'flavor': 'play',
+              'min_sdk': 14,
+              'compile_sdk': 26
+          })
+      ]
+  }),
+  Repo({
+      'name': 'apps-android-wikipedia',
+      'url': 'https://github.com/christofferqa/apps-android-wikipedia',
       'revision': '686e8aa5682af8e6a905054b935dd2daa57e63ee',
-      'flavor': 'prod',
-      'signed-apk-name': 'app-prod-universal-release.apk',
-  },
-  'chanu': {
-      'app_id': 'com.chanapps.four.activity',
-      'git_repo': 'https://github.com/mkj-gram/chanu.git',
+      'apps': [
+          App({
+              'id': 'org.wikipedia',
+              'flavor': 'prod',
+              'signed_apk_name': 'app-prod-universal-release.apk'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'chanu',
+      'url': 'https://github.com/mkj-gram/chanu.git',
       'revision': '04ade1e9c33d707f0850d5eb9d6fa5e8af814a26',
-  },
-  'friendlyeats-android': {
-      'app_id': 'com.google.firebase.example.fireeats',
-      'git_repo': 'https://github.com/christofferqa/friendlyeats-android.git',
+      'apps': [
+          App({
+              'id': 'com.chanapps.four.activity'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'friendlyeats-android',
+      'url': 'https://github.com/christofferqa/friendlyeats-android.git',
       'revision': '10091fa0ec37da12e66286559ad1b6098976b07b',
-  },
-  'Instabug-Android': {
-      'app_id': 'com.example.instabug',
-      'git_repo': 'https://github.com/christofferqa/Instabug-Android.git',
-      'revision': 'b8df78c96630a6537fbc07787b4990afc030cc0f'
-  },
-  'KISS': {
-      'app_id': 'fr.neamar.kiss',
-      'git_repo': 'https://github.com/christofferqa/KISS',
+      'apps': [
+          App({
+              'id': 'com.google.firebase.example.fireeats'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Instabug-Android',
+      'url': 'https://github.com/christofferqa/Instabug-Android.git',
+      'revision': 'b8df78c96630a6537fbc07787b4990afc030cc0f',
+      'apps': [
+          App({
+             'id': 'com.example.instabug'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'KISS',
+      'url': 'https://github.com/christofferqa/KISS',
       'revision': '093da9ee0512e67192f62951c45a07a616fc3224',
-  },
-  'materialistic': {
-      'app_id': 'io.github.hidroh.materialistic',
-      'git_repo': 'https://github.com/christofferqa/materialistic',
+      'apps': [
+          App({
+              'id': 'fr.neamar.kiss'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'materialistic',
+      'url': 'https://github.com/christofferqa/materialistic',
       'revision': '2b2b2ee25ce9e672d5aab1dc90a354af1522b1d9',
-  },
-  'Minimal-Todo': {
-      'app_id': 'com.avjindersinghsekhon.minimaltodo',
-      'git_repo': 'https://github.com/christofferqa/Minimal-Todo',
+      'apps': [
+          App({
+              'id': 'io.github.hidroh.materialistic'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Minimal-Todo',
+      'url': 'https://github.com/christofferqa/Minimal-Todo',
       'revision': '9d8c73746762cd376b718858ec1e8783ca07ba7c',
-  },
-  'NewPipe': {
-      'app_id': 'org.schabi.newpipe',
-      'git_repo': 'https://github.com/christofferqa/NewPipe',
+      'apps': [
+          App({
+              'id': 'com.avjindersinghsekhon.minimaltodo'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'NewPipe',
+      'url': 'https://github.com/christofferqa/NewPipe',
       'revision': 'ed543099c7823be00f15d9340f94bdb7cb37d1e6',
-  },
-  'rover-android': {
-      'app_id': 'io.rover.app.debug',
-      'app_module': 'debug-app',
-      'git_repo': 'https://github.com/mkj-gram/rover-android.git',
+      'apps': [
+          App({
+              'id': 'org.schabi.newpipe'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'rover-android',
+      'url': 'https://github.com/mkj-gram/rover-android.git',
       'revision': '859af82ba56fe9035ae9949156c7a88e6012d930',
-  },
-  'Signal-Android': {
-      'app_id': 'org.thoughtcrime.securesms',
-      'app_module': '',
-      'flavor': 'play',
-      'git_repo': 'https://github.com/mkj-gram/Signal-Android.git',
-      'main_dex_rules': 'multidex-config.pro',
+      'apps': [
+          App({
+              'id': 'io.rover.app.debug',
+              'module': 'debug-app'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Signal-Android',
+      'url': 'https://github.com/mkj-gram/Signal-Android.git',
       'revision': 'a45d0c1fed20fa39e8b9445fe7790326f46b3166',
-      'releaseTarget': 'assemblePlayRelease',
-      'signed-apk-name': 'Signal-play-release-4.32.7.apk',
-  },
-  'Simple-Calendar': {
-      'app_id': 'com.simplemobiletools.calendar.pro',
-      'git_repo': 'https://github.com/christofferqa/Simple-Calendar',
+      'apps': [
+          App({
+              'id': 'org.thoughtcrime.securesms',
+              'module': '',
+              'flavor': 'play',
+              'main_dex_rules': 'multidex-config.pro',
+              'releaseTarget': 'assemblePlayRelease',
+              'signed_apk_name': 'Signal-play-release-4.32.7.apk'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Simple-Calendar',
+      'url': 'https://github.com/christofferqa/Simple-Calendar',
       'revision': '82dad8c203eea5a0f0ddb513506d8f1de986ef2b',
-      'signed-apk-name': 'calendar-release.apk'
-  },
-  'sqldelight': {
-      'app_id': 'com.example.sqldelight.hockey',
-      'git_repo': 'https://github.com/christofferqa/sqldelight.git',
+      'apps': [
+          App({
+              'id': 'com.simplemobiletools.calendar.pro',
+              'signed_apk_name': 'calendar-release.apk'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'sqldelight',
+      'url': 'https://github.com/christofferqa/sqldelight.git',
       'revision': '2e67a1126b6df05e4119d1e3a432fde51d76cdc8',
-      'app_module': 'sample/android',
-      'archives_base_name': 'android',
-      'min_sdk': 14,
-      'compile_sdk': 28,
-  },
-  'tachiyomi': {
-      'app_id': 'eu.kanade.tachiyomi',
-      'git_repo': 'https://github.com/sgjesse/tachiyomi.git',
+      'apps': [
+          App({
+              'id': 'com.example.sqldelight.hockey',
+              'module': 'sample/android',
+              'archives_base_name': 'android',
+              'min_sdk': 14,
+              'compile_sdk': 28
+          })
+      ]
+  }),
+  Repo({
+      'name': 'tachiyomi',
+      'url': 'https://github.com/sgjesse/tachiyomi.git',
       'revision': 'b15d2fe16864645055af6a745a62cc5566629798',
-      'flavor': 'standard',
-      'releaseTarget': 'app:assembleRelease',
-      'min_sdk': 16
-  },
-  'tivi': {
-      'app_id': 'app.tivi',
-      'git_repo': 'https://github.com/sgjesse/tivi.git',
+      'apps': [
+          App({
+              'id': 'eu.kanade.tachiyomi',
+              'flavor': 'standard',
+              'releaseTarget': 'app:assembleRelease',
+              'min_sdk': 16
+          })
+      ]
+  }),
+  Repo({
+      'name': 'tivi',
+      'url': 'https://github.com/sgjesse/tivi.git',
       'revision': '25c52e3593e7c98da4e537b49b29f6f67f88754d',
-      'min_sdk': 23,
-      'compile_sdk': 28,
-  },
-  'Tusky': {
-      'app_id': 'com.keylesspalace.tusky',
-      'git_repo': 'https://github.com/mkj-gram/Tusky.git',
+      'apps': [
+          App({
+              'id': 'app.tivi',
+              'min_sdk': 23,
+              'compile_sdk': 28
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Tusky',
+      'url': 'https://github.com/mkj-gram/Tusky.git',
       'revision': 'b794f3ab90388add98461ffe70edb65c39351c33',
-      'flavor': 'blue'
-  },
-  'Vungle-Android-SDK': {
-      'app_id': 'com.publisher.vungle.sample',
-      'git_repo': 'https://github.com/mkj-gram/Vungle-Android-SDK.git',
+      'apps': [
+          App({
+              'id': 'com.keylesspalace.tusky',
+              'flavor': 'blue'
+          })
+      ]
+  }),
+  Repo({
+      'name': 'Vungle-Android-SDK',
+      'url': 'https://github.com/mkj-gram/Vungle-Android-SDK.git',
       'revision': '3e231396ea7ce97b2655e03607497c75730e45f6',
-  },
+      'apps': [
+          App({
+              'id': 'com.publisher.vungle.sample'
+          })
+      ]
+  }),
   # This does not build yet.
-  'muzei': {
-      'git_repo': 'https://github.com/sgjesse/muzei.git',
+  Repo({
+      'name': 'muzei',
+      'url': 'https://github.com/sgjesse/muzei.git',
       'revision': 'bed2a5f79c6e08b0a21e3e3f9242232d0848ef74',
-      'app_module': 'main',
-      'archives_base_name': 'muzei',
-      'skip': True,
-  },
-}
+      'apps': [
+          App({
+              'module': 'main',
+              'archives_base_name': 'muzei',
+              'skip': True
+          })
+      ]
+  })
+]
+
+def GetAllApps():
+  apps = []
+  for repo in APP_REPOSITORIES:
+    for app in repo.apps:
+      apps.append((app, repo))
+  return apps
+
+def GetAllAppNames():
+  return [app.name for (app, repo) in GetAllApps()]
+
+def GetAppWithName(query):
+  for (app, repo) in GetAllApps():
+    if app.name == query:
+      return (app, repo)
+  assert False
 
 # TODO(christofferqa): Do not rely on 'emulator-5554' name
 emulator_id = 'emulator-5554'
@@ -221,15 +386,15 @@
 def IsTrackedByGit(file):
   return subprocess.check_output(['git', 'ls-files', file]).strip() != ''
 
-def GitClone(git_url, revision, checkout_dir, quiet):
+def GitClone(repo, checkout_dir, quiet):
   result = subprocess.check_output(
-      ['git', 'clone', git_url, checkout_dir]).strip()
+      ['git', 'clone', repo.url, checkout_dir]).strip()
   head_rev = utils.get_HEAD_sha1_for_checkout(checkout_dir)
-  if revision == head_rev:
+  if repo.revision == head_rev:
     return result
   warn('Target revision is not head in {}.'.format(checkout_dir))
   with utils.ChangedWorkingDirectory(checkout_dir, quiet=quiet):
-    subprocess.check_output(['git', 'reset', '--hard', revision])
+    subprocess.check_output(['git', 'reset', '--hard', repo.revision])
   return result
 
 def GitCheckout(file):
@@ -249,10 +414,9 @@
   else:
     return '+' + str(round((after - before) / before * 100)) + '%'
 
-def UninstallApkOnEmulator(app, config, options):
-  app_id = config.get('app_id')
+def UninstallApkOnEmulator(app, options):
   process = subprocess.Popen(
-      ['adb', '-s', emulator_id, 'uninstall', app_id],
+      ['adb', '-s', emulator_id, 'uninstall', app.id],
       stdout=subprocess.PIPE, stderr=subprocess.PIPE)
   stdout, stderr = process.communicate()
 
@@ -260,13 +424,13 @@
     # Successfully uninstalled
     return
 
-  if 'Unknown package: {}'.format(app_id) in stderr:
+  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))
+          app.id, stdout, stderr))
 
 def WaitForEmulator():
   stdout = subprocess.check_output(['adb', 'devices'])
@@ -288,21 +452,21 @@
     else:
       return True
 
-def GetResultsForApp(app, config, options, temp_dir):
+def GetResultsForApp(app, repo, options, temp_dir):
   # Checkout and build in the build directory.
-  checkout_dir = os.path.join(WORKING_DIR, app)
+  repo_name = repo.name
+  repo_checkout_dir = os.path.join(WORKING_DIR, repo_name)
 
   result = {}
 
-  if not os.path.exists(checkout_dir) and not options.golem:
+  if not os.path.exists(repo_checkout_dir) and not options.golem:
     with utils.ChangedWorkingDirectory(WORKING_DIR, quiet=options.quiet):
-      GitClone(
-          config['git_repo'], config['revision'], checkout_dir, options.quiet)
+      GitClone(repo, repo_checkout_dir, options.quiet)
 
-  checkout_rev = utils.get_HEAD_sha1_for_checkout(checkout_dir)
-  if config['revision'] != checkout_rev:
+  checkout_rev = utils.get_HEAD_sha1_for_checkout(repo_checkout_dir)
+  if repo.revision != checkout_rev:
     msg = 'Checkout is not target revision for {} in {}.'.format(
-        app, checkout_dir)
+        app.name, repo_checkout_dir)
     if options.ignore_versions:
       warn(msg)
     else:
@@ -310,14 +474,16 @@
 
   result['status'] = 'success'
 
+  app_checkout_dir = os.path.join(repo_checkout_dir, app.dir)
   result_per_shrinker = BuildAppWithSelectedShrinkers(
-      app, config, options, checkout_dir, temp_dir)
+      app, repo, options, app_checkout_dir, temp_dir)
   for shrinker, shrinker_result in result_per_shrinker.iteritems():
     result[shrinker] = shrinker_result
 
   return result
 
-def BuildAppWithSelectedShrinkers(app, config, options, checkout_dir, temp_dir):
+def BuildAppWithSelectedShrinkers(
+    app, repo, options, checkout_dir, temp_dir):
   result_per_shrinker = {}
 
   with utils.ChangedWorkingDirectory(checkout_dir, quiet=options.quiet):
@@ -328,8 +494,9 @@
       try:
         out_dir = os.path.join(checkout_dir, 'out', shrinker)
         (apk_dest, profile_dest_dir, proguard_config_file) = \
-            BuildAppWithShrinker(app, config, shrinker, checkout_dir, out_dir,
-                temp_dir, options)
+            BuildAppWithShrinker(
+                app, repo, shrinker, checkout_dir, out_dir, temp_dir,
+                options)
         dex_size = ComputeSizeOfDexFilesInApk(apk_dest)
         result['apk_dest'] = apk_dest
         result['build_status'] = 'success'
@@ -349,7 +516,7 @@
       if result.get('build_status') == 'success':
         if options.monkey:
           result['monkey_status'] = 'success' if RunMonkey(
-              app, config, options, apk_dest) else 'failed'
+              app, options, apk_dest) else 'failed'
 
         if 'r8' in shrinker and options.r8_compilation_steps > 1:
           recompilation_results = []
@@ -358,7 +525,8 @@
           # true.
           out_dir = os.path.join(checkout_dir, 'out', shrinker + '-1')
           (apk_dest, profile_dest_dir, ext_proguard_config_file) = \
-              BuildAppWithShrinker(app, config, shrinker, checkout_dir, out_dir,
+              BuildAppWithShrinker(
+                  app, repo, shrinker, checkout_dir, out_dir,
                   temp_dir, options, keepRuleSynthesisForRecompilation=True)
           dex_size = ComputeSizeOfDexFilesInApk(apk_dest)
           recompilation_result = {
@@ -380,17 +548,16 @@
                       if line.strip() and '-printconfiguration' not in line))
 
           # Extract min-sdk and target-sdk
-          (min_sdk, compile_sdk) = as_utils.GetMinAndCompileSdk(app, config,
-              checkout_dir, apk_dest)
+          (min_sdk, compile_sdk) = \
+              as_utils.GetMinAndCompileSdk(app, checkout_dir, apk_dest)
 
           # Now rebuild generated apk.
           previous_apk = apk_dest
 
           # We may need main dex rules when re-compiling with R8 as standalone.
           main_dex_rules = None
-          if config.get('main_dex_rules'):
-            main_dex_rules = os.path.join(
-                checkout_dir, config.get('main_dex_rules'))
+          if app.main_dex_rules:
+            main_dex_rules = os.path.join(checkout_dir, app.main_dex_rules)
 
           for i in range(1, options.r8_compilation_steps):
             try:
@@ -407,18 +574,19 @@
               }
               if options.monkey:
                 recompilation_result['monkey_status'] = 'success' if RunMonkey(
-                    app, config, options, recompiled_apk_dest) else 'failed'
+                    app, 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))
+              warn('Failed to recompile {} with {}'.format(
+                  app.name, shrinker))
               recompilation_results.append({ 'build_status': 'failed' })
               break
           result['recompilation_results'] = recompilation_results
 
       result_per_shrinker[shrinker] = result
 
-  if not options.app:
+  if len(options.apps) > 1:
     print('')
     LogResultsForApp(app, result_per_shrinker, options)
     print('')
@@ -426,10 +594,10 @@
   return result_per_shrinker
 
 def BuildAppWithShrinker(
-    app, config, shrinker, checkout_dir, out_dir, temp_dir, options,
+    app, repo, shrinker, checkout_dir, out_dir, temp_dir, options,
     keepRuleSynthesisForRecompilation=False):
   print('Building {} with {}{}'.format(
-      app,
+      app.name,
       shrinker,
       ' for recompilation' if keepRuleSynthesisForRecompilation else ''))
 
@@ -441,9 +609,7 @@
   # Add 'r8.jar' to top-level build.gradle.
   as_utils.add_r8_dependency(checkout_dir, temp_dir, IsMinifiedR8(shrinker))
 
-  app_module = config.get('app_module', 'app')
-  archives_base_name = config.get('archives_base_name', app_module)
-  flavor = config.get('flavor')
+  archives_base_name = app.archives_base_name
 
   if not os.path.exists(out_dir):
     os.makedirs(out_dir)
@@ -452,16 +618,16 @@
   proguard_config_dest = os.path.abspath(
       os.path.join(out_dir, 'proguard-rules.pro'))
   as_utils.SetPrintConfigurationDirective(
-      app, config, checkout_dir, proguard_config_dest)
+      app, checkout_dir, proguard_config_dest)
 
   env = {}
   env['ANDROID_HOME'] = utils.getAndroidHome()
   env['JAVA_OPTS'] = '-ea:com.android.tools.r8...'
 
-  releaseTarget = config.get('releaseTarget')
+  releaseTarget = app.releaseTarget
   if not releaseTarget:
-    releaseTarget = app_module.replace('/', ':') + ':' + 'assemble' + (
-        flavor.capitalize() if flavor else '') + 'Release'
+    releaseTarget = app.module.replace('/', ':') + ':' + 'assemble' + (
+        app.flavor.capitalize() if app.flavor else '') + 'Release'
 
   # Value for property android.enableR8.
   enableR8 = 'r8' in shrinker
@@ -480,14 +646,17 @@
   stdout = utils.RunCmd(cmd, env, quiet=options.quiet)
 
   apk_base_name = (archives_base_name
-      + (('-' + flavor) if flavor else '') + '-release')
-  signed_apk_name = config.get('signed-apk-name', apk_base_name + '.apk')
+      + (('-' + app.flavor) if app.flavor else '') + '-release')
+  signed_apk_name = (
+      app.signed_apk_name
+      if app.signed_apk_name
+      else apk_base_name + '.apk')
   unsigned_apk_name = apk_base_name + '-unsigned.apk'
 
-  build_dir = config.get('build_dir', 'build')
-  build_output_apks = os.path.join(app_module, build_dir, 'outputs', 'apk')
-  if flavor:
-    build_output_apks = os.path.join(build_output_apks, flavor, 'release')
+  build_dir = app.build_dir
+  build_output_apks = os.path.join(app.module, build_dir, 'outputs', 'apk')
+  if app.flavor:
+    build_output_apks = os.path.join(build_output_apks, app.flavor, 'release')
   else:
     build_output_apks = os.path.join(build_output_apks, 'release')
 
@@ -526,7 +695,7 @@
   assert 'r8' in shrinker
   assert apk_dest.endswith('.apk')
 
-  print('Rebuilding {} with {}'.format(app, shrinker))
+  print('Rebuilding {} with {}'.format(app.name, shrinker))
 
   # Compile given APK with shrinker to temporary zip file.
   android_jar = utils.get_android_jar(compile_sdk)
@@ -558,21 +727,20 @@
       apk, dex=zip_dest, resources='META-INF/services/*', out=apk_dest,
       quiet=options.quiet)
 
-def RunMonkey(app, config, options, apk_dest):
+def RunMonkey(app, options, apk_dest):
   if not WaitForEmulator():
     return False
 
-  UninstallApkOnEmulator(app, config, options)
+  UninstallApkOnEmulator(app, options)
   InstallApkOnEmulator(apk_dest, options)
 
-  app_id = config.get('app_id')
   number_of_events_to_generate = options.monkey_events
 
   # Intentionally using a constant seed such that the monkey generates the same
   # event sequence for each shrinker.
   random_seed = 42
 
-  cmd = ['adb', 'shell', 'monkey', '-p', app_id, '-s', str(random_seed),
+  cmd = ['adb', 'shell', 'monkey', '-p', app.id, '-s', str(random_seed),
       str(number_of_events_to_generate)]
 
   try:
@@ -582,7 +750,7 @@
   except subprocess.CalledProcessError as e:
     succeeded = False
 
-  UninstallApkOnEmulator(app, config, options)
+  UninstallApkOnEmulator(app, options)
 
   return succeeded
 
@@ -611,7 +779,7 @@
 
 
 def LogComparisonResultsForApp(app, result_per_shrinker, options):
-  print(app + ':')
+  print(app.name + ':')
 
   if result_per_shrinker.get('status', 'success') != 'success':
     error_message = result_per_shrinker.get('error_message')
@@ -686,7 +854,7 @@
   result = optparse.OptionParser()
   result.add_option('--app',
                     help='What app to run on',
-                    choices=APPS.keys())
+                    choices=GetAllAppNames())
   result.add_option('--download-only', '--download_only',
                     help='Whether to download apps without any compilation',
                     default=False,
@@ -743,6 +911,11 @@
   result.add_option('--version',
                     help='The version of R8 to use (e.g., 1.4.51)')
   (options, args) = result.parse_args(argv)
+  if options.app:
+    options.apps = [GetAppWithName(options.app)]
+    del options.app
+  else:
+    options.apps = GetAllApps()
   if options.shrinker:
     for shrinker in options.shrinker:
       assert shrinker in SHRINKERS
@@ -761,13 +934,13 @@
       options.shrinker.remove('r8-nolib-full')
   return (options, args)
 
-def download_apps(quiet):
-  # Download apps and place in build
+def clone_repositories(quiet):
+  # Clone repositories into WORKING_DIR.
   with utils.ChangedWorkingDirectory(WORKING_DIR):
-    for app, config in APPS.iteritems():
-      app_dir = os.path.join(WORKING_DIR, app)
-      if not os.path.exists(app_dir):
-        GitClone(config['git_repo'], config['revision'], app_dir, quiet)
+    for name, repo in APP_REPOSITORIES.iteritems():
+      repo_dir = os.path.join(WORKING_DIR, name)
+      if not os.path.exists(repo_dir):
+        GitClone(repo, repo_dir, quiet)
 
 
 def main(argv):
@@ -786,7 +959,7 @@
     os.makedirs(WORKING_DIR)
 
   if options.download_only:
-    download_apps(options.quiet)
+    clone_repositories(options.quiet)
     return
 
   with utils.TempDir() as temp_dir:
@@ -814,14 +987,11 @@
 
     result_per_shrinker_per_app = {}
 
-    if options.app:
-      result_per_shrinker_per_app[options.app] = GetResultsForApp(
-          options.app, APPS.get(options.app), options, temp_dir)
-    else:
-      for app, config in sorted(APPS.iteritems(), key=lambda s: s[0].lower()):
-        if not config.get('skip', False):
-          result_per_shrinker_per_app[app] = GetResultsForApp(
-              app, config, options, temp_dir)
+    for (app, repo) in options.apps:
+      if app.skip:
+        continue
+      result_per_shrinker_per_app[app.name] = \
+          GetResultsForApp(app, repo, options, temp_dir)
 
     LogResultsForApps(result_per_shrinker_per_app, options)