#
# All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
# its licensors.
#
# For complete copyright and license terms please see the LICENSE at the root of this
# distribution (the "License"). All use of this software is governed by the License,
# or, if provided, by the license below or the license accompanying this file. Do not
# remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#
# Android builder
"""
Usage (in wscript):
def options(opt):
opt.load('android')
def configure(conf):
conf.load('android')
"""
# System Imports
import imghdr
import os
import shutil
import stat
import string
import sys
import time
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from datetime import datetime
from subprocess import call, check_output, STDOUT
from types import MethodType
# waflib imports
from waflib import Context, TaskGen, Build, Utils, Node, Logs, Options, Errors
from waflib.Build import POST_LAZY, POST_AT_ONCE
from waflib.Configure import conf, conf_event, ConfigurationContext
from waflib.Task import Task, ASK_LATER, RUN_ME, SKIP_ME
from waflib.TaskGen import feature, before, before_method, after_method, taskgen_method
from waflib.Tools import ccroot
ccroot.USELIB_VARS['android'] = set([ 'AAPT', 'AAPT_RESOURCES', 'AAPT_INCLUDES', 'AAPT_PACKAGE_FLAGS' ])
# lmbrwaflib imports
from lmbrwaflib import packaging
from lmbrwaflib import lumberyard
from lmbrwaflib.cry_utils import append_to_unique_list, get_command_line_limit
from lmbrwaflib.settings_manager import LUMBERYARD_SETTINGS
from lmbrwaflib.utils import junction_directory, remove_junction, write_auto_gen_header
################################################################
# Defaults #
BUILDER_DIR = 'Code/Tools/Android/ProjectBuilder'
BUILDER_FILES = 'android_builder.json'
ANDROID_LIBRARY_FILES = 'android_libraries.json'
RESOLUTION_MESSAGE = 'Please re-run Setup Assistant with "Compile For Android" enabled and run the configure command again.'
RESOLUTION_SETTINGS = ( 'mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi' )
DEFAULT_CONFIG_CHANGES = [
'keyboard',
'keyboardHidden',
'orientation',
'screenSize',
'smallestScreenSize',
'screenLayout',
'uiMode',
]
ORIENTATION_LANDSCAPE = 1 << 0
ORIENTATION_PORTRAIT = 1 << 1
ORIENTATION_ALL = (ORIENTATION_LANDSCAPE | ORIENTATION_PORTRAIT)
ORIENTATION_FLAG_TO_KEY_MAP = {
ORIENTATION_LANDSCAPE : 'land',
ORIENTATION_PORTRAIT : 'port',
}
LATEST_KEYWORD = 'latest'
ANDROID_CACHE_FOLDER = 'AndroidCache'
# these are the default names for application icons and splash images
APP_ICON_NAME = 'app_icon.png'
APP_SPLASH_NAME = 'app_splash.png'
# master list of all supported APIs
SUPPORTED_APIS = [
'android-19',
'android-21',
'android-22',
'android-23',
'android-24',
'android-25',
'android-26',
'android-27',
'android-28',
'android-29',
]
MIN_NDK_REV = 15
MIN_ARMv8_API = 'android-21'
SUPPORTED_NDK_PLATFORMS = sorted(SUPPORTED_APIS)
# changes to the app submission process requires apps to target new APIs for the SDK (Java) version
MIN_SDK_VERSION = 'android-28'
SUPPORTED_SDK_VERSIONS = sorted([ api for api in SUPPORTED_APIS if api >= MIN_SDK_VERSION ])
# keep the minimum build tools version in sync with the min supported SDK version
MIN_BUILD_TOOLS_VERSION = '{}.0.0'.format(MIN_SDK_VERSION.split('-')[1])
# known build tools versions with stability issues
UNSUPPORTED_BUILD_TOOLS_VERSIONS = {
'win32' : [
],
'darwin' : [
]
}
# build tools versions marked as obsolete by the Android SDK manager
OBSOLETE_BUILD_TOOLS_VERSIONS = [
]
# 'defines' for the different asset deployment modes
class Enum(tuple):
__getattr__ = tuple.index
ASSET_MODE = Enum([
'configuration_default', # Uses the current build configuration to determine how to package the assets e.g. debug/profile = loose_files, release/performance = project_settings
'loose_files', # no additional processing will be done on the compiled assets
'loose_paks', # pak files will be generated from the compiled assets
'apk_files', # the compiled assets will be packaged inside the APK
'apk_paks', # pak files will be generated and packaged inside the APK
'project_settings' # uses the use_[main|patch]_obb settings in project.json to determine if paks in the APK or OBBs will be used
])
APK_WITH_ASSETS_SUFFIX = '_w_assets'
# root types
ACCESS_NORMAL = 0 # the device is not rooted, we do not have access to any elevated permissions
ACCESS_ROOT_ADBD = 1 # the device is rooted, we have elevated permissions at the adb level
ACCESS_SHELL_SU = 2 # the device is rooted, we only have elevated permissions using 'adb shell su -c'
# The default permissions for installed libraries on device.
LIB_FILE_PERMISSIONS = '755'
# The default owner:group for installed libraries on device.
LIB_OWNER_GROUP = 'system:system'
ADB_QUOTED_PATH = None
# #
################################################################
@contextmanager
def push_dir(directory):
"""
Temporarily changes the current working directory. By decorating it with the contexmanager, makes this function only
usable in "with" statements, otherwise its a no-op. When the "with" statement is executed, this function will run
till the yield, then run what's inside the "with" statement and finally run what's after the yield.
"""
previous_dir = os.getcwd()
os.chdir(directory)
yield
os.chdir(previous_dir)
################################################################
@feature('cshlib', 'cxxshlib')
@after_method('apply_link')
def apply_so_name(self):
"""
Adds the linker flag to set the DT_SONAME in ELF shared objects. The
name used here will be used instead of the file name when the dynamic
linker attempts to load the shared object
"""
if 'android' in self.bld.env['PLATFORM'] and self.env.SONAME_ST:
flag = self.env.SONAME_ST % self.link_task.outputs[0]
self.env.append_value('LINKFLAGS', flag.split())
################################################################
@conf
def get_android_api_lib_list(ctx):
"""
Gets a list of android apis that pre-built libs could be built against based
on the current build target e.g. NDK_PLATFORM
"""
ndk_platform = ctx.get_android_ndk_platform()
try:
index = SUPPORTED_NDK_PLATFORMS.index(ndk_platform)
except:
ctx.fatal('[ERROR] Unsupported Android NDK platform version %s', ndk_platform)
else:
# we can only use libs built with api levels lower or equal to the one being used
return SUPPORTED_NDK_PLATFORMS[:index + 1] # end index is exclusive, so we add 1
################################################################
@conf
def is_android_armv8_api_valid(ctx):
"""
Checks to make sure desired API level meets the min spec for ARMv8 targets
"""
ndk_platform = ctx.get_android_ndk_platform()
return (ndk_platform >= MIN_ARMv8_API)
################################################################
def remove_file_and_empty_directory(directory, file_name):
"""
Helper function for deleting a file and directory, if empty
"""
file_path = os.path.join(directory, file_name)
# first delete the file, if it exists
if os.path.exists(file_path):
os.remove(file_path)
# then remove the directory, if it exists and is empty
if os.path.exists(directory) and not os.listdir(directory):
os.rmdir(directory)
################################################################
def remove_readonly(func, path, _):
'''Clear the readonly bit and reattempt the removal'''
os.chmod(path, stat.S_IWRITE)
func(path)
################################################################
def construct_source_path(conf, project, source_path):
"""
Helper to construct the source path to an asset override such as
application icons or splash screen images
"""
if os.path.isabs(source_path):
path_node = conf.root.make_node(source_path)
else:
path_node = conf.root.make_node([ Context.launch_dir, conf.game_code_folder(project), 'Resources', source_path ])
return path_node.abspath()
################################################################
def clear_splash_assets(project_node, path_prefix):
target_directory = project_node.make_node(path_prefix)
remove_file_and_empty_directory(target_directory.abspath(), APP_SPLASH_NAME)
for resolution in RESOLUTION_SETTINGS:
# The xxxhdpi resolution is only for application icons, its overkill to include them for drawables... for now
if resolution == 'xxxhdpi':
continue
target_directory = project_node.make_node(path_prefix + '-' + resolution)
remove_file_and_empty_directory(target_directory.abspath(), APP_SPLASH_NAME)
################################################################
def options(opt):
group = opt.add_option_group('android-specific config')
group.add_option('--deploy-android-asset-mode', dest = 'deploy_android_asset_mode', action = 'store', default = None, help = 'Deprecated: this option has been renamed to --android-asset-mode')
group.add_option('--android-sdk-version-override', dest = 'android_sdk_version_override', action = 'store', default = None, help = 'Override the Android SDK version used in the Java compilation. Only works during configure.')
group.add_option('--android-ndk-platform-override', dest = 'android_ndk_platform_override', action = 'store', default = None, help = 'Override the Android NDK platform version used in the native compilation. Only works during configure.')
group.add_option('--dev-store-pass', dest = 'dev_store_pass', action = 'store', default = 'Lumberyard', help = 'The store password for the development keystore')
group.add_option('--dev-key-pass', dest = 'dev_key_pass', action = 'store', default = 'Lumberyard', help = 'The key password for the development keystore')
group.add_option('--distro-store-pass', dest = 'distro_store_pass', action = 'store', default = '', help = 'The store password for the distribution keystore')
group.add_option('--distro-key-pass', dest = 'distro_key_pass', action = 'store', default = '', help = 'The key password for the distribution keystore')
group.add_option('--from-editor-deploy', dest = 'from_editor_deploy', action = 'store_true', default = False, help = 'Signals that the build is coming from the editor deployment tool')
group.add_option('--deploy-android-attempt-libs-only', dest = 'deploy_android_attempt_libs_only', action = 'store_true', default = False,
help = 'Will only push the changed native libraries. If "deploy_android_executable" is enabled, it will take precedent if modified. Option ignored if "deploy_android_clean_device" is enabled. This feature is only available for "unlocked" devices.')
################################################################
def configure(conf):
def _lst_to_str(lst):
return ', '.join(lst)
Logs.info('Validating Android SDK/NDK installation...')
env = conf.env
# validate the stored sdk and ndk paths from SetupAssistant
sdk_root = conf.get_env_file_var('LY_ANDROID_SDK', required = True)
ndk_root = conf.get_env_file_var('LY_ANDROID_NDK', required = True)
if not (sdk_root and ndk_root):
missing_paths = []
missing_paths += ['Android SDK'] if not sdk_root else []
missing_paths += ['Android NDK'] if not ndk_root else []
conf.fatal('[ERROR] Missing paths from Setup Assistant detected for: {}. {}'.format(_lst_to_str(missing_paths), RESOLUTION_MESSAGE))
# initial check to verify the SDK isn't missing components
missing_components = []
core_components_map = {
'build-tools': 'Android SDK Build-Tools',
'platforms': 'Android SDK Platforms ({})'.format(_lst_to_str(SUPPORTED_SDK_VERSIONS)),
'platform-tools': 'Android SDK Platform-Tools',
'tools': 'Android SDK Tools',
}
for folder, component in core_components_map.items():
component_path = os.path.join(sdk_root, folder)
if not os.path.exists(component_path) or not os.listdir(component_path):
missing_components.append(component)
google_extras = []
google_extras_dir = os.path.join(sdk_root, 'extras', 'google')
if os.path.exists(google_extras_dir):
google_extras = os.listdir(google_extras_dir)
if not any([lib_folder for lib_folder in ('market_apk_expansion', 'play_apk_expansion') if lib_folder in google_extras]):
missing_components.append('Google Play APK Expansion Library')
if not any([lib_folder for lib_folder in ('market_licensing', 'play_licensing') if lib_folder in google_extras]):
missing_components.append('Google Play Licensing Library')
if missing_components:
conf.fatal('[ERROR] The Android SDK installed to {} appears to be incomplete. '
'Please use the Android SDK Manager to ensure the following components are installed and run the configure command again.\n'
'\t-> Required SDK components: {}'.format(sdk_root, _lst_to_str(missing_components)))
env['ANDROID_SDK_HOME'] = sdk_root
env['ANDROID_NDK_HOME'] = ndk_root
# get the revision of the NDK
ndk_rev = None
properties_file_path = os.path.join(ndk_root, 'source.properties')
if os.path.exists(properties_file_path):
with open(properties_file_path) as ndk_props_file:
for line in ndk_props_file.readlines():
tokens = line.split('=')
trimed_tokens = [token.strip() for token in tokens]
if 'Pkg.Revision' in trimed_tokens:
ndk_rev = trimed_tokens[1]
if not ndk_rev:
conf.fatal('[ERROR] Unable to validate Android NDK version in path {}. Please confirm the path to the Android NDK '
'in Setup Assistant is pointing NDK r{} or higher and run the configure command again'.format(ndk_root, MIN_NDK_REV))
ndk_rev_tokens = ndk_rev.split('.')
ndk_rev_major = int(ndk_rev_tokens[0])
ndk_rev_minor = int(ndk_rev_tokens[1])
ndk_rev_suffix = '' if not ndk_rev_minor else chr(ord('a') + ndk_rev_minor)
if ndk_rev_major < MIN_NDK_REV:
conf.fatal('[ERROR] The version of the Android NDK in use - r{}{} - is below the minimum supported version - r{}. '
'Please select a newer version of the Android NDK in Setup Assistant and run the configure command again.'.format(ndk_rev_major, ndk_rev_suffix, MIN_NDK_REV))
env['ANDROID_NDK_REV_FULL'] = ndk_rev
env['ANDROID_NDK_REV_MAJOR'] = ndk_rev_major
env['ANDROID_NDK_REV_MINOR'] = ndk_rev_minor
env['ANDROID_IS_NDK_19_PLUS'] = ndk_rev_major >= 19
# validate the desired SDK (Java) version
installed_sdk_versions = os.listdir(os.path.join(sdk_root, 'platforms'))
valid_sdk_versions = sorted([ platform for platform in installed_sdk_versions if platform in SUPPORTED_SDK_VERSIONS ])
Logs.debug('android: Valid SDK versions installed are: {}'.format(valid_sdk_versions))
if not valid_sdk_versions:
conf.fatal('[ERROR] Unable to detect a valid Android SDK version installed in path {}. '
'Please use the Android SDK Manager to download an appropriate SDK version and run the configure command again.\n'
'\t-> Supported versions are: {}'.format(sdk_root, _lst_to_str(SUPPORTED_SDK_VERSIONS)))
sdk_version = Options.options.android_sdk_version_override or conf.get_android_sdk_version()
if not sdk_version:
conf.fatal('[ERROR] No Android SDK version specified! Please confirm the "SDK_VERSION" entry is set in '
'_WAF_/android/android_setting.json and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(_lst_to_str(valid_sdk_versions)))
elif sdk_version.lower() == LATEST_KEYWORD:
sdk_version = valid_sdk_versions[-1]
Logs.debug('android: Using the latest installed Android SDK version {}'.format(sdk_version))
elif sdk_version in SUPPORTED_APIS:
if sdk_version not in SUPPORTED_SDK_VERSIONS:
conf.fatal('[ERROR] Android SDK version - {} - is below the minimum target SDK version required for app '
'submission. Please change "SDK_VERSION" in _WAF_/android/android_setting.json to a supported '
'API and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(sdk_version, _lst_to_str(valid_sdk_versions)))
elif sdk_version not in valid_sdk_versions:
conf.fatal('[ERROR] Failed to find Android SDK version - {} - installed in path {}. Please use the Android '
'SDK Manager to download the desired SDK version or change "SDK_VERSION" in _WAF_/android/android_settings.json '
'to a supported version installed and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(sdk_version, sdk_root, _lst_to_str(valid_sdk_versions)))
else:
conf.fatal('[ERROR] Android SDK version - {} - is unsupported. Please change "SDK_VERSION" in '
'_WAF_/android/android_setting.json to a supported API and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(sdk_version, _lst_to_str(valid_sdk_versions)))
env['ANDROID_SDK_VERSION'] = sdk_version
env['ANDROID_SDK_VERSION_NUMBER'] = int(sdk_version.split('-')[1])
# validate the desired NDK platform version
if ndk_rev_major >= 19:
platforms_file = conf.root.make_node([ ndk_root, 'meta', 'platforms.json'])
json_data = conf.parse_json_file(platforms_file)
if not json_data:
conf.fatal('[ERROR] Failed to find platforms file in path {}.'.format(platforms_file.abspath()))
platform_aliases = json_data['aliases']
installed_ndk_platforms = set(['android-{}'.format(platform_number) for platform_number in list(platform_aliases.values())])
else:
installed_ndk_platforms = os.listdir(os.path.join(ndk_root, 'platforms'))
valid_ndk_platforms = sorted([ platform for platform in installed_ndk_platforms if platform in SUPPORTED_NDK_PLATFORMS ])
Logs.debug('android: Valid NDK platforms for revision {} are: {}'.format(ndk_rev, valid_ndk_platforms))
ndk_platform = Options.options.android_ndk_platform_override or conf.get_android_ndk_platform()
if not ndk_platform:
conf.fatal('[ERROR] No Android NDK platform specified! Please confirm the "NDK_PLATFORM" entry is set in '
'_WAF_/android/android_setting.json and run the configure command again.\n'
'\t-> Supported platforms for NDK {} are: {}'.format(ndk_rev, _lst_to_str(valid_ndk_platforms)))
elif ndk_platform in SUPPORTED_NDK_PLATFORMS:
# search for the closest, lower match in the event the specified native platform API is in the
# supported list but the version of the NDK used doesn't have pre-built libraries for it
if ndk_platform not in valid_ndk_platforms:
best_match = ndk_platform
for platform in valid_ndk_platforms:
if platform <= ndk_platform:
best_match = platform
Logs.warn('[WARN] The Android NDK in use - {} - does not directly support the desired platform version - {}. '
'Falling back to closest match found: {}.\nConsider explicitly setting the "NDK_PLATFORM" in '
'_WAF_/android/android_setting.json to a valid supported version part of the NDK package.\n'
'\t-> Supported platforms are: {}'.format(ndk_rev, ndk_platform, best_match, _lst_to_str(valid_ndk_platforms)))
ndk_platform = best_match
else:
conf.fatal('[ERROR] Android NDK platform - {} - is unsupported. Please change "NDK_PLATFORM" in '
'_WAF_/android/android_setting.json to a supported API and run the configure command again.\n'
'\t-> Supported platforms for NDK {} are: {}'.format(ndk_platform, ndk_rev, _lst_to_str(valid_ndk_platforms)))
env['ANDROID_NDK_PLATFORM'] = ndk_platform
env['ANDROID_NDK_PLATFORM_NUMBER'] = int(ndk_platform.split('-')[1])
# final check is to make sure the ndk platform <= sdk version to ensure compatibility
if not (ndk_platform <= sdk_version):
conf.fatal('[ERROR] The Android API specified in "NDK_PLATFORM" - {} - is newer than the API specified '
'in "SDK_VERSION" - {}; this can lead to compatibility issues.\nPlease update your '
'_WAF_/android/android_settings.json to make sure "NDK_PLATFORM" <= "SDK_VERSION" and '
'run the configure command again.'.format(ndk_platform, sdk_version))
# validate the desired SDK build-tools version
build_tools_dir_contents = os.listdir(os.path.join(sdk_root, 'build-tools'))
installed_build_tools_versions = [ entry for entry in build_tools_dir_contents if entry.split('.')[0].isdigit() ]
host_unsupported_build_tools_versions = UNSUPPORTED_BUILD_TOOLS_VERSIONS.get(Utils.unversioned_sys_platform(), [])
unusable_build_tools_versions = host_unsupported_build_tools_versions + OBSOLETE_BUILD_TOOLS_VERSIONS
valid_build_tools_versions = sorted([ entry for entry in installed_build_tools_versions if entry >= MIN_BUILD_TOOLS_VERSION and entry not in unusable_build_tools_versions ])
Logs.debug('android: Valid build-tools versions installed are: {}'.format(valid_build_tools_versions))
if not valid_build_tools_versions:
additional_details = ''
if host_unsupported_build_tools_versions:
additional_details = 'Also note the following versions are unsupported:\n\t-> {}'.format(_lst_to_str(host_unsupported_build_tools_versions))
conf.fatal('[ERROR] Unable to detect a valid Android SDK build-tools version installed in path {}. '
'Please use the Android SDK Manager to download build-tools version {} or higher and run '
'the configure command again. {}'.format(sdk_root, MIN_BUILD_TOOLS_VERSION, additional_details))
build_tools_version = conf.get_android_build_tools_version()
if not build_tools_version:
conf.fatal('[ERROR] No Android build-tools version specified! Please confirm the "BUILD_TOOLS_VER" entry is '
'set in _WAF_/android/android_setting.json and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(_lst_to_str(valid_build_tools_versions)))
elif build_tools_version.lower() == LATEST_KEYWORD:
build_tools_version = valid_build_tools_versions[-1]
Logs.debug('android: Using the latest installed Android SDK build-tools version {}'.format(build_tools_version))
elif build_tools_version >= MIN_BUILD_TOOLS_VERSION:
if build_tools_version in OBSOLETE_BUILD_TOOLS_VERSIONS:
Logs.warn('[WARN] The Android SDK build-tools version - {} - has been marked as obsolete by Google. '
'Consider using a different version of the build tools by changing "BUILD_TOOLS_VER" in '
'_WAF_/android/android_settings.json to "latest" or to another valid installed version '
'and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(build_tools_version, _lst_to_str(valid_build_tools_versions)))
elif build_tools_version not in valid_build_tools_versions:
conf.fatal('[ERROR] Failed to find Android SDK build-tools version - {} - installed in path {}. '
'Please use the Android SDK Manager to download the appropriate build-tools version '
'or change "BUILD_TOOLS_VER" in _WAF_/android/android_settings.json to a supported '
'version installed and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(build_tools_version, sdk_root, _lst_to_str(valid_build_tools_versions)))
else:
conf.fatal('[ERROR] Android SDK build-tools version - {} - is unsupported. Please change "BUILD_TOOLS_VER" in'
'_WAF_/android/android_setting.json to {} or higher and run the configure command again.\n'
'\t-> Supported versions installed are: {}'.format(build_tools_version, MIN_BUILD_TOOLS_VERSION, _lst_to_str(valid_build_tools_versions)))
conf.env['ANDROID_BUILD_TOOLS_VER'] = build_tools_version
Logs.info('Finished validating Android SDK/NDK installation!')
################################################################
@conf
def get_android_cache_node(conf):
return conf.get_bintemp_folder_node().make_node(ANDROID_CACHE_FOLDER)
################################################################
@conf
def add_to_android_cache(conf, path_to_resource, arch_override = None):
"""
Adds resource files from outside the engine folder into a local cache directory so they can be used by WAF tasks.
Returns the path of the new resource file relative the cache root.
"""
cache_node = get_android_cache_node(conf)
cache_node.mkdir()
dest_node = cache_node
dest_subfolder = arch_override or conf.env['ANDROID_ARCH']
if dest_subfolder:
dest_node = cache_node.make_node(dest_subfolder)
dest_node.mkdir()
file_name = os.path.basename(path_to_resource)
files_node = dest_node.make_node(file_name)
if os.path.isfile(files_node.abspath()):
files_node.delete()
shutil.copy2(path_to_resource, files_node.abspath())
files_node.chmod(Utils.O755)
rel_path = files_node.path_from(cache_node)
Logs.debug('android: Adding resource - {} - to Android cache'.format(rel_path))
return rel_path
################################################################
def process_json(conf, json_data, curr_node, root_node, template, copied_files):
for elem in json_data:
if elem == 'NO_OP':
continue
if os.path.isabs(elem):
source_curr = conf.root.make_node(elem)
else:
source_curr = root_node.make_node(elem)
target_curr = curr_node.make_node(elem)
if isinstance(json_data, dict):
# resolve name overrides for the copy, if specified
if isinstance(json_data[elem], str) or isinstance(json_data[elem], str):
target_curr = curr_node.make_node(json_data[elem])
# otherwise continue processing the tree
else:
target_curr.mkdir()
process_json(conf, json_data[elem], target_curr, root_node, template, copied_files)
continue
# leaf handing
if imghdr.what(source_curr.abspath()) in ( 'rgb', 'gif', 'pbm', 'ppm', 'tiff', 'rast', 'xbm', 'jpeg', 'bmp', 'png' ):
shutil.copyfile(source_curr.abspath(), target_curr.abspath())
else:
transformed_text = string.Template(source_curr.read()).substitute(template)
target_curr.write(transformed_text)
target_curr.chmod(Utils.O755)
copied_files.append(target_curr.abspath())
################################################################
def copy_and_patch_android_libraries(conf, source_node, android_root):
"""
Copy the libraries that need to be patched and do the patching of the files.
"""
class _Library:
def __init__(self, name, path):
self.name = name
self.path = path
self.patch_files = []
def add_file_to_patch(self, file):
self.patch_files.append(file)
class _File:
def __init__(self, path):
self.path = path
self.changes = []
def add_change(self, change):
self.changes.append(change)
class _Change:
def __init__(self, line, old, new):
self.line = line
self.old = old
self.new = new
lib_src_file = source_node.make_node(ANDROID_LIBRARY_FILES)
json_data = conf.parse_json_file(lib_src_file)
if not json_data:
conf.fatal('[ERROR] Android library settings (%s) not found or invalid.' % ANDROID_LIBRARY_FILES)
return False
# Collect the libraries that need to be patched
libs_to_patch = []
for libName, value in json_data.items():
# The library is in different places depending on the revision, so we must check multiple paths.
srcDir = None
for path in value['srcDir']:
path = string.Template(path).substitute(conf.env)
if os.path.exists(path):
srcDir = path
break
if not srcDir:
conf.fatal('[ERROR] Failed to find library - %s - in path(s) [%s]. Please download the library from the Android SDK Manager and run the configure command again.'
% (libName, ", ".join(string.Template(path).substitute(conf.env) for path in value['srcDir'])))
return False
if 'patches' in value:
lib_to_patch = _Library(libName, srcDir)
for patch in value['patches']:
file_to_patch = _File(patch['path'])
for change in patch['changes']:
lineNum = change['line']
oldLines = change['old']
newLines = change['new']
for oldLine in oldLines[:-1]:
change = _Change(lineNum, oldLine, (newLines.pop() if newLines else None))
file_to_patch.add_change(change)
lineNum += 1
else:
change = _Change(lineNum, oldLines[-1], ('\n'.join(newLines) if newLines else None))
file_to_patch.add_change(change)
lib_to_patch.add_file_to_patch(file_to_patch)
libs_to_patch.append(lib_to_patch)
dest_root = os.path.join(Context.launch_dir, conf.get_android_patched_libraries_relative_path())
# Patch the libraries
for lib in libs_to_patch:
dest_path = os.path.join(dest_root, lib.name)
shutil.rmtree(dest_path, ignore_errors=True, onerror=remove_readonly)
shutil.copytree(lib.path, dest_path)
for file in lib.patch_files:
inputFilePath = os.path.join(lib.path, file.path)
outputFilePath = os.path.join(dest_path, file.path)
with open(inputFilePath) as inputFile:
lines = inputFile.readlines()
with open(outputFilePath, 'w') as outFile:
for replace in file.changes:
lines[replace.line] = str.replace(lines[replace.line], replace.old, (replace.new if replace.new else ""), 1)
outFile.write(''.join([line for line in lines if line]))
return True
################################################################
def process_multi_window_settings(conf, game_project, multi_window_settings, template):
def _is_number_option_valid(value, name):
if value:
if isinstance(value, int):
return True
else:
Logs.warn('[WARN] Invalid value for property "{}", expected whole number'.format(name))
return False
launch_in_fullscreen = False
# the Samsung DEX specific values can be added regardless of target API and multi-window support
samsung_dex_options = multi_window_settings.get('samsung_dex_options', None)
if samsung_dex_options:
launch_in_fullscreen = samsung_dex_options.get('launch_in_fullscreen', False)
# setting the launch window size in DEX mode since launching in fullscreen is strictly tied
# to multi-window being enabled
launch_width = samsung_dex_options.get('launch_width', None)
launch_height = samsung_dex_options.get('launch_height', None)
# both have to be specified otherwise they are ignored
if _is_number_option_valid(launch_width, 'launch_width') and _is_number_option_valid(launch_height, 'launch_height'):
template['SAMSUNG_DEX_LAUNCH_WIDTH'] = (''.format(launch_width))
template['SAMSUNG_DEX_LAUNCH_HEIGHT'] = (''.format(launch_height))
keep_alive = samsung_dex_options.get('keep_alive', None)
if keep_alive in ( True, False ):
template['SAMSUNG_DEX_KEEP_ALIVE'] = ''.format(str(keep_alive).lower())
sdk_version = conf.get_android_sdk_version()
multi_window_enabled = multi_window_settings.get('enabled', False)
# the option to change the display resolution was added in API 24 as well, these changes are sent as density changes
template['ANDROID_CONFIG_CHANGES'] = '|'.join(DEFAULT_CONFIG_CHANGES + [ 'density' ])
# if targeting above the min API level the default value for this attribute is true so we need to explicitly disable it
template['ANDROID_MULTI_WINDOW'] = 'android:resizeableActivity="{}"'.format(str(multi_window_enabled).lower())
if not multi_window_enabled:
return False
# remove the DEX launch window size if requested to launch in fullscreen mode
if launch_in_fullscreen:
template['SAMSUNG_DEX_LAUNCH_WIDTH'] = ''
template['SAMSUNG_DEX_LAUNCH_HEIGHT'] = ''
default_width = multi_window_settings.get('default_width', None)
default_height = multi_window_settings.get('default_height', None)
min_width = multi_window_settings.get('min_width', None)
min_height = multi_window_settings.get('min_height', None)
gravity = multi_window_settings.get('gravity', None)
layout = ''
if any([ default_width, default_height, min_width, min_height, gravity ]):
layout = ''
template['ANDROID_MULTI_WINDOW_PROPERTIES'] = layout
return True
@conf
def create_base_android_projects(conf):
"""
This function will generate the bare minimum android project
and include the new android launcher(s) in the build path.
So no Android Studio gradle files will be generated.
"""
launch_node = conf.get_launch_node()
engine_node = conf.get_engine_node()
android_root = launch_node.make_node(conf.get_android_project_relative_path())
android_root.mkdir()
source_node = engine_node.make_node(BUILDER_DIR)
builder_file_src = source_node.make_node(BUILDER_FILES)
builder_file_dest = conf.path.get_bld().make_node(BUILDER_DIR)
if not os.path.exists(builder_file_src.abspath()):
conf.fatal('[ERROR] Failed to find the Android project builder - %s - in path %s. Verify file exists and run the configure command again.' % (BUILDER_FILES, BUILDER_DIR))
return False
created_directories = []
for project in conf.get_enabled_game_project_list():
# make sure the project has android options specified
android_settings = conf.get_android_settings(project)
if not android_settings:
Logs.warn('[WARN] Android settings not found in %s/project.json, skipping.' % project)
continue
proj_root = android_root.make_node(conf.get_executable_name(project))
proj_root.mkdir()
created_directories.append(proj_root.path_from(android_root))
proj_src_path = os.path.join(proj_root.abspath(), 'src')
if os.path.exists(proj_src_path):
shutil.rmtree(proj_src_path, ignore_errors=True, onerror=remove_readonly)
# setup the macro replacement map for the builder files
activity_name = '%sActivity' % project
transformed_package = conf.get_android_package_name(project).replace('.', '/')
template = {
'ANDROID_PACKAGE' : conf.get_android_package_name(project),
'ANDROID_PACKAGE_PATH' : transformed_package,
'ANDROID_APP_NAME' : conf.get_launcher_product_name(project), # external facing name
'ANDROID_PROJECT_NAME' : project, # internal facing name
'ANDROID_PROJECT_ACTIVITY' : activity_name,
'ANDROID_LAUNCHER_NAME' : conf.get_executable_name(project), # first native library to load from java
'ANDROID_VERSION_NUMBER' : conf.get_android_version_number(project),
'ANDROID_VERSION_NAME' : conf.get_android_version_name(project),
'ANDROID_CONFIG_CHANGES' : '|'.join(DEFAULT_CONFIG_CHANGES),
'ANDROID_SCREEN_ORIENTATION' : conf.get_android_orientation(project),
'ANDROID_APP_PUBLIC_KEY' : conf.get_android_app_public_key(project),
'ANDROID_APP_OBFUSCATOR_SALT' : conf.get_android_app_obfuscator_salt(project),
'ANDROID_USE_MAIN_OBB' : conf.get_android_use_main_obb(project),
'ANDROID_USE_PATCH_OBB' : conf.get_android_use_patch_obb(project),
'ANDROID_ENABLE_KEEP_SCREEN_ON' : conf.get_android_enable_keep_screen_on(project),
'ANDROID_DISABLE_IMMERSIVE_MODE' : conf.get_android_disable_immersive_mode(project),
'ANDROID_MIN_SDK_VERSION' : conf.env['ANDROID_NDK_PLATFORM_NUMBER'],
'ANDROID_TARGET_SDK_VERSION' : conf.env['ANDROID_SDK_VERSION_NUMBER'],
'ANDROID_MULTI_WINDOW' : '',
'ANDROID_MULTI_WINDOW_PROPERTIES' : '',
'SAMSUNG_DEX_KEEP_ALIVE' : '',
'SAMSUNG_DEX_LAUNCH_WIDTH' : '',
'SAMSUNG_DEX_LAUNCH_HEIGHT' : '',
}
is_multi_window = False
multi_window_settings = android_settings.get('multi_window_options', None)
if multi_window_settings:
is_multi_window = process_multi_window_settings(conf, project, multi_window_settings, template)
# when multi-window support is enabled, the desired orientation is more of a suggestion
orientation = ORIENTATION_ALL
if not is_multi_window:
requested_orientation = conf.get_android_orientation(project)
if requested_orientation in ('landscape', 'reverseLandscape', 'sensorLandscape', 'userLandscape'):
orientation = ORIENTATION_LANDSCAPE
elif requested_orientation in ('portrait', 'reversePortrait', 'sensorPortrait', 'userPortrait'):
orientation = ORIENTATION_PORTRAIT
# update the builder file with the correct package name
transformed_node = builder_file_dest.find_or_declare('%s_builder.json' % project)
transformed_text = string.Template(builder_file_src.read()).substitute(template)
transformed_node.write(transformed_text)
# process the builder file and create project
copied_files = []
json_data = conf.parse_json_file(transformed_node)
process_json(conf, json_data, proj_root, source_node, template, copied_files)
# resolve the application icon overrides
resource_node = proj_root.make_node(['src', 'main', 'res'])
icon_overrides = conf.get_android_app_icons(project)
if icon_overrides:
mipmap_path_prefix = 'mipmap'
# if a default icon is specified, then copy it into the generic mipmap folder
default_icon = icon_overrides.get('default', None)
if default_icon is not None:
default_icon_source_node = construct_source_path(conf, project, default_icon)
default_icon_target_dir = resource_node.make_node(mipmap_path_prefix)
default_icon_target_dir.mkdir()
dest_file = os.path.join(default_icon_target_dir.abspath(), APP_ICON_NAME)
shutil.copyfile(default_icon_source_node, dest_file)
os.chmod(dest_file, Utils.O755)
copied_files.append(dest_file)
else:
Logs.debug('android: No default icon override specified for %s' % project)
# process each of the resolution overrides
warnings = []
for resolution in RESOLUTION_SETTINGS:
target_directory = resource_node.make_node(mipmap_path_prefix + '-' + resolution)
# get the current resolution icon override
icon_source = icon_overrides.get(resolution, default_icon)
if icon_source is default_icon:
# if both the resolution and the default are unspecified, warn the user but do nothing
if icon_source is None:
warnings.append(
'No icon override found for "{}". Either supply one for "{}" or a "default" in the android_settings "icon" section of the project.json file for {}'.format(resolution, resolution, project)
)
# if only the resolution is unspecified, remove the resolution specific version from the project
else:
Logs.debug('android: Default icon being used for "%s" in %s' % (resolution, project))
remove_file_and_empty_directory(target_directory.abspath(), APP_ICON_NAME)
continue
icon_source_node = construct_source_path(conf, project, icon_source)
icon_target_node = target_directory.make_node(APP_ICON_NAME)
shutil.copyfile(icon_source_node, icon_target_node.abspath())
icon_target_node.chmod(Utils.O755)
copied_files.append(icon_target_node.abspath())
# guard against spamming warnings in the case the icon override block is full of garbage and no actual overrides
if len(warnings) != len(RESOLUTION_SETTINGS):
for warning_msg in warnings:
Logs.warn('[WARN] {}'.format(warning_msg))
# resolve the application splash screen overrides
splash_overrides = conf.get_android_app_splash_screens(project)
if splash_overrides:
drawable_path_prefix = 'drawable-'
for orientation_flag, orientation_key in ORIENTATION_FLAG_TO_KEY_MAP.items():
orientation_path_prefix = drawable_path_prefix + orientation_key
oriented_splashes = splash_overrides.get(orientation_key, {})
unused_override_warning = None
if (orientation & orientation_flag) == 0:
unused_override_warning = 'Splash screen overrides specified for "{}" when desired orientation is set to "{}" in project {}. These overrides will be ignored.'.format(
orientation_key,
ORIENTATION_FLAG_TO_KEY_MAP[orientation],
project)
# if a default splash image is specified for this orientation, then copy it into the generic drawable- folder
default_splash_img = oriented_splashes.get('default', None)
if default_splash_img is not None:
if unused_override_warning:
Logs.warn('[WARN] {}'.format(unused_override_warning))
continue
default_splash_img_source_node = construct_source_path(conf, project, default_splash_img)
default_splash_img_target_dir = resource_node.make_node(orientation_path_prefix)
default_splash_img_target_dir.mkdir()
dest_file = os.path.join(default_splash_img_target_dir.abspath(), APP_SPLASH_NAME)
shutil.copyfile(default_splash_img_source_node, dest_file)
os.chmod(dest_file, Utils.O755)
copied_files.append(dest_file)
else:
Logs.debug('android: No default splash screen override specified for "%s" orientation in %s' % (orientation_key, project))
# process each of the resolution overrides
warnings = []
# The xxxhdpi resolution is only for application icons, its overkill to include them for drawables... for now
valid_resolutions = set(RESOLUTION_SETTINGS)
valid_resolutions.discard('xxxhdpi')
for resolution in valid_resolutions:
target_directory = resource_node.make_node(orientation_path_prefix + '-' + resolution)
# get the current resolution splash image override
splash_img_source = oriented_splashes.get(resolution, default_splash_img)
if splash_img_source is default_splash_img:
# if both the resolution and the default are unspecified, warn the user but do nothing
if splash_img_source is None:
section = "%s-%s" % (orientation_key, resolution)
warnings.append(
'No splash screen override found for "{}". Either supply one for "{}" or a "default" in the android_settings "splash_screen-{}" section of the project.json file for {}'.format(section, resolution, orientation_key, project)
)
# if only the resolution is unspecified, remove the resolution specific version from the project
else:
Logs.debug('android: Default splash screen being used for "%s-%s" in %s', orientation_key, resolution, project)
remove_file_and_empty_directory(target_directory.abspath(), APP_SPLASH_NAME)
continue
splash_img_source_node = construct_source_path(conf, project, splash_img_source)
splash_img_target_node = target_directory.make_node(APP_SPLASH_NAME)
shutil.copyfile(splash_img_source_node, splash_img_target_node.abspath())
splash_img_target_node.chmod(Utils.O755)
copied_files.append(splash_img_target_node.abspath())
# guard against spamming warnings in the case the splash override block is full of garbage and no actual overrides
if len(warnings) != len(valid_resolutions):
if unused_override_warning:
Logs.warn('[WARN] {}'.format(unused_override_warning))
else:
for warning_msg in warnings:
Logs.warn('[WARN] {}'.format(warning_msg))
# micro-optimization to clear assets from the final bundle that won't be used
if orientation == ORIENTATION_LANDSCAPE:
Logs.debug('android: Clearing the portrait assets from %s' % project)
clear_splash_assets(resource_node, 'drawable-port')
elif orientation == ORIENTATION_PORTRAIT:
Logs.debug('android: Clearing the landscape assets from %s' % project)
clear_splash_assets(resource_node, 'drawable-land')
# delete all files from the destination folder that were not copied by the script or part of the android studio project
all_files = proj_root.ant_glob("**", excl=['build.gradle', 'CMakeLists.txt', '*.iml'])
files_to_delete = [path for path in all_files if path.abspath() not in copied_files]
for file in files_to_delete:
file.delete()
# add all the projects to the root wscript
android_wscript = android_root.make_node('wscript')
with open(android_wscript.abspath(), 'w') as wscript_file:
w = wscript_file.write
write_auto_gen_header(wscript_file)
w('SUBFOLDERS = [\n')
w('\t\'%s\'\n]\n\n' % '\',\n\t\''.join(created_directories))
w('def build(bld):\n')
w('\tvalid_subdirs = [x for x in SUBFOLDERS if bld.path.find_node("%s/wscript" % x)]\n')
w('\tbld.recurse(valid_subdirs)\n')
# Some Android SDK libraries have bugs, so we need to copy them locally and patch them.
if not copy_and_patch_android_libraries(conf, source_node, android_root):
return False
return True
@conf
def process_android_projects(conf):
projects_path = os.path.join(Context.launch_dir, conf.get_android_project_relative_path())
conf.recurse(projects_path)
################################################################
@conf
def is_module_for_game_project(self, module_name, game_project, project_name):
"""
Check to see if the task generator is part of the build for a particular game project.
The following rules apply:
1. It is a gem requested by the game project
2. It is the game project / project's launcher
3. It is part of the general modules list
"""
enabled_game_projects = self.get_enabled_game_project_list()
if self.is_gem(module_name):
for gem in self.get_game_gems(game_project):
if module_name in [gem_module.target_name for gem_module in gem.modules]:
return True;
elif module_name == game_project or game_project == project_name:
return True
elif module_name not in enabled_game_projects and project_name is None:
return True
return False
################################################################
def collect_game_project_modules(ctx, game_project):
module_tasks = []
for group in ctx.groups:
for task_generator in group:
if not isinstance(task_generator, TaskGen.task_gen):
continue
Logs.debug('android: Processing task %s', task_generator.name)
task_type = getattr(task_generator, '_type', None)
if task_type not in ('stlib', 'shlib', 'objects'):
Logs.debug('android: -> Task is NOT a C/C++ task, Skipping...')
continue
project_name = getattr(task_generator, 'project_name', None)
if not ctx.is_module_for_game_project(task_generator.name, game_project, project_name):
Logs.debug('android: -> Task is NOT part of the game project, Skipping...')
continue
module_tasks.append(task_generator)
return module_tasks
def collect_source_paths(android_task, module_tasks, src_path_tag):
game_project = android_task.game_project
bld = android_task.bld
platform = bld.env['PLATFORM']
config = bld.env['CONFIGURATION']
search_tags = [
src_path_tag,
'android_{}'.format(src_path_tag),
'android_{}_{}'.format(config, src_path_tag),
'{}_{}'.format(platform, src_path_tag),
'{}_{}_{}'.format(platform, config, src_path_tag),
]
source_paths = []
for task_generator in module_tasks:
raw_paths = []
for tag in search_tags:
raw_paths += getattr(task_generator, tag, [])
for path in raw_paths:
if os.path.isabs(path):
path = bld.root.make_node(path)
else:
path = task_generator.path.make_node(path)
source_paths.append(path)
return source_paths
################################################################
def set_key_and_store_pass(ctx):
if ctx.get_android_build_environment() == 'Distribution':
key_pass = ctx.options.distro_key_pass
store_pass = ctx.options.distro_store_pass
if not (key_pass and store_pass):
ctx.fatal('[ERROR] Build environment is set to Distribution but --distro-key-pass or --distro-store-pass arguments were not specified or blank')
else:
key_pass = ctx.options.dev_key_pass
store_pass = ctx.options.dev_store_pass
ctx.env['KEYPASS'] = key_pass
ctx.env['STOREPASS'] = store_pass
################################################################
################################################################
class strip_symbols_base(Task):
"""
Strips symbols from a shared library
"""
color = 'CYAN'
vars = [ 'STRIP' ]
def __str__(self):
task_description = super(strip_symbols_base, self).__str__()
return task_description.replace(self.__class__.__name__, 'strip_symbols')
def runnable_status(self):
if super(strip_symbols_base, self).runnable_status() == ASK_LATER:
return ASK_LATER
src = self.inputs[0].abspath()
tgt = self.outputs[0].abspath()
# If the target file is missing, we need to run
try:
stat_tgt = os.stat(tgt)
except OSError:
return RUN_ME
# Now compare both file stats
try:
stat_src = os.stat(src)
except OSError:
pass
else:
CREATION_TIME_PADDING = 10
# only check timestamps
if stat_src.st_mtime >= (stat_tgt.st_mtime + CREATION_TIME_PADDING):
return RUN_ME
# Everything fine, we can skip this task
return SKIP_ME
class strip_symbols_gcc(strip_symbols_base):
run_str = '${STRIP} --strip-all -o ${TGT} ${SRC}'
class strip_symbols_llvm_ndk_r18(strip_symbols_base):
run_str = '${STRIP} -strip-all ${SRC} ${TGT}'
class strip_symbols_llvm_ndk_r19(strip_symbols_base):
run_str = '${STRIP} ${SRC} -strip-all -o ${TGT}'
@taskgen_method
def create_strip_symbols_task(self, src, tgt):
# ndk r18 introduced the llvm based symbol stripper and in r19 they changed the usage
ndk_rev = self.env['ANDROID_NDK_REV_MAJOR']
if ndk_rev >= 19:
symbol_stripper = 'strip_symbols_llvm_ndk_r19'
if ndk_rev == 18:
symbol_stripper = 'strip_symbols_llvm_ndk_r18'
else:
symbol_stripper = 'strip_symbols_gcc'
return self.create_task(symbol_stripper, src, tgt)
################################################################
class android_manifest_preproc(Task):
color = 'PINK'
def runnable_status(self):
result = super(android_manifest_preproc, self).runnable_status()
if result == SKIP_ME and not os.path.isfile(self.outputs[0].abspath()):
return RUN_ME
return result
def run(self):
min_sdk = self.env['ANDROID_NDK_PLATFORM_NUMBER']
target_sdk = self.env['ANDROID_SDK_VERSION_NUMBER']
input_contents = self.inputs[0].read()
transfromed_text = input_contents.replace(
'',
''.format(min_sdk, target_sdk))
self.outputs[0].write(transfromed_text)
################################################################
class aapt_package_base(Task):
"""
General purpose 'package' variant Android Asset Packaging Tool task
"""
color = 'PINK'
vars = [ 'AAPT', 'AAPT_RESOURCES', 'AAPT_INCLUDES', 'AAPT_PACKAGE_FLAGS' ]
def runnable_status(self):
def _to_list(value):
if isinstance(value, list):
return value
else:
return [ value ]
if not self.inputs:
self.inputs = []
aapt_resources = getattr(self.generator, 'aapt_resources', [])
assets = getattr(self, 'assets', [])
apk_layout = getattr(self, 'srcdir', [])
input_paths = _to_list(aapt_resources) + _to_list(assets) + _to_list(apk_layout)
for path in input_paths:
files = path.ant_glob('**/*')
self.inputs.extend(files)
android_manifest = getattr(self.generator, 'main_android_manifest', None)
if android_manifest:
self.inputs.append(android_manifest)
result = super(aapt_package_base, self).runnable_status()
if result == SKIP_ME:
for output in self.outputs:
if not os.path.isfile(output.abspath()):
return RUN_ME
return result
################################################################
class android_code_gen(aapt_package_base):
"""
Generates the R.java files from the Android resources
"""
run_str = '${AAPT} package -f -M ${ANDROID_MANIFEST} ${AAPT_RESOURCE_ST:AAPT_RESOURCES} ${AAPT_INLC_ST:AAPT_INCLUDES} ${AAPT_PACKAGE_FLAGS} -m -J ${OUTDIR}'
nocache = True
################################################################
class package_resources(aapt_package_base):
"""
Packages all the native resources from the Android project
"""
run_str = '${AAPT} package -f ${ANDROID_DEBUG_MODE} -M ${ANDROID_MANIFEST} ${AAPT_RESOURCE_ST:AAPT_RESOURCES} ${AAPT_INLC_ST:AAPT_INCLUDES} ${AAPT_PACKAGE_FLAGS} -F ${TGT}'
################################################################
class build_apk(aapt_package_base):
"""
Generates an unsigned, unaligned Android APK
"""
run_str = '${AAPT} package -f ${ANDROID_DEBUG_MODE} -M ${ANDROID_MANIFEST} ${AAPT_RESOURCE_ST:AAPT_RESOURCES} ${AAPT_INLC_ST:AAPT_INCLUDES} ${AAPT_ASSETS_ST:AAPT_ASSETS} ${AAPT_PACKAGE_FLAGS} -F ${TGT} ${SRCDIR}'
################################################################
class aapt_crunch(Task):
"""
Processes the PNG resources from the Android project
"""
color = 'PINK'
run_str = '${AAPT} crunch ${AAPT_RESOURCE_ST:AAPT_RESOURCES} -C ${TGT}'
vars = [ 'AAPT', 'AAPT_RESOURCES' ]
def runnable_status(self):
if not self.inputs:
self.inputs = []
for resource in self.generator.aapt_resources:
res = resource.ant_glob('**/*')
self.inputs.extend(res)
return super(aapt_crunch, self).runnable_status()
################################################################
class aidl(Task):
"""
Processes the Android interface files
"""
color = 'PINK'
run_str = '${AIDL} ${AIDL_PREPROC_ST:AIDL_PREPROCESSES} ${SRC} ${TGT}'
def runnable_status(self):
result = super(aidl, self).runnable_status()
if result == SKIP_ME:
for output in self.outputs:
if not os.path.isfile(output.abspath()):
return RUN_ME
return result
################################################################
class dex(Task):
"""
Compiles the .class files into the dalvik executable
"""
color = 'PINK'
run_str = '${DX} --dex --output ${TGT} ${JAR_INCLUDES} ${SRCDIR}'
def runnable_status(self):
for tsk in self.run_after:
if not tsk.hasrun:
return ASK_LATER
if not self.inputs:
self.inputs = []
srcdir = self.srcdir
if not isinstance(srcdir, list):
srcdir = [ srcdir ]
for src_node in srcdir:
self.inputs.extend(src_node.ant_glob('**/*.class', remove = False, quiet = True))
result = super(dex, self).runnable_status()
if result == SKIP_ME:
for output in self.outputs:
if not os.path.isfile(output.abspath()):
return RUN_ME
return result
################################################################
class zipalign(Task):
"""
Performs a specified byte alignment on the source file
"""
color = 'PINK'
run_str = '${ZIPALIGN} -f ${ZIPALIGN_SIZE} ${SRC} ${TGT}'
def runnable_status(self):
result = super(zipalign, self).runnable_status()
if result == SKIP_ME:
for output in self.outputs:
if not os.path.isfile(output.abspath()):
return RUN_ME
return result
################################################################
################################################################
@taskgen_method
def create_strip_task(self, source_file, dest_location):
lib_name = os.path.basename(source_file.abspath())
output_node = dest_location.make_node(lib_name)
# For Android Studio we should just copy the libs because stripping is part of the build process.
# But we have issues with long path names that makes the stripping process to fail in Android Studio.
self.create_strip_symbols_task(source_file, output_node)
################################################################
@feature('cshlib', 'cxxshlib')
@before_method('propagate_uselib_vars')
def link_aws_sdk_core_after_android(self):
'''
Android monolithic builds require the aws core library to be linked after any
other aws libs in order for the symbols to be resolved correctly.
'''
if not (self.bld.is_android_platform(self.env['PLATFORM']) and self.bld.is_build_monolithic()):
return
if 'AWS_CPP_SDK_CORE' in self.uselib:
self.uselib = [ uselib for uselib in self.uselib if uselib != 'AWS_CPP_SDK_CORE' ]
self.uselib.append('AWS_CPP_SDK_CORE')
@feature('c', 'cxx')
@after_method('apply_link')
def android_natives_processing(self):
bld = self.bld
platform = bld.env['PLATFORM']
configuration = bld.env['CONFIGURATION']
link_task = getattr(self, 'link_task', None)
if (not bld.is_android_platform(platform)) or (not link_task) or (self._type != 'shlib'):
return
output_node = self.bld.get_output_folders(platform, configuration)[0]
project_name = getattr(self, 'project_name', None)
game_project_builder_nodes = []
for game in bld.get_enabled_game_project_list():
if not bld.is_module_for_game_project(self.name, game, project_name):
continue
# If the game is a valid android project, a specific build native value will have been created during
# the project configuration. Only process games with valid android project settings
game_build_native_key = '{}_BUILDER_NATIVES'.format(game)
if game_build_native_key not in self.env:
continue
builder_node = bld.root.find_dir(self.env[game_build_native_key])
if builder_node:
game_project_builder_nodes.append(builder_node)
for src in link_task.outputs:
src_lib = output_node.make_node(src.name)
for builder_node in game_project_builder_nodes:
self.create_strip_task(src_lib, builder_node)
third_party_artifacts = getattr(self.env, 'COPY_3RD_PARTY_ARTIFACTS', [])
for artifact in third_party_artifacts:
if isinstance(artifact, tuple):
_, ext = os.path.splitext(artifact[0].abspath())
else:
_, ext = os.path.splitext(artifact.abspath())
# Only care about shared libraries
if ext != '.so':
continue
for builder_node in game_project_builder_nodes:
self.create_strip_task(artifact, builder_node)
external_artifacts = getattr(self.env, 'COPY_DEPENDENT_FILES_{}'.format(self.target.upper()), [])
for artifact in external_artifacts:
_, ext = os.path.splitext(artifact)
# Only care about shared libraries
if ext != '.so':
continue
for builder_node in game_project_builder_nodes:
src_node = self.bld.root.make_node(artifact)
tgt_node = builder_node.make_node(src_node.name)
self.create_task('copy_outputs', src = src_node, tgt = tgt_node)
################################################################
################################################################
@feature('wrapped_copy_outputs')
@before_method('process_source')
def create_copy_outputs(self):
self.meths.remove('process_source')
self.create_task('copy_outputs', self.source, self.target)
@taskgen_method
def sign_and_align_apk(self, base_apk_name, raw_apk, intermediate_folder, final_output, suffix = ''):
# first sign the apk with jarsigner
apk_name = '{}_unaligned{}.apk'.format(base_apk_name, suffix)
unaligned_apk = intermediate_folder.make_node(apk_name)
self.jarsign_task = jarsign_task = self.create_task('jarsigner', raw_apk, unaligned_apk)
# align the new apk with assets
apk_name = '{}{}.apk'.format(base_apk_name, suffix)
final_apk = final_output.make_node(apk_name)
self.zipalign_task = zipalign_task = self.create_task('zipalign', unaligned_apk, final_apk)
# chain the alignment to happen after signing
zipalign_task.set_run_after(jarsign_task)
################################################################
################################################################
@conf
def AndroidAPK(ctx, *k, **kw):
project_name = kw['project_name']
env = ctx.env
platform = env['PLATFORM']
configuration = env['CONFIGURATION']
if ctx.cmd in ('configure', 'generate_uber_files', 'msvs'):
return
if not (ctx.is_android_platform(platform) or platform =='project_generator'):
return
if project_name not in ctx.get_enabled_game_project_list():
return
Logs.debug('android: ******************************** ')
Logs.debug('android: Processing {}...'.format(project_name))
if not hasattr(ctx, 'default_group_name'):
default_group = ctx.get_group(None)
ctx.default_group_name = ctx.get_group_name(default_group)
Logs.debug('android: ctx.default_group_name = %s', ctx.default_group_name)
root_input = ctx.path.get_src().make_node('src')
root_output = ctx.path.get_bld()
apk_layout_dir = root_output.make_node('builder')
# The variant name is constructed in the same fashion as how Gradle generates all it's build
# variants. After all the Gradle configurations and product flavors are evaluated, the variants
# are generated in the following lower camel case format {product_flavor}{configuration}.
# Our configuration and Gradle's configuration is a one to one mapping of what each describe,
# while our platform is effectively Gradle's product flavor.
gradle_variant = '{}{}'.format(platform, configuration.title())
# copy over the required 3rd party libs that need to be bundled into the apk
abi = env['ANDROID_ARCH']
if not abi:
abi = 'armeabi-v7a'
if ctx.options.from_android_studio:
local_native_libs_node = root_input.make_node([ gradle_variant, 'jniLibs', abi ])
else:
local_native_libs_node = apk_layout_dir.make_node([ 'lib', abi ])
local_native_libs_node.mkdir()
Logs.debug('android: APK builder path (native libs) -> {}'.format(local_native_libs_node.abspath()))
env['{}_BUILDER_NATIVES'.format(project_name)] = local_native_libs_node.abspath()
android_cache = get_android_cache_node(ctx)
libs_to_copy = env['EXT_LIBS']
for lib in libs_to_copy:
src = android_cache.make_node(lib)
lib_name = os.path.basename(lib)
tgt = local_native_libs_node.make_node(lib_name)
ctx(features = 'wrapped_copy_outputs', source = src, target = tgt)
# since we are having android studio building the apk we can kick out early
if ctx.options.from_android_studio:
return
# order of precedence from highest (primary) to lowest (inputs): full variant, build type, product flavor, main
local_variant_dirs = [ gradle_variant, configuration, platform, 'main' ]
java_source_nodes = []
android_manifests = []
resource_nodes = []
for source_dir in local_variant_dirs:
java_node = root_input.find_node([ source_dir, 'java' ])
if java_node:
java_source_nodes.append(java_node)
res_node = root_input.find_node([ source_dir, 'res' ])
if res_node:
resource_nodes.append(res_node)
manifest_node = root_input.find_node([ source_dir, 'AndroidManifest.xml' ])
if manifest_node:
android_manifests.append(manifest_node)
if not android_manifests:
ctx.fatal('[ERROR] Unable to find any AndroidManifest.xml files in project path {}.'.format(ctx.path.get_src().abspath()))
Logs.debug('android: Found local Java source directories {}'.format(java_source_nodes))
Logs.debug('android: Found local resource directories {}'.format(resource_nodes))
Logs.debug('android: Found local manifest file {}'.format(android_manifests))
# get the keystore passwords
set_key_and_store_pass(ctx)
# Private function to add android libraries to the build
def _add_library(folder, libName, source_paths, manifests, package_names, resources):
'''
Collect the resources and package names of the specified library.
'''
if not folder:
Logs.error('[ERROR] Invalid folder for library {}. Please check the path in the {} file.'.format(libName, java_libs_json.abspath()))
return False
src = folder.find_dir('src')
if not src:
Logs.error("[ERROR] Could not find the 'src' folder for library {}. Please check that they are present at {}".format(libName, folder.abspath()))
return False
source_paths.append(src)
manifest = folder.find_node('AndroidManifest.xml')
if not manifest:
Logs.error("[ERROR] Could not find the AndroidManifest.xml folder for library {}. Please check that they are present at {}".format(libName, folder.abspath()))
return False
manifests.append(manifest)
tree = ET.parse(manifest.abspath())
root = tree.getroot()
package = root.get('package')
if not package:
Logs.error("[ERROR] Could not find 'package' node in {}. Please check that the manifest is valid ".format(manifest.abspath()))
return False
package_names.append(package)
res = folder.find_dir('res')
if res:
resources.append(res)
return True
library_packages = []
library_jars = []
java_libs_json = ctx.root.make_node(kw['android_java_libs'])
json_data = ctx.parse_json_file(java_libs_json)
if json_data:
for libName, value in json_data.items():
if 'libs' in value:
# Collect any java lib that is needed so we can add it to the classpath.
for java_lib in value['libs']:
jar_path = string.Template(java_lib['path']).substitute(env)
if os.path.exists(jar_path):
library_jars.append(jar_path)
elif java_lib['required']:
ctx.fatal('[ERROR] Required java lib - {} - was not found'.format(jar_path))
if 'patches' in value:
cur_path = ctx.srcnode.abspath()
rel_path = ctx.get_android_patched_libraries_relative_path()
lib_path = os.path.join(cur_path, rel_path, libName)
else:
# Search the multiple library paths where the library can be
lib_path = None
for path in value['srcDir']:
path = string.Template(path).substitute(env)
if os.path.exists(path):
lib_path = path
break
if not _add_library(ctx.root.make_node(lib_path), libName, java_source_nodes, android_manifests, library_packages, resource_nodes):
ctx.fatal('[ERROR] Could not add the android library - {}'.format(libName))
r_java_out = root_output.make_node('r')
aidl_out = root_output.make_node('aidl')
java_out = root_output.make_node('classes')
crunch_out = root_output.make_node('res')
manifests_out = root_output.make_node('manifest')
game_package = ctx.get_android_package_name(project_name)
executable_name = ctx.get_executable_name(project_name)
Logs.debug('android: ****')
Logs.debug('android: All Java source directories {}'.format(java_source_nodes))
Logs.debug('android: All resource directories {}'.format(resource_nodes))
java_include_paths = java_source_nodes + [ r_java_out, aidl_out ]
java_source_paths = java_source_nodes
uses = kw.get('use', [])
if not isinstance(uses, list):
uses = [ uses ]
################################
# Push all the Android apk packaging into their own build groups with
# lazy posting to ensure they are processed at the end of the build
ctx.post_mode = POST_LAZY
build_group_name = '{}_android_group'.format(project_name)
ctx.add_group(build_group_name)
ctx(
name = '{}_APK'.format(project_name),
target = executable_name,
features = [ 'android', 'android_apk', 'javac', 'use', 'uselib' ],
use = uses,
game_project = project_name,
# java settings
compat = env['JAVA_VERSION'], # java compatibility version number
classpath = library_jars,
srcdir = java_include_paths, # folder containing the sources to compile
outdir = java_out, # folder where to output the classes (in the build directory)
# android settings
android_manifests = android_manifests,
android_package = game_package,
aidl_outdir = aidl_out,
r_java_outdir = r_java_out,
manifests_out = manifests_out,
apk_layout_dir = apk_layout_dir,
apk_native_lib_dir = local_native_libs_node,
apk_output_dir = 'apk',
aapt_assets = [],
aapt_resources = resource_nodes,
aapt_extra_packages = library_packages,
aapt_package_flags = [],
aapt_package_resources_outdir = 'bin',
aapt_crunch_outdir = crunch_out,
)
# reset the build group/mode back to default
ctx.post_mode = POST_AT_ONCE
ctx.set_group(ctx.default_group_name)
################################################################
@feature('android')
@before('apply_java')
def apply_android_java(self):
"""
Generates the AIDL tasks for all other tasks that may require it, and adds
their Java source directories to the current projects Java source paths
so they all get processed at the same time. Also processes the direct
Android Archive Resource uses.
"""
Utils.def_attrs(
self,
srcdir = [],
classpath = [],
aidl_srcdir = [],
aapt_assets = [],
aapt_includes = [],
aapt_resources = [],
aapt_extra_packages = [],
)
# validate we have some required attributes
apk_native_lib_dir = getattr(self, 'apk_native_lib_dir', None)
if not apk_native_lib_dir:
self.fatal('[ERROR] No "apk_native_lib_dir" specified in Android package task.')
android_manifests = getattr(self, 'android_manifests', None)
if not android_manifests:
self.fatal('[ERROR] No "android_manifests" specified in Android package task.')
manifest_nodes = []
for manifest in android_manifests:
if not isinstance(manifest, Node.Node):
manifest_nodes.append(self.path.get_src().make_node(manifest))
else:
manifest_nodes.append(manifest)
self.android_manifests = manifest_nodes
self.main_android_manifest = manifest_nodes[0] # guaranteed to be the main; manifests are added in order of precedence highest to lowest
# process the uses
if not hasattr(self, 'use'):
setattr(self, 'use', [])
local_uses = self.to_list(self.use)[:]
Logs.debug('android: -> Processing Android libs used by %s: %s', self.name, local_uses)
# collect any AAR uses from other modules
game_project_modules = collect_game_project_modules(self.bld, self.game_project)
other_uses = []
for tsk in game_project_modules:
# skip the launchers / same module, those source paths were already added above
if tsk.name.endswith('Launcher'):
continue
other_uses.extend(self.to_list(getattr(tsk, 'use', [])))
other_uses = list(set(other_uses))
Logs.debug('android: -> Processing possible Android libs used by dependent modules: %s', other_uses)
input_manifests = []
use_libs_added = []
libs = local_uses + other_uses
for lib_name in libs:
try:
task_gen = self.bld.get_tgen_by_name(lib_name)
task_gen.post()
except Exception:
continue
else:
if not hasattr(task_gen, 'aar_task'):
continue
Logs.debug('android: -> Applying AAR - %s - to the build', lib_name)
use_libs_added.append(lib_name)
# ensure the lib is part of the APK uses so all the AAR properties are propagated
if lib_name not in local_uses:
append_to_unique_list(self.use, lib_name)
# required entries from the library
append_to_unique_list(self.aapt_extra_packages, task_gen.package)
append_to_unique_list(self.aapt_includes, task_gen.jar_task.outputs[0].abspath())
append_to_unique_list(self.aapt_resources, task_gen.aapt_resources)
append_to_unique_list(input_manifests, task_gen.manifest)
# optional entries from the library
if task_gen.aapt_assets:
append_to_unique_list(self.aapt_assets, task_gen.aapt_assets)
# since classpath is propagated by the java tool, we just need to make sure the jars are propagated to the android specific tools using aapt_includes
if task_gen.classpath:
append_to_unique_list(self.aapt_includes, task_gen.classpath)
if task_gen.native_libs:
native_libs_root = task_gen.native_libs_root
native_libs = task_gen.native_libs
for lib in native_libs:
rel_path = lib.path_from(native_libs_root)
tgt = apk_native_lib_dir.make_node(rel_path)
strip_task = self.create_strip_symbols_task(lib, tgt)
self.bld.add_to_group(strip_task, self.bld.default_group_name)
Logs.debug('android: -> Android use libs added {}'.format(use_libs_added))
# append manifests from modules(including Gems) to the list
module_manifest_paths = collect_source_paths(self, game_project_modules, 'manifest_path')
for path in module_manifest_paths:
module_manifest = path.find_node('AndroidManifest.xml')
if module_manifest:
append_to_unique_list(input_manifests, module_manifest)
Logs.debug('android: -> AndroidManifest added: {}'.format(module_manifest.abspath()))
# generate the task to merge the manifests
manifest_nodes.extend(input_manifests)
# manifest processing
manifests_out = getattr(self, 'manifests_out', None)
if manifests_out:
if not isinstance(manifests_out, Node.Node):
manifests_out = self.path.get_bld().make_node(manifests_out)
else:
manifests_out = self.path.get_bld().make_node('manifest')
manifests_out.mkdir()
# android studio doesn't like having the min/target sdk specified in the manifest
# but then the manifest merger will complain with it gone, so it needs to be injected
# back in before the merging happens
manifest_preproc_out = manifests_out.make_node('preproc')
manifest_preproc_out.mkdir()
manifest_preproc_tgt = manifest_preproc_out.make_node('AndroidManifest.xml')
self.manifest_preproc_task = self.create_task('android_manifest_preproc', self.main_android_manifest, manifest_preproc_tgt)
self.main_android_manifest = manifest_nodes[0] = manifest_preproc_tgt
if len(manifest_nodes) >= 2:
manifest_merged_out = manifests_out.make_node('merged')
manifest_merged_out.mkdir()
merged_manifest = manifest_merged_out.make_node('AndroidManifest.xml')
self.manifest_merger_task = manifest_merger_task = self.create_task('android_manifest_merger', manifest_nodes, merged_manifest)
manifest_merger_task.env['MAIN_MANIFEST'] = self.main_android_manifest.abspath()
input_manifest_paths = [ manifest.abspath() for manifest in manifest_nodes[1:] ]
manifest_merger_task.env['LIBRARY_MANIFESTS'] = os.pathsep.join(input_manifest_paths)
self.main_android_manifest = merged_manifest
# generate all the aidl tasks
aidl_outdir = getattr(self, 'aidl_outdir', None)
if aidl_outdir:
if not isinstance(aidl_outdir, Node.Node):
aidl_outdir = self.path.get_bld().make_node(aidl_outdir)
else:
aidl_outdir = self.path.get_bld().make_node('aidl')
aidl_outdir.mkdir()
aidl_src_paths = collect_source_paths(self, game_project_modules, 'aidl_src_path')
self.aidl_tasks = []
for srcdir in aidl_src_paths:
for aidl_file in srcdir.ant_glob('**/*.aidl'):
rel_path = aidl_file.path_from(srcdir)
java_file = aidl_outdir.make_node('{}.java'.format(os.path.splitext(rel_path)[0]))
aidl_task = self.create_task('aidl', aidl_file, java_file)
self.aidl_tasks.append(aidl_task)
java_src_paths = collect_source_paths(self, game_project_modules, 'java_src_path')
append_to_unique_list(self.srcdir, java_src_paths)
jars = collect_source_paths(self, game_project_modules, 'jars')
for jar in jars:
jar_path = ''
if isinstance(jar, Node.Node):
jar_path = jar.abspath()
else:
jar_path = jar
append_to_unique_list(self.classpath, jar_path)
append_to_unique_list(self.aapt_includes, jar_path)
Logs.debug('android: -> Additional Java source paths found {}'.format(java_src_paths))
################################################################
@feature('android')
@before_method('process_source')
@after_method('apply_java')
def apply_android(self):
"""
Generates the code generation task (produces R.java) and setups the task chaining
for AIDL, Java and the code gen task
"""
Utils.def_attrs(
self,
classpath = [],
aapt_resources = [],
aapt_includes = [],
aapt_extra_packages = [],
aapt_package_flags = [],
)
main_package = getattr(self, 'android_package', None)
if not main_package:
raise Errors.WafError('[ERROR] No "android_package" specified in Android package task.')
javac_task = getattr(self, 'javac_task', None)
if not javac_task:
raise Errors.WafError('[ERROR] It seems the "javac" task failed to be generated, unable to complete the Android build process.')
self.code_gen_task = code_gen_task = self.create_task('android_code_gen')
r_java_outdir = getattr(self, 'r_java_outdir', None)
if r_java_outdir:
if not isinstance(r_java_outdir, Node.Node):
r_java_outdir = self.path.get_bld().make_node(r_java_outdir)
else:
r_java_outdir = self.path.get_bld().make_node('r')
r_java_outdir.mkdir()
code_gen_task.env['OUTDIR'] = r_java_outdir.abspath()
android_manifest = self.main_android_manifest
code_gen_task.env['ANDROID_MANIFEST'] = android_manifest.abspath()
# resources
aapt_resources = []
for resource in self.aapt_resources:
if isinstance(resource, Node.Node):
aapt_resources.append(resource.abspath())
else:
aapt_resources.append(resource)
self.aapt_resource_paths = aapt_resources
code_gen_task.env.append_value('AAPT_RESOURCES', aapt_resources)
# included jars
aapt_includes = self.aapt_includes + self.classpath
aapt_include_paths = []
for include_path in self.aapt_includes:
if isinstance(include_path, Node.Node):
aapt_include_paths.append(include_path.abspath())
else:
aapt_include_paths.append(include_path)
self.aapt_include_paths = aapt_include_paths
code_gen_task.env.append_value('AAPT_INCLUDES', aapt_include_paths)
# additional flags
aapt_package_flags = self.aapt_package_flags
extra_packages = self.aapt_extra_packages
if extra_packages:
aapt_package_flags.extend([ '--extra-packages', ':'.join(extra_packages) ])
code_gen_task.env.append_value('AAPT_PACKAGE_FLAGS', aapt_package_flags)
# outputs (R.java files)
included_packages = [ main_package ] + extra_packages
output_nodes = []
for package in included_packages:
sub_dirs = package.split('.')
dir_path = os.path.join(*sub_dirs)
r_java_path = os.path.join(dir_path, 'R.java')
r_java_node = r_java_outdir.make_node(r_java_path)
output_nodes.append(r_java_node)
code_gen_task.set_outputs(output_nodes)
# task chaining
manifest_preproc_task = getattr(self, 'manifest_preproc_task', None)
manifest_merger_task = getattr(self, 'manifest_merger_task', None)
if manifest_preproc_task and manifest_merger_task:
code_gen_task.set_run_after(manifest_merger_task)
manifest_merger_task.set_run_after(manifest_preproc_task)
elif manifest_preproc_task:
code_gen_task.set_run_after(manifest_preproc_task)
elif manifest_merger_task:
code_gen_task.set_run_after(manifest_merger_task)
aidl_tasks = getattr(self, 'aidl_tasks', [])
for aidl_task in aidl_tasks:
code_gen_task.set_run_after(aidl_task)
javac_task.set_run_after(self.code_gen_task)
################################################################
@feature('android_apk')
@after_method('apply_android')
def apply_android_apk(self):
"""
Generates the rest of the tasks necessary for building an APK (dex, crunch, package, build, sign, alignment).
"""
Utils.def_attrs(
self,
aapt_assets = [],
aapt_include_paths = [],
aapt_resource_paths = [],
aapt_package_flags = [],
)
root_input = self.path.get_src()
root_output = self.path.get_bld()
if not hasattr(self, 'target'):
self.target = self.name
executable_name = self.target
aapt_resources = self.aapt_resource_paths
aapt_includes = self.aapt_include_paths
aapt_assets = []
asset_nodes = []
for asset_dir in self.aapt_assets:
if isinstance(asset_dir, Node.Node):
aapt_assets.append(asset_dir.abspath())
asset_nodes.append(asset_dir)
else:
aapt_assets.append(asset_dir)
asset_nodes.append(root_input.make_node(asset_dir))
android_manifest = self.android_manifests[0]
if hasattr(self, 'manifest_merger_task'):
android_manifest = self.manifest_merger_task.outputs[0]
# dex task
apk_layout_dir = getattr(self, 'apk_layout_dir', None)
if apk_layout_dir:
if not isinstance(apk_layout_dir, Node.Node):
apk_layout_dir = self.path.get_bld().make_node(apk_layout_dir)
else:
apk_layout_dir = root_output.make_node('builder')
apk_layout_dir.mkdir()
self.dex_task = dex_task = self.create_task('dex')
self.dex_task.set_outputs(apk_layout_dir.make_node('classes.dex'))
dex_task.env.append_value('JAR_INCLUDES', aapt_includes)
dex_srcdir = self.outdir
dex_task.env['SRCDIR'] = dex_srcdir.abspath()
dex_task.srcdir = dex_srcdir
# crunch task
self.crunch_task = crunch_task = self.create_task('aapt_crunch')
crunch_outdir = getattr(self, 'aapt_crunch_outdir', None)
if crunch_outdir:
if not isinstance(crunch_outdir, Node.Node):
crunch_outdir = root_output.make_node(crunch_outdir)
else:
crunch_outdir = root_output.make_node('res')
crunch_outdir.mkdir()
crunch_task.set_outputs(crunch_outdir)
crunch_task.env.append_value('AAPT_INCLUDES', aapt_includes)
crunch_task.env.append_value('AAPT_RESOURCES', aapt_resources)
# package resources task
self.package_resources_task = package_resources_task = self.create_task('package_resources')
aapt_package_resources_outdir = getattr(self, 'aapt_package_resources_outdir', None)
if aapt_package_resources_outdir:
if not isinstance(aapt_package_resources_outdir, Node.Node):
aapt_package_resources_outdir = root_output.make_node(aapt_package_resources_outdir)
else:
aapt_package_resources_outdir = root_output.make_node('bin')
aapt_package_resources_outdir.mkdir()
package_resources_task.set_outputs(aapt_package_resources_outdir.make_node('{}.ap_'.format(executable_name)))
package_resources_task.env['ANDROID_MANIFEST'] = android_manifest.abspath()
package_resources_task.env.append_value('AAPT_INCLUDES', aapt_includes)
package_resources_task.env.append_value('AAPT_RESOURCES', aapt_resources)
################################
# generate the APK
# Generating all the APK has to be in the right order. This is important for Android store APK uploads,
# if the alignment happens before the signing, then the signing will blow over the alignment and will
# require a realignment before store upload.
# 1. Generate the unsigned, unaligned APK
# 2. Sign the APK
# 3. Align the APK
apk_output_dir = getattr(self, 'apk_output_dir', None)
if apk_output_dir:
if not isinstance(apk_output_dir, Node.Node):
apk_output_dir = root_output.make_node(apk_output_dir)
else:
apk_output_dir = root_output.make_node('apk')
apk_output_dir.mkdir()
# 1. build_apk
self.apk_task = apk_task = self.create_task('build_apk')
apk_task.env['SRCDIR'] = apk_layout_dir.abspath()
apk_task.srcdir = apk_layout_dir
apk_name = '{}_unaligned_unsigned.apk'.format(executable_name)
unsigned_unaligned_apk = apk_output_dir.make_node(apk_name)
apk_task.set_outputs(unsigned_unaligned_apk)
apk_task.env['ANDROID_MANIFEST'] = android_manifest.abspath()
apk_task.assets = asset_nodes
apk_task.env.append_value('AAPT_ASSETS', aapt_assets)
apk_task.env.append_value('AAPT_INCLUDES', aapt_includes)
apk_task.env.append_value('AAPT_RESOURCES', aapt_resources)
# 2. jarsign and 3. zipalign
final_apk_out = self.bld.get_output_folders(self.bld.env['PLATFORM'], self.bld.env['CONFIGURATION'])[0]
self.sign_and_align_apk(
base_apk_name = executable_name,
raw_apk = unsigned_unaligned_apk,
intermediate_folder = apk_output_dir,
final_output = final_apk_out
)
# task chaining
dex_task.set_run_after(self.javac_task)
crunch_task.set_run_after(dex_task)
package_resources_task.set_run_after(crunch_task)
apk_task.set_run_after(package_resources_task)
self.jarsign_task.set_run_after(apk_task)
###############################################################################
###############################################################################
def adb_call(*cmd_args, **keywords):
'''
Issue a adb command. Args are joined into a single string with spaces
in between and keyword arguments supported is device=serial # of device
reported by adb.
Examples:
adb_call('start-server') results in "adb start-server" being executed
adb_call('push', local_path, remote_path, device='123456') results in "adb -s 123456 push " being executed
'''
global ADB_QUOTED_PATH
command = [ ADB_QUOTED_PATH ]
if 'device' in keywords:
command.extend([ '-s', keywords['device'] ])
command.extend(cmd_args)
cmdline = ' '.join(command)
Logs.debug('adb_call: running command \'%s\'', cmdline)
try:
output = check_output(cmdline, stderr = STDOUT, shell = True)
stripped_output = str(output.decode(sys.stdout.encoding or 'iso8859-1', 'replace')).rstrip()
# don't need to dump the output of 'push' or 'install' commands
if not any(cmd for cmd in ('push', 'install') if cmd in cmd_args):
if '\n' in stripped_output:
# the newline arg is because Logs.debug will replace newlines with spaces
# in the format string before passing it on to the logger
Logs.debug('adb_call: output = %s%s', '\n', stripped_output)
else:
Logs.debug('adb_call: output = %s', stripped_output)
return stripped_output
except Exception as inst:
Logs.debug('adb_call: exception was thrown: ' + str(inst))
return None # Return None so the caller can handle the failure gracefully'
def adb_ls(path, device_id, args = [], as_root = False):
'''
Special wrapper around calling "adb shell ls ". This uses
adb_call under the hood but provides some additional error handling specific
to the "ls" command. Optionally, this command can be run as super user, or
'as_root', which is disabled by default.
Returns:
status - if the command failed or not
output - the stripped output from the ls command
'''
error_messages = [
'No such file or directory',
'Permission denied'
]
shell_command = [ 'shell' ]
if as_root:
shell_command.extend([ 'su', '-c' ])
shell_command.append('ls')
shell_command.extend(args)
shell_command.append(path)
Logs.debug('adb_ls: {}'.format(shell_command))
raw_output = adb_call(*shell_command, device = device_id)
if raw_output is None:
Logs.debug('adb_ls: No output given')
return False, None
if raw_output is None or any([error for error in error_messages if error in raw_output]):
Logs.debug('adb_ls: Error message found')
status = False
else:
Logs.debug('adb_ls: Command was successful')
status = True
return status, raw_output
def get_list_of_android_devices():
'''
Gets the connected android devices using the adb command devices and
returns a list of serial numbers of connected devices.
'''
devices = []
devices_output = adb_call("devices")
if devices_output is not None:
devices_output = devices_output.split(os.linesep)
for output in devices_output:
if any(x in output for x in ['List', '*', 'emulator']):
Logs.debug("android: skipping the following line as it has 'List', '*' or 'emulator' in it: %s" % output)
continue
device_serial = output.split()
if device_serial:
if 'unauthorized' in output.lower():
Logs.warn("[WARN] android: device %s is not authorized for development access. Please reconnect the device and check for a confirmation dialog." % device_serial[0])
else:
devices.append(device_serial[0])
Logs.debug("android: found device with serial: " + device_serial[0])
return devices
def get_device_access_type(device_id):
'''
Determines what kind of access level we have on the device
'''
adb_call('root', device = device_id) # this ends up being either a no-op or restarts adbd on device as root
adbd_info = adb_call('shell', '"ps | grep adbd"', device = device_id)
if adbd_info and ('root' in adbd_info):
Logs.debug('adb_call: Device - {} - has adbd root access'.format(device_id))
return ACCESS_ROOT_ADBD
su_test = adb_call('shell', '"su -c echo test"', device = device_id)
if su_test and ('test' in su_test):
Logs.debug('adb_call: Device - {} - has shell su access'.format(device_id))
return ACCESS_SHELL_SU
Logs.debug('adb_call: Unable to verify root access for device {}. Assuming default access mode.'.format(device_id))
return ACCESS_NORMAL
def update_device_file_timestamp(remote_file_path, device_id, as_root = False):
'''
Updates the contents of the remote file with the current local time. Optionally, this
command can be run as super user, or 'as_root', which is disabled by default.
'''
adb_command = [ 'shell' ]
if as_root:
adb_command.extend([ 'su', '-c' ])
echo_command = '"echo {} > {}"'.format(datetime.now(), remote_file_path)
adb_command.append(echo_command)
adb_call(*adb_command, device = device_id)
def get_device_file_timestamp(remote_file_path, device_id, as_root = False):
'''
Get the integer timestamp value of a file from a given device. Optionally, this
command can be run as super user, or 'as_root', which is disabled by default.
'''
timestamp_string = ''
ls_status, _ = adb_ls(args = [ '-l' ], path = remote_file_path, device_id = device_id, as_root = as_root)
if ls_status:
adb_command = [ 'shell' ]
if as_root:
adb_command.extend([ 'su', '-c' ])
cat_command = '"cat {}"'.format(remote_file_path)
adb_command.append(cat_command)
file_contents = adb_call(*adb_command, device = device_id)
if file_contents:
timestamp_string = file_contents.strip()
if timestamp_string:
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M:%S.%f'):
try:
target_time = time.mktime(time.strptime(timestamp_string, fmt))
break
except:
target_time = 0
Logs.debug('android_deploy: {} time is {}'.format(remote_file_path, target_time))
return target_time
return 0
def auto_detect_device_storage_path(device_id, log_warnings = False):
'''
Uses the device's environment variable "EXTERNAL_STORAGE" to determine the correct
path to public storage that has write permissions. If at any point does the env var
validation fail, fallback to checking known possible paths to external storage.
'''
def _log_debug(message):
Logs.debug('android_deploy: {}'.format(message))
def _log_warn(message):
Logs.warn('[WARN] {}'.format(message))
log_func = _log_warn if log_warnings else _log_debug
def _check_known_paths():
external_storage_paths = [
'/sdcard/',
'/storage/emulated/0/',
'/storage/emulated/legacy/',
'/storage/sdcard0/',
'/storage/self/primary/',
]
_log_debug('Falling back to search list of known external storage paths for device {}.'.format(device_id))
for path in external_storage_paths:
_log_debug('Searching {}'.format(path))
status, _ = adb_ls(path, device_id)
if status:
return path[:-1]
else:
log_func('Failed to detect a valid path with correct permissions for device {}'.format(device_id))
return ''
external_storage = adb_call('shell', '"set | grep EXTERNAL_STORAGE"', device = device_id)
if not external_storage:
_log_debug('Call to get the EXTERNAL_STORAGE environment variable from device {} failed.'.format(device_id))
return _check_known_paths()
storage_path = external_storage.split('=')
if len(storage_path) != 2:
_log_debug('Received bad data while attempting extract the EXTERNAL_STORAGE environment variable from device {}.'.format(device_id))
return _check_known_paths()
var_path = storage_path[1].strip()
status, _ = adb_ls(var_path, device_id)
if status:
return var_path
_log_debug('The path specified in EXTERNAL_STORAGE seems to have permission issues, attempting to resolve with realpath for device {}.'.format(device_id))
real_path = adb_call('shell', 'realpath', var_path, device = device_id)
if not real_path:
_log_debug('Something happened while attempting to resolve the path from the EXTERNAL_STORAGE environment variable for device {}.'.format(device_id))
return _check_known_paths()
real_path = real_path.strip()
status, _ = adb_ls(real_path, device_id)
if status:
return real_path
_log_debug('Unable to validate the resolved EXTERNAL_STORAGE environment variable path for device {}.'.format(device_id))
return _check_known_paths()
def construct_assets_path_for_game_project(ctx, game_project):
'''
Generates the relative path from the root of public storage to the application's specific data folder
'''
return 'Android/data/{}/files'.format(ctx.get_android_package_name(game_project))
def build_shaders(ctx, game, assets_type, layout_node, assets_cache, generate_pak):
try:
packaging.get_shader_list(ctx, game, assets_type, 'GLES3')
except Exception as e:
Logs.info('[INFO] No updated shader list')
pass
if not generate_pak:
packaging.generate_shaders(ctx, game, assets_type, 'GLES3')
shader_cache_path = os.path.join(assets_cache.abspath(), game.lower(), 'shaders', 'cache')
shutil.rmtree(shader_cache_path, ignore_errors=True)
generated_shaders_path = os.path.join(assets_cache.abspath(), 'user', 'cache', 'shaders', 'cache')
shutil.move(generated_shaders_path, shader_cache_path)
else:
Logs.info('[INFO] Generating the shader pak files...')
shaders_pak_dir = packaging.generate_shaders_pak(ctx, game, assets_type, 'GLES3')
shader_paks = shaders_pak_dir.ant_glob('*.pak')
if not shader_paks:
ctx.fatal('[ERROR] No shader pak files were found after running the pak_shaders command')
# copy the shader paks into the layout directory
shader_pak_dest = layout_node.make_node(game.lower())
if not os.path.exists(shader_pak_dest.abspath()):
shader_pak_dest.mkdir()
for pak in shader_paks:
dest_node = shader_pak_dest.make_node(pak.name)
# Make sure there is no existing shader pak first
if os.path.isfile(dest_node.abspath()):
dest_node.delete()
Logs.debug('android_deploy: Copying {} => {}'.format(pak.relpath(), dest_node.relpath()))
shutil.copy2(pak.abspath(), dest_node.abspath())
class pack_apk(Task):
color = 'PINK'
class command_buffer:
def __init__(self, base_command_args):
self._args_master = base_command_args
self._base_len = len(' '.join(base_command_args))
self.args = self._args_master[:]
self.len = self._base_len
def flush(self):
if len(self.args) > len(self._args_master):
Logs.debug('android: Running command - {}'.format(self.args))
call(self.args)
self.args = self._args_master[:]
self.len = self._base_len
def runnable_status(self):
if not self.inputs:
self.source_apk.cache_sig = Utils.h_file(self.source_apk.abspath())
self.inputs = [
self.source_apk
] + self.assets.ant_glob('**/*', excl=['user/'])
result = super(pack_apk, self).runnable_status()
if result == SKIP_ME and not os.path.isfile(self.outputs[0].abspath()):
return RUN_ME
return result
def run(self):
bld = self.generator.bld
source_assets_path = self.assets.abspath()
target_apk_path = self.outputs[0].abspath()
shutil.copy2(self.source_apk.abspath(), target_apk_path)
# We need to make the 'assets' junction in order to generate the correct pathing structure when adding files to an existing APK
android_cache = bld.get_android_cache_node()
asset_junction = android_cache.make_node('assets')
if os.path.exists(asset_junction.abspath()):
remove_junction(asset_junction.abspath())
try:
Logs.debug('android: Creating assets junction "{}" ==> "{}"'.format(source_assets_path, asset_junction.abspath()))
junction_directory(source_assets_path, asset_junction.abspath())
except:
bld.fatal("[ERROR] Could not create junction for asset folder {}".format(source_assets_path))
# add the assets to the APK
command = pack_apk.command_buffer([
bld.env['AAPT'],
'add',
target_apk_path
])
command_len_max = get_command_line_limit()
with push_dir(android_cache.abspath()):
Logs.debug('android: -> from {}'.format(os.getcwd()))
assets = asset_junction.ant_glob('**/*')
for asset in assets:
file_path = asset.path_from(android_cache)
file_path = file_path.replace('\\', '/')
path_len = len(file_path) + 4 # buffer for space and quotes
if (command.len + path_len) >= command_len_max:
command.flush()
command.len += path_len
command.args.append(file_path)
# flush the command buffer one more time
command.flush()
remove_junction(asset_junction.abspath())
class adb_copy_output(Task):
'''
Class to handle copying of a single file in the layout to the android
device.
'''
def __init__(self, *k, **kw):
Task.__init__(self, *k, **kw)
self.device = ''
self.target = ''
def set_device(self, device):
'''Sets the android device (serial number from adb devices command) to copy the file to'''
self.device = device
def set_target(self, target):
'''Sets the target file directory (absolute path) and file name on the device'''
self.target = target
def run(self):
# Embed quotes in src/target so that we can correctly handle spaces
src = '"{}"'.format(self.inputs[0].abspath())
tgt = '"{}"'.format(self.target)
Logs.debug('adb_copy_output: performing copy - {} to {} on device {}'.format(src, tgt, self.device))
adb_call('push', src, tgt, device = self.device)
return 0
def runnable_status(self):
if Task.runnable_status(self) == ASK_LATER:
return ASK_LATER
return RUN_ME
@taskgen_method
def adb_copy_task(self, android_device, src_node, output_target):
'''
Create a adb_copy_output task to copy the src_node to the ouput_target
on the specified device. output_target is the absolute path and file name
of the target file.
'''
copy_task = self.create_task('adb_copy_output', src_node)
copy_task.set_device(android_device)
copy_task.set_target(output_target)
###############################################################################
def wrap_android_install_context(ctx):
sdk_root = ctx.get_env_file_var('LY_ANDROID_SDK', required = True)
global ADB_QUOTED_PATH
ADB_QUOTED_PATH = '"{}"'.format(os.path.join(sdk_root, 'platform-tools', 'adb'))
def get_android_asset_mode(self):
if not hasattr(self, 'android_asset_mode'):
# convert the old option, if specified
asset_mode = self.options.deploy_android_asset_mode
if asset_mode:
Logs.warn('[WARN] Using deprecated option --deploy-android-asset-mode, option has been renamed to --android-asset-mode')
default_option = ASSET_MODE[ASSET_MODE.configuration_default]
old_to_new_option = {
'loose' : 'loose_files',
'paks' : 'loose_paks',
'project_settings' : 'project_settings',
}
asset_mode = old_to_new_option.get(asset_mode.lower(), default_option)
else:
asset_mode = self.options.android_asset_mode.lower()
if asset_mode not in ASSET_MODE:
self.fatal('[ERROR] Unable to determine the android asset mode. Valid options for --android-asset-mode are limited to: {}'.format(ASSET_MODE))
config = self.env['CONFIGURATION']
configuration_settings = LUMBERYARD_SETTINGS.get_root_configuration_name(config)
root_configuration = configuration_settings.base_config.name if configuration_settings.base_config else configuration_settings.name
config_to_mode = {
'debug' : ASSET_MODE.loose_files,
'profile' : ASSET_MODE.loose_files,
'release' : ASSET_MODE.project_settings,
'performance' : ASSET_MODE.project_settings,
}
if self.options.from_android_studio:
# force a mode where assets are not packed into the APK since android studio handles the APK generation
asset_mode = (ASSET_MODE.loose_paks if self.paks_required else ASSET_MODE.loose_files)
elif ASSET_MODE.index(asset_mode) == ASSET_MODE.configuration_default or self.paks_required:
asset_mode = config_to_mode[root_configuration]
else:
asset_mode = ASSET_MODE.index(asset_mode)
self.android_asset_mode = asset_mode
return self.android_asset_mode
def use_obb(self):
if not hasattr(self, 'cached_use_obb'):
game = self.get_bootstrap_game_folder()
use_main_obb = (self.get_android_use_main_obb(game).lower() == 'true')
use_patch_obb = (self.get_android_use_patch_obb(game).lower() == 'true')
self.cached_use_obb = (use_main_obb or use_patch_obb)
return self.cached_use_obb
def get_layout_node(self):
if not hasattr(self, 'android_layout_node'):
game = self.get_bootstrap_game_folder()
asset_mode = self.get_android_asset_mode()
if asset_mode in (ASSET_MODE.loose_files, ASSET_MODE.apk_files):
self.android_layout_node = self.get_assets_cache_node()
elif asset_mode in (ASSET_MODE.loose_paks, ASSET_MODE.apk_paks):
paks_cache = '{}_paks'.format(self.assets_cache_path)
self.android_layout_node = self.path.make_node(paks_cache)
elif asset_mode == ASSET_MODE.project_settings:
cache_folder = '{}_{}'.format(self.assets_cache_path, 'obb' if self.use_obb() else 'paks')
self.android_layout_node = self.path.make_node(cache_folder)
# just in case get_layout_node is called before deploy_android_asset_mode has been validated, which
# could mean android_layout_node never getting set
try:
return self.android_layout_node
except:
self.fatal('[ERROR] Unable to determine the asset layout node for Android.')
def user_message(self, message):
Logs.pprint('CYAN', '[INFO] {}'.format(message))
def log_error(self, message):
if self.options.from_editor_deploy or self.options.from_android_studio:
self.fatal(message)
else:
Logs.error(message)
ctx.use_obb = MethodType(use_obb, ctx)
ctx.get_android_asset_mode = MethodType(get_android_asset_mode, ctx)
ctx.get_layout_node = MethodType(get_layout_node, ctx)
ctx.user_message = MethodType(user_message, ctx)
ctx.log_error = MethodType(log_error, ctx)
return ctx
@feature('package_android_armv8_clang')
def package_android(tsk_gen):
'''
Prepare the deploy process by generating the necessary asset layout
directories, pak / obb files and packing assets in the APK if necessary.
'''
bld = wrap_android_install_context(tsk_gen.bld)
platform = bld.env['PLATFORM']
configuration = bld.env['CONFIGURATION']
if not bld.is_android_platform(platform):
return
game = bld.project
if game not in bld.get_enabled_game_project_list():
Logs.warn('[WARN] The game project specified in bootstrap.cfg - {} - is not in the '
'enabled game projects list. Skipping Android deployment...'.format(game))
return
# determine the selected asset deploy mode
asset_mode = bld.get_android_asset_mode()
Logs.debug('android_package: Using asset mode - {}'.format(ASSET_MODE[asset_mode]))
assets_platform = bld.assets_platform
if bld.get_assets_cache_node() is None:
assets_dir = bld.assets_cache_path
output_folders = [ folder.name for folder in bld.get_standard_host_output_folders() ]
raise Errors.WafError('[ERROR] There is no asset cache to read from at "{}". Please run AssetProcessor or '
'AssetProcessorBatch from {} with "{}" assets enabled in the '
'AssetProcessorPlatformConfig.ini'.format(assets_dir, '|'.join(output_folders), assets_platform))
assets_cache = bld.get_assets_cache_node()
layout_node = bld.get_layout_node()
if not os.path.exists(layout_node.abspath()):
layout_node.mkdir()
# clear all stale transient files (e.g. logs, shader source, etc.) from the source asset cache
bld.clear_user_cache(assets_cache)
# clear all stale transient files (e.g. logs, shader source/paks, etc.) from the pak file cache
bld.clear_user_cache(layout_node)
bld.clear_shader_cache(layout_node)
is_obb = bld.use_obb()
# generate shaders for release builds
should_generate_shaders = ((asset_mode == ASSET_MODE.project_settings) or (asset_mode == ASSET_MODE.apk_files and configuration == 'release'))
if should_generate_shaders:
# don't generate pak files for obb or apk_files mode.
generate_pak = False
if not (is_obb or asset_mode == ASSET_MODE.apk_files):
generate_pak = True
build_shaders(bld, game, assets_platform, layout_node, assets_cache, generate_pak)
# handle the asset pre-processing
if asset_mode in (ASSET_MODE.loose_paks, ASSET_MODE.apk_paks, ASSET_MODE.project_settings):
if bld.use_vfs():
bld.fatal('[ERROR] Cannot use VFS when the --android-asset-mode is set to "{}". Please set remote_filesystem=0 in bootstrap.cfg'.format(ASSET_MODE[asset_mode]))
use_project_settings = (asset_mode == ASSET_MODE.project_settings)
if is_obb and not use_project_settings:
bld.fatal('[ERROR] Cannot use OBBs when the --android-asset-mode is set to "{}". Either change --android-asset-mode to '
'"configuration_default" or update the "use_main_obb"|"use_patch_obb" settings the game\'s project.json file.'.format(ASSET_MODE[asset_mode]))
# generate the pak/obb files
rc_job_file = bld.get_android_rc_obb_job(game) if is_obb else bld.get_android_rc_pak_job(game)
bld.user_message('Generating the necessary pak files...')
packaging.run_rc_job(
ctx=bld,
game=game,
job=rc_job_file,
assets_platform=assets_platform,
source_path=assets_cache.relpath(),
target_path=layout_node.relpath(),
is_obb = is_obb
)
# pack the assets into the apk
if asset_mode in (ASSET_MODE.apk_files, ASSET_MODE.apk_paks, ASSET_MODE.project_settings):
# get the keystore passwords
set_key_and_store_pass(bld)
# generate the new apk with assets in it
executable_name = bld.get_executable_name(game)
variant = getattr(bld.__class__, 'variant', None)
if not variant:
(platform, configuration) = bld.get_platform_and_configuration()
variant = '{}_{}'.format(platform, configuration)
apk_out_root = bld.get_bintemp_folder_node().make_node([variant, bld.get_android_project_relative_path(), executable_name, 'apk'])
pack_apk_task = tsk_gen.create_task('pack_apk')
pack_apk_task.source_apk = apk_out_root.make_node('{}_unaligned_unsigned.apk'.format(executable_name))
pack_apk_task.assets = layout_node
tgt_apk_node = apk_out_root.make_node('{}_unaligned_unsigned{}.apk'.format(executable_name, APK_WITH_ASSETS_SUFFIX))
pack_apk_task.set_outputs(tgt_apk_node)
# sign and align the apk
final_apk_out = bld.get_output_folders(platform, configuration)[0]
tsk_gen.sign_and_align_apk(
base_apk_name = executable_name,
raw_apk = tgt_apk_node,
intermediate_folder = apk_out_root,
final_output = final_apk_out,
suffix = APK_WITH_ASSETS_SUFFIX
)
tsk_gen.jarsign_task.set_run_after(pack_apk_task)
@feature('deploy_android_armv8_clang')
def deploy_android(tsk_gen):
'''
Installs the project APK and copies the layout directory to all the
android devices that are connected to the host.
'''
def should_copy_file(src_file_node, target_time):
should_copy = False
try:
stat_src = os.stat(src_file_node.abspath())
should_copy = stat_src.st_mtime >= target_time
except OSError:
pass
return should_copy
bld = wrap_android_install_context(tsk_gen.bld)
platform = bld.env['PLATFORM']
configuration = bld.env['CONFIGURATION']
if not bld.is_android_platform(platform):
return
# ensure the adb server is running
if adb_call('start-server') is None:
bld.log_error('[ERROR] Failed to start adb server, unable to perform the deploy')
return
connected_devices = get_list_of_android_devices()
if not connected_devices:
adb_call('kill-server')
if bld.options.from_editor_deploy:
bld.fatal('[ERROR] No Android devices detected, unable to deploy')
else:
Logs.warn('[WARN] No Android devices detected, skipping deployment...')
return
game = bld.project
executable_name = bld.get_executable_name(game)
asset_mode = bld.get_android_asset_mode()
# create the path to the APK
suffix = ''
if asset_mode in (ASSET_MODE.apk_files, ASSET_MODE.apk_paks, ASSET_MODE.project_settings):
suffix = APK_WITH_ASSETS_SUFFIX
output_folder = bld.get_output_folders(platform, configuration)[0]
apk_name = '{}/{}{}.apk'.format(output_folder.abspath(), executable_name, suffix)
layout_node = bld.get_layout_node()
do_clean = bld.is_option_true('deploy_android_clean_device')
deploy_executable = bld.is_option_true('deploy_android_executable') and not bld.options.from_android_studio
if bld.use_vfs():
if asset_mode == ASSET_MODE.loose_files:
deploy_assets = True
else:
# this is already checked in the prepare stage, but just to be safe...
bld.fatal('[ERROR] Cannot use VFS when the --android-asset-mode is set to "{}". Please set remote_filesystem=0 in bootstrap.cfg'.format(ASSET_MODE[asset_mode]))
else:
deploy_assets = asset_mode in (ASSET_MODE.loose_files, ASSET_MODE.loose_paks)
# no sense in pushing the assets if we are cleaning the device and not reinstalling the APK from normal command line
if do_clean and not deploy_executable and not bld.options.from_android_studio:
deploy_assets = False
Logs.debug('android_deploy: deploy options: do_clean {}, deploy_exec {}, deploy_assets {}'.format(do_clean, deploy_executable, deploy_assets))
if deploy_executable and not os.path.exists(apk_name):
bld.fatal('[ERROR] Could not find the Android executable (APK) in path - {} - necessary for deployment. Run the build command for {}_{} to generate it'.format(apk_name, platform, configuration))
return
deploy_libs = (bld.options.deploy_android_attempt_libs_only
and not (do_clean or bld.options.from_editor_deploy or bld.options.from_android_studio)
and asset_mode not in (ASSET_MODE.apk_files, ASSET_MODE.apk_paks, ASSET_MODE.project_settings))
Logs.debug('android_deploy: The option to attempt library only deploy is %s', 'ENABLED' if deploy_libs else 'DISABLED')
variant = '{}_{}'.format(platform, configuration)
apk_builder_path = os.path.join( variant, bld.get_android_project_relative_path(), executable_name, 'builder' )
apk_builder_node = bld.get_bintemp_folder_node().make_node(apk_builder_path)
abi_func = getattr(bld, 'get_%s_target_abi' % platform, None)
lib_paths = ['lib'] + [ abi_func() ] if abi_func else [] # since we don't support 'fat' apks it's ok to not have the abi specifier but it's still preferred
stripped_libs_node = apk_builder_node.make_node(lib_paths)
game_package = bld.get_android_package_name(game)
device_install_path = '/data/data/{}'.format(game_package)
if deploy_libs:
apk_stat = os.stat(apk_name)
apk_size = apk_stat.st_size
relative_assets_path = construct_assets_path_for_game_project(bld, game)
# This is the name of a file that we will use as a marker/timestamp. We
# will get the timestamp of the file off the device and compare that with
# asset files on the host machine to determine if the host machine asset
# file is newer than what the device has, and if so copy it to the device.
timestamp_file_name = 'deploy.timestamp'
target_devices = []
device_filter = bld.options.deploy_android_device_filter
if not device_filter:
target_devices = connected_devices
else:
device_list = device_filter.split(',')
for device_id in device_list:
device_id = device_id.strip()
if device_id not in connected_devices:
Logs.warn('[WARN] Android device ID - {} - in device filter not detected as connected'.format(device_id))
else:
target_devices.append(device_id)
deploy_count = 0
for android_device in target_devices:
bld.user_message('Starting to deploy to android device ' + android_device)
storage_path = auto_detect_device_storage_path(android_device, log_warnings = True)
if not storage_path:
continue
output_target = '{}/{}'.format(storage_path, relative_assets_path)
device_timestamp_file = '{}/{}'.format(output_target, timestamp_file_name)
if do_clean:
bld.user_message('Cleaning target before deployment...')
adb_call('shell', 'rm', '-rf', output_target, device = android_device)
bld.user_message('Target Cleaned...')
package_name = bld.get_android_package_name(game)
if len(package_name) != 0:
bld.user_message('Uninstalling package ' + package_name)
adb_call('uninstall', package_name, device = android_device)
################################
if deploy_libs:
access_type = get_device_access_type(android_device)
if access_type == ACCESS_NORMAL:
Logs.warn('[WARN] android_deploy: Unable to perform the library only copy on device {}'.format(android_device))
elif access_type in (ACCESS_ROOT_ADBD, ACCESS_SHELL_SU):
device_file_staging_path = '{}/LY_Staging'.format(storage_path)
device_lib_timestamp_file = '{}/files/{}'.format(device_install_path, timestamp_file_name)
def _adb_push(source_node, dest, device_id):
adb_call('push', '"{}"'.format(source_node.abspath()), dest, device = device_id)
def _adb_shell(source_node, dest, device_id):
temp_dest = '{}/{}'.format(device_file_staging_path, source_node.name)
adb_call('push', '"{}"'.format(source_node.abspath()), temp_dest, device = device_id)
adb_call('shell', 'su', '-c', 'cp', temp_dest, dest, device = device_id)
if access_type == ACCESS_ROOT_ADBD:
adb_root_push_func = _adb_push
elif access_type == ACCESS_SHELL_SU:
adb_root_push_func = _adb_shell
adb_call('shell', 'mkdir', device_file_staging_path)
install_check = adb_call('shell', '"pm list packages | grep {}"'.format(game_package), device = android_device)
if install_check:
target_time = get_device_file_timestamp(device_lib_timestamp_file, android_device, True)
# cases for early out in favor of re-installing the APK:
# If target_time is zero, the file wasn't found which would indicate we haven't attempt to push just the libs before
# The dalvik executable is newer than the last time we deployed to this device
if target_time == 0 or should_copy_file(apk_builder_node.make_node('classes.dex'), target_time):
bld.user_message('A new APK needs to be installed instead for device {}'.format(android_device))
# otherwise attempt to copy the libs directly
else:
bld.user_message('Scanning which libraries need to be copied...')
libs_to_add = []
total_libs_size = 0
fallback_to_apk = False
libs = stripped_libs_node.ant_glob('**/*.so')
for lib in libs:
if should_copy_file(lib, target_time):
lib_stat = os.stat(lib.abspath())
total_libs_size += lib_stat.st_size
libs_to_add.append(lib)
if total_libs_size >= apk_size:
bld.user_message('Too many libriares changed, falling back to installing a new APK on {}'.format(android_device))
fallback_to_apk = True
break
if not fallback_to_apk:
for lib in libs_to_add:
final_target_dir = '{}/lib/{}'.format(device_install_path, lib.name)
adb_root_push_func(lib, final_target_dir, android_device)
adb_call('shell', 'su', '-c', 'chown', LIB_OWNER_GROUP, final_target_dir, device = android_device)
adb_call('shell', 'su', '-c', 'chmod', LIB_FILE_PERMISSIONS, final_target_dir, device = android_device)
deploy_executable = False
update_device_file_timestamp(device_lib_timestamp_file, android_device, as_root = True)
# clean up the staging directory
if access_type == ACCESS_SHELL_SU:
adb_call('shell', 'rm', '-rf', device_file_staging_path)
################################
if deploy_executable:
install_options = getattr(Options.options, 'deploy_android_install_options')
replace_package = ''
if bld.is_option_true('deploy_android_replace_apk'):
replace_package = '-r'
bld.user_message('Installing ' + apk_name)
install_result = adb_call('install', install_options, replace_package, '"{}"'.format(apk_name), device = android_device)
if not install_result or 'success' not in install_result.lower():
Logs.warn('[WARN] android deploy: failed to install APK on device %s.' % android_device)
if install_result:
# The error msg is the last non empty line of the output.
error_msg = next(error for error in reversed(install_result.splitlines()) if error)
Logs.warn('[WARN] android deploy: %s' % error_msg)
continue
if deploy_assets:
if bld.use_vfs():
for file in bld.get_bootstrap_files():
local_node = layout_node.find_resource(file)
if not local_node:
bld.fatal('[ERROR] Failed to locate file {} in path {}'.format(os.path.normpath(file), layout_node.abspath()))
target_file = '{}/{}'.format(output_target, str.replace(local_node.path_from(layout_node), '\\', '/'))
tsk_gen.adb_copy_task(android_device, local_node, target_file)
else:
target_time = get_device_file_timestamp(device_timestamp_file, android_device)
if do_clean or target_time == 0:
bld.user_message('Copying all assets to the device {}. This may take some time...'.format(android_device))
# There is a chance that if someone ran VFS before this deploy on an empty directory the output_target directory will have
# already existed when we do the push command. In this case if we execute adb push command it will create an ES3 directory
# and put everything there, causing the deploy to be 'successful' but the game will crash as it won't be able to find the
# assets. Since we detected a "clean" build, wipe out the output_target folder if it exists first then do the push and
# everything will be just fine.
ls_status, _ = adb_ls(output_target, android_device)
if ls_status:
adb_call('shell', 'rm', '-rf', output_target)
push_status = adb_call('push', '"{}"'.format(layout_node.abspath()), output_target, device = android_device)
if not push_status:
# Clean up any files we may have pushed to make the next run success rate better
adb_call('shell', 'rm', '-rf', output_target, device = android_device)
bld.fatal('[ERROR] The ABD command to push all the files to the device failed.')
continue
else:
layout_files = layout_node.ant_glob('**/*')
bld.user_message('Scanning {} files to determine which ones need to be copied...'.format(len(layout_files)))
for src_file in layout_files:
# Faster to check if we should copy now rather than in the copy_task
if should_copy_file(src_file, target_time):
final_target_dir = '{}/{}'.format(output_target, str.replace(src_file.path_from(layout_node), '\\', '/'))
tsk_gen.adb_copy_task(android_device, src_file, final_target_dir)
update_device_file_timestamp(device_timestamp_file, android_device)
deploy_count = deploy_count + 1
if not deploy_count:
bld.fatal('[ERROR] Failed to deploy the build to any connected devices.')
@conf_event(after_methods=['load_compile_rules_for_enabled_platforms'],
after_events=['inject_generate_uber_command', 'inject_generate_module_def_command', 'inject_msvs_command'])
def inject_android_studio_command(conf):
"""
Make sure the android studio generation command is injected
"""
if not isinstance(conf, ConfigurationContext):
return
# Android target platform commands
enabled_platform_names = [platform.name() for platform in conf.get_all_target_platforms()]
if any(platform for platform in enabled_platform_names if conf.is_android_platform(platform)):
# build the base Android projects required for generating their respective APKs
android_builder_func = getattr(conf, 'create_base_android_projects', None)
if not android_builder_func:
conf.fatal('[ERROR] Failed to find required Android builder function - create_base_android_projects')
if not android_builder_func():
conf.fatal('[ERROR] Failed to generate the base projects required to build Android')
# rebuild the project if invoked from android studio or sepcifically requested to do so
if conf.options.from_android_studio or conf.is_option_true('generate_android_studio_projects_automatically'):
if 'build' in Options.commands:
build_cmd_idx = Options.commands.index('build')
Options.commands.insert(build_cmd_idx, 'android_studio')
else:
Options.commands.append('android_studio')
@lumberyard.multi_conf
def check_ib_flag(ctx, bld_cmd, ib_result, dev_tool_accelerated):
# Android builds need the Dev Tools Acceleration Package in order to use the profile.xml to specify how tasks are distributed
if 'android' in bld_cmd:
if not dev_tool_accelerated:
Logs.warn('Dev Tool Acceleration Package not found! This is required in order to use Incredibuild for Android. Build will not be accelerated through Incredibuild.')
return False
return True