blob: 244c093c4f86a93131bacbd675950522d0ab10b2 [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2018, the R8 project authors. Please see the AUTHORS file
# 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_utils
import os
import optparse
import subprocess
import sys
import time
import utils
import zipfile
import as_utils
SHRINKERS = ['r8', 'r8full', 'r8-minified', 'r8full-minified', 'proguard']
WORKING_DIR = utils.BUILD
if 'R8_BENCHMARK_DIR' in os.environ and os.path.isdir(os.environ['R8_BENCHMARK_DIR']):
WORKING_DIR = os.environ['R8_BENCHMARK_DIR']
APPS = {
# 'app-name': {
# 'git_repo': ...
# '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/1hakr/AnExplorer',
'flavor': 'googleMobilePro',
'signed-apk-name': 'AnExplorer-googleMobileProRelease-4.0.3.apk',
},
'AntennaPod': {
'app_id': 'de.danoeh.antennapod',
'git_repo': 'https://github.com/AntennaPod/AntennaPod.git',
'flavor': 'play',
},
'apps-android-wikipedia': {
'app_id': 'org.wikipedia',
'git_repo': 'https://github.com/wikimedia/apps-android-wikipedia',
'flavor': 'prod',
'signed-apk-name': 'app-prod-universal-release.apk'
},
'friendlyeats-android': {
'app_id': 'com.google.firebase.example.fireeats',
'git_repo': 'https://github.com/firebase/friendlyeats-android.git'
},
'KISS': {
'app_id': 'fr.neamar.kiss',
'git_repo': 'https://github.com/Neamar/KISS',
},
'materialistic': {
'app_id': 'io.github.hidroh.materialistic',
'git_repo': 'https://github.com/hidroh/materialistic',
},
'Minimal-Todo': {
'app_id': 'com.avjindersinghsekhon.minimaltodo',
'git_repo': 'https://github.com/avjinder/Minimal-Todo',
},
'NewPipe': {
'app_id': 'org.schabi.newpipe',
'git_repo': 'https://github.com/TeamNewPipe/NewPipe',
},
'Simple-Calendar': {
'app_id': 'com.simplemobiletools.calendar.pro',
'git_repo': 'https://github.com/SimpleMobileTools/Simple-Calendar',
'signed-apk-name': 'calendar-release.apk'
},
'tachiyomi': {
'app_id': 'eu.kanade.tachiyomi',
'git_repo': 'https://github.com/sgjesse/tachiyomi.git',
'flavor': 'standard',
'releaseTarget': 'app:assembleRelease',
},
# This does not build yet.
'muzei': {
'git_repo': 'https://github.com/sgjesse/muzei.git',
'app_module': 'main',
'archives_base_name': 'muzei',
'skip': True,
},
}
# Common environment setup.
user_home = os.path.expanduser('~')
android_home = os.path.join(user_home, 'Android', 'Sdk')
android_build_tools_version = '28.0.3'
android_build_tools = os.path.join(
android_home, 'build-tools', android_build_tools_version)
# TODO(christofferqa): Do not rely on 'emulator-5554' name
emulator_id = 'emulator-5554'
def ComputeSizeOfDexFilesInApk(apk):
dex_size = 0
z = zipfile.ZipFile(apk, 'r')
for filename in z.namelist():
if filename.endswith('.dex'):
dex_size += z.getinfo(filename).file_size
return dex_size
def IsBuiltWithR8(apk):
script = os.path.join(utils.TOOLS_DIR, 'extractmarker.py')
return '~~R8' in subprocess.check_output(['python', script, apk]).strip()
def IsMinifiedR8(shrinker):
return shrinker == 'r8-minified' or shrinker == 'r8full-minified'
def IsTrackedByGit(file):
return subprocess.check_output(['git', 'ls-files', file]).strip() != ''
def GitClone(git_url):
return subprocess.check_output(['git', 'clone', git_url]).strip()
def GitPull():
# Use --no-edit to accept the auto-generated merge message, if any.
return subprocess.call(['git', 'pull', '--no-edit']) == 0
def GitCheckout(file):
return subprocess.check_output(['git', 'checkout', file]).strip()
def MoveApkToDest(apk, apk_dest):
print('Moving `{}` to `{}`'.format(apk, apk_dest))
assert os.path.isfile(apk)
if os.path.isfile(apk_dest):
os.remove(apk_dest)
os.rename(apk, apk_dest)
def InstallApkOnEmulator(apk_dest):
subprocess.check_call(
['adb', '-s', emulator_id, 'install', '-r', '-d', apk_dest])
def WaitForEmulator():
stdout = subprocess.check_output(['adb', 'devices'])
if '{}\tdevice'.format(emulator_id) in stdout:
return
print('Emulator \'{}\' not connected; waiting for connection'.format(
emulator_id))
time_waited = 0
while True:
time.sleep(10)
time_waited += 10
stdout = subprocess.check_output(['adb', 'devices'])
if '{}\tdevice'.format(emulator_id) not in stdout:
print('... still waiting for connection')
if time_waited >= 5 * 60:
raise Exception('No emulator connected for 5 minutes')
else:
return
def GetResultsForApp(app, config, options):
git_repo = config['git_repo']
# Checkout and build in the build directory.
checkout_dir = os.path.join(WORKING_DIR, app)
result = {}
if not os.path.exists(checkout_dir):
with utils.ChangedWorkingDirectory(WORKING_DIR):
GitClone(git_repo)
elif options.pull:
with utils.ChangedWorkingDirectory(checkout_dir):
# Checkout build.gradle to avoid merge conflicts.
if IsTrackedByGit('build.gradle'):
GitCheckout('build.gradle')
if not GitPull():
result['status'] = 'failed'
result['error_message'] = 'Unable to pull from remote'
return result
result['status'] = 'success'
result_per_shrinker = BuildAppWithSelectedShrinkers(
app, config, options, checkout_dir)
for shrinker, shrinker_result in result_per_shrinker.iteritems():
result[shrinker] = shrinker_result
return result
def BuildAppWithSelectedShrinkers(app, config, options, checkout_dir):
result_per_shrinker = {}
with utils.ChangedWorkingDirectory(checkout_dir):
for shrinker in SHRINKERS:
if options.shrinker and shrinker not in options.shrinker:
continue
apk_dest = None
result = {}
try:
apk_dest = BuildAppWithShrinker(
app, config, shrinker, checkout_dir, options)
dex_size = ComputeSizeOfDexFilesInApk(apk_dest)
result['apk_dest'] = apk_dest,
result['build_status'] = 'success'
result['dex_size'] = dex_size
except:
warn('Failed to build {} with {}'.format(app, shrinker))
result['build_status'] = 'failed'
if options.monkey:
if result.get('build_status') == 'success':
result['monkey_status'] = 'success' if RunMonkey(
app, config, apk_dest) else 'failed'
result_per_shrinker[shrinker] = result
if IsTrackedByGit('gradle.properties'):
GitCheckout('gradle.properties')
return result_per_shrinker
def BuildAppWithShrinker(app, config, shrinker, checkout_dir, options):
print('Building {} with {}'.format(app, shrinker))
if options.disable_tot:
as_utils.remove_r8_dependency(checkout_dir)
else:
as_utils.add_r8_dependency(checkout_dir, IsMinifiedR8(shrinker))
app_module = config.get('app_module', 'app')
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
# select shrinker.
if IsTrackedByGit('gradle.properties'):
GitCheckout('gradle.properties')
with open("gradle.properties", "a") as gradle_properties:
if 'r8' in shrinker:
gradle_properties.write('\nandroid.enableR8=true\n')
if shrinker == 'r8full' or shrinker == 'r8full-minified':
gradle_properties.write('android.enableR8.fullMode=true\n')
else:
assert shrinker == 'proguard'
gradle_properties.write('\nandroid.enableR8=false\n')
out = os.path.join(checkout_dir, 'out', shrinker)
if not os.path.exists(out):
os.makedirs(out)
env = os.environ.copy()
env['ANDROID_HOME'] = android_home
env['JAVA_OPTS'] = '-ea'
releaseTarget = config.get('releaseTarget')
if not releaseTarget:
releaseTarget = app_module + ':' + 'assemble' + (
flavor.capitalize() if flavor else '') + 'Release'
cmd = ['./gradlew', '--no-daemon', 'clean', releaseTarget, '--stacktrace']
utils.PrintCmd(cmd)
subprocess.check_call(cmd, env=env)
apk_base_name = (archives_base_name
+ (('-' + flavor) if flavor else '') + '-release')
signed_apk_name = config.get('signed-apk-name', 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')
else:
build_output_apks = os.path.join(build_output_apks, 'release')
signed_apk = os.path.join(build_output_apks, signed_apk_name)
unsigned_apk = os.path.join(build_output_apks, unsigned_apk_name)
if options.sign_apks and not os.path.isfile(signed_apk):
assert os.path.isfile(unsigned_apk)
if options.sign_apks:
keystore = 'app.keystore'
keystore_password = 'android'
apk_utils.sign_with_apksigner(
android_build_tools,
unsigned_apk,
signed_apk,
keystore,
keystore_password)
if os.path.isfile(signed_apk):
apk_dest = os.path.join(out, signed_apk_name)
MoveApkToDest(signed_apk, apk_dest)
else:
apk_dest = os.path.join(out, unsigned_apk_name)
MoveApkToDest(unsigned_apk, apk_dest)
assert IsBuiltWithR8(apk_dest) == ('r8' in shrinker), (
'Unexpected marker in generated APK for {}'.format(shrinker))
return apk_dest
def RunMonkey(app, config, apk_dest):
WaitForEmulator()
InstallApkOnEmulator(apk_dest)
app_id = config.get('app_id')
number_of_events_to_generate = 50
stdout = subprocess.check_output(['adb', 'shell', 'monkey', '-p', app_id,
str(number_of_events_to_generate)])
return 'Events injected: {}'.format(number_of_events_to_generate) in stdout
def LogResults(result_per_shrinker_per_app, options):
for app, result_per_shrinker in result_per_shrinker_per_app.iteritems():
print(app + ':')
if result_per_shrinker.get('status') != 'success':
error_message = result_per_shrinker.get('error_message')
print(' skipped ({})'.format(error_message))
continue
baseline = float(
result_per_shrinker.get('proguard', {}).get('dex_size', -1))
for shrinker in SHRINKERS:
if shrinker not in result_per_shrinker:
continue
result = result_per_shrinker.get(shrinker)
build_status = result.get('build_status')
if build_status != 'success':
warn(' {}: {}'.format(shrinker, build_status))
else:
print(' {}:'.format(shrinker))
dex_size = result.get('dex_size')
if dex_size != baseline and baseline >= 0:
if dex_size < baseline:
success(' dex size: {} ({}, -{}%)'.format(
dex_size, dex_size - baseline,
round((1.0 - dex_size / baseline) * 100), 1))
elif dex_size >= baseline:
warn(' dex size: {} ({}, +{}%)'.format(
dex_size, dex_size - baseline,
round((baseline - dex_size) / dex_size * 100, 1)))
else:
print(' dex size: {}'.format(dex_size))
if options.monkey:
monkey_status = result.get('monkey_status')
if monkey_status != 'success':
warn(' monkey: {}'.format(monkey_status))
else:
success(' monkey: {}'.format(monkey_status))
def ParseOptions(argv):
result = optparse.OptionParser()
result.add_option('--app',
help='What app to run on',
choices=APPS.keys())
result.add_option('--monkey',
help='Whether to install and run app(s) with monkey',
default=False,
action='store_true')
result.add_option('--pull',
help='Whether to pull the latest version of each app',
default=False,
action='store_true')
result.add_option('--sign_apks',
help='Whether the APKs should be signed',
default=False,
action='store_true')
result.add_option('--shrinker',
help='The shrinkers to use (by default, all are run)',
action='append')
result.add_option('--disable_tot',
help='Whether to disable the use of the ToT version of R8',
default=False,
action='store_true')
(options, args) = result.parse_args(argv)
if options.shrinker:
for shrinker in options.shrinker:
assert shrinker in SHRINKERS
return (options, args)
def main(argv):
global SHRINKERS
(options, args) = ParseOptions(argv)
assert options.disable_tot or os.path.isfile(utils.R8_JAR), (
'Cannot build from ToT without r8.jar')
assert options.disable_tot or os.path.isfile(utils.R8LIB_JAR), (
'Cannot build from ToT without r8lib.jar')
if options.disable_tot:
# Cannot run r8 lib without adding r8lib.jar as an dependency
SHRINKERS = [
shrinker for shrinker in SHRINKERS
if 'minified' not in shrinker]
result_per_shrinker_per_app = {}
if options.app:
result_per_shrinker_per_app[options.app] = GetResultsForApp(
options.app, APPS.get(options.app), options)
else:
for app, config in APPS.iteritems():
if not config.get('skip', False):
result_per_shrinker_per_app[app] = GetResultsForApp(
app, config, options)
LogResults(result_per_shrinker_per_app, options)
def success(message):
CGREEN = '\033[32m'
CEND = '\033[0m'
print(CGREEN + message + CEND)
def warn(message):
CRED = '\033[91m'
CEND = '\033[0m'
print(CRED + message + CEND)
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))