You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
o3de/cmake/Tools/Platform/Android/android_support.py

1636 lines
82 KiB
Python

#
# 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.
#
import imghdr
import datetime
import logging
import os
import json
import platform
import re
import shutil
import stat
import string
import sys
import subprocess
import pathlib
from distutils.version import LooseVersion
# Resolve the common python module
ROOT_DEV_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '..'))
if ROOT_DEV_PATH not in sys.path:
sys.path.append(ROOT_DEV_PATH)
from cmake.Tools import common
APP_NAME = 'app'
ANDROID_MANIFEST_FILE = 'AndroidManifest.xml'
ANDROID_LIBRARIES_JSON_FILE = 'android_libraries.json'
BUILD_CONFIGURATIONS = ['Debug', 'Profile', 'Release']
# We currently only support arm64-v8a
ANDROID_ARCH = 'arm64-v8a'
ANDROID_RESOLUTION_SETTINGS = ('mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi')
DEFAULT_CONFIG_CHANGES = [
'keyboard',
'keyboardHidden',
'orientation',
'screenSize',
'smallestScreenSize',
'screenLayout',
'uiMode',
]
TEST_RUNNER_PROJECT = 'AzTestRunner'
TEST_RUNNER_PACKAGE_NAME = 'com.lumberyard.tests'
# Android Orientation Constants
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',
}
ORIENTATION_MAPPING = {
'landscape': ORIENTATION_LANDSCAPE,
'reverseLandscape': ORIENTATION_LANDSCAPE,
'sensorLandscape': ORIENTATION_LANDSCAPE,
'userLandscape': ORIENTATION_LANDSCAPE,
'portrait': ORIENTATION_PORTRAIT,
'reversePortrait': ORIENTATION_PORTRAIT,
'sensorPortrait': ORIENTATION_PORTRAIT,
'userPortrait': ORIENTATION_PORTRAIT
}
MIPMAP_PATH_PREFIX = 'mipmap'
APP_ICON_NAME = 'app_icon.png'
APP_SPLASH_NAME = 'app_splash.png'
PYTHON_SCRIPT = 'python.cmd' if platform.system() == 'Windows' else 'python.sh'
ANDROID_LAUNCHER_NAME_PATTERN = "{project_name}.GameLauncher"
class AndroidProjectManifestEnvironment(object):
"""
This class manages the environment for the AndroidManifiest.xml template file, based on project settings and environments
that were passed in or calculated from the command line arguments.
"""
def __init__(self, engine_root, project_path, android_sdk_version_number, android_ndk_platform_number, is_test:bool):
"""
Initialize the object with the project specific parameters and values for the game project
:param engine_root: The path where the engine is located
:param project_path: The path were the project is located
:param android_sdk_version_number: The android SDK platform version
:param android_ndk_platform_number: The android NDK platform version
:param is_test: Indicates if theAzTestRunner application should be run
"""
if is_test:
# The AzTestRunner project.json is located under {engine_root}/Code/Tools/AzTestRunner/Platform/Android/android_project.json
project_properties_path = engine_root / 'Code' / 'Tools' / 'AzTestRunner' / 'Platform' / 'Android' / 'android_project.json'
else:
# The project.json file is located under the game name folder
project_properties_path = project_path / 'project.json'
# Read and parse the project.json file into a dictionary to process the specific attributes needed for the manifest template
project_properties_content = project_properties_path.resolve(strict=True)\
.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
self.project_path = project_path
# Extract the key attributes we need to process and build up our environment table
project_json = json.loads(project_properties_content)
project_name = project_json.get('project_name')
if not project_name:
raise common.LmbrCmdError(f"Missing required 'project_name' from project.json for project at '{str(project_path)}'")
product_name = project_json.get('product_name', project_name)
game_project_android_settings = project_json['android_settings']
package_name = game_project_android_settings["package_name"]
package_path = package_name.replace('.', '/')
project_activity = f'{TEST_RUNNER_PROJECT}Activity' if is_test else f'{project_name}Activity'
# Multiview options require special processing
multi_window_options = AndroidProjectManifestEnvironment.process_android_multi_window_options(game_project_android_settings)
self.internal_dict = {
'ANDROID_PACKAGE': package_name,
'ANDROID_PACKAGE_PATH': package_path,
'ANDROID_VERSION_NUMBER': game_project_android_settings["version_number"],
"ANDROID_VERSION_NAME": game_project_android_settings["version_name"],
"ANDROID_SCREEN_ORIENTATION": game_project_android_settings["orientation"],
'ANDROID_APP_NAME': TEST_RUNNER_PROJECT if is_test else product_name, # external facing name
'ANDROID_PROJECT_NAME': TEST_RUNNER_PROJECT if is_test else project_name, # internal facing name
'ANDROID_PROJECT_ACTIVITY': project_activity,
'ANDROID_LAUNCHER_NAME': TEST_RUNNER_PROJECT if is_test else ANDROID_LAUNCHER_NAME_PATTERN.format(project_name=project_name),
'ANDROID_CONFIG_CHANGES': multi_window_options['ANDROID_CONFIG_CHANGES'],
'ANDROID_APP_PUBLIC_KEY': game_project_android_settings.get('app_public_key', 'NoKey'),
'ANDROID_APP_OBFUSCATOR_SALT': game_project_android_settings.get('app_obfuscator_salt', ''),
'ANDROID_USE_MAIN_OBB': game_project_android_settings.get('use_main_obb', 'false'),
'ANDROID_USE_PATCH_OBB': game_project_android_settings.get('use_patch_obb', 'false'),
'ANDROID_ENABLE_KEEP_SCREEN_ON': game_project_android_settings.get('enable_keep_screen_on', 'false'),
'ANDROID_DISABLE_IMMERSIVE_MODE': game_project_android_settings.get('disable_immersive_mode', 'false'),
'ANDROID_MIN_SDK_VERSION': android_ndk_platform_number,
'ANDROID_TARGET_SDK_VERSION': android_sdk_version_number,
'ICONS': game_project_android_settings.get('icons', None),
'SPLASH_SCREEN': game_project_android_settings.get('splash_screen', None),
'ANDROID_MULTI_WINDOW': multi_window_options['ANDROID_MULTI_WINDOW'],
'ANDROID_MULTI_WINDOW_PROPERTIES': multi_window_options['ANDROID_MULTI_WINDOW_PROPERTIES'],
'SAMSUNG_DEX_KEEP_ALIVE': multi_window_options['SAMSUNG_DEX_KEEP_ALIVE'],
'SAMSUNG_DEX_LAUNCH_WIDTH': multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'],
'SAMSUNG_DEX_LAUNCH_HEIGHT': multi_window_options['SAMSUNG_DEX_LAUNCH_HEIGHT']
}
def __getitem__(self, item):
return self.internal_dict.get(item)
@staticmethod
def process_android_multi_window_options(game_project_android_settings):
"""
Perform custom processing for game projects that have custom 'multi_window_options' in their project.json definition
:param game_project_android_settings: The parsed out android settings from the game's project.json
:return: Dictionary of attributes for any optional multiview option detected from the android settings
"""
def is_number_option_valid(value, name):
if value:
if isinstance(value, int):
return True
else:
logging.warning('[WARN] Invalid value for property "%s", expected whole number', name)
return False
def get_int_attribute(settings, key_name):
settings_value = settings.get(key_name, None)
if not settings_value:
return None
if not isinstance(settings_value, int):
logging.warning('[WARN] Invalid value for property "%s", expected whole number', key_name)
return None
return settings_value
multi_window_options = {
'SAMSUNG_DEX_LAUNCH_WIDTH': '',
'SAMSUNG_DEX_LAUNCH_HEIGHT': '',
'SAMSUNG_DEX_KEEP_ALIVE': '',
'ANDROID_CONFIG_CHANGES': '|'.join(DEFAULT_CONFIG_CHANGES),
'ANDROID_MULTI_WINDOW_PROPERTIES': '',
'ANDROID_MULTI_WINDOW': '',
'ORIENTATION': ORIENTATION_ALL
}
multi_window_settings = game_project_android_settings.get('multi_window_options', None)
if not multi_window_settings:
# If there are no multi-window options, then set the orientation to the orientation attribute if set, otherwise use the default 'ALL' orientation
requested_orientation = game_project_android_settings['orientation']
multi_window_options['ORIENTATION'] = ORIENTATION_MAPPING.get(requested_orientation, ORIENTATION_ALL)
return multi_window_options
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 = get_int_attribute(samsung_dex_options, 'launch_width')
launch_height = get_int_attribute(samsung_dex_options, 'launch_height')
# both have to be specified otherwise they are ignored
if launch_width and launch_height:
multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'] = (f'<meta-data '
f'android:name="com.samsung.android.sdk.multiwindow.dex.launchwidth" '
f'android:value="{launch_width}"'
f'/>')
multi_window_options['SAMSUNG_DEX_LAUNCH_HEIGHT'] = (f'<meta-data '
f'android:name="com.samsung.android.sdk.multiwindow.dex.launchheight" '
f'android:value="{launch_height}"'
f'/>')
keep_alive = samsung_dex_options.get('keep_alive', None)
if keep_alive in (True, False):
multi_window_options['SAMSUNG_DEX_KEEP_ALIVE'] = f'<meta-data ' \
f'android:name="com.samsung.android.keepalive.density" ' \
f'android:value="{str(keep_alive).lower()}" ' \
f'/>'
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
multi_window_options['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
multi_window_options['ANDROID_MULTI_WINDOW'] = f'android:resizeableActivity="{str(multi_window_enabled).lower()}"'
if not multi_window_enabled:
return multi_window_options
# remove the DEX launch window size if requested to launch in fullscreen mode
if launch_in_fullscreen:
multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'] = ''
multi_window_options['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 = '<layout '
# the default width/height values are respected as launch values in DEX mode so they should
# be ignored if the intention is to launch in fullscreen when running in DEX mode
if not launch_in_fullscreen:
if is_number_option_valid(default_width, 'default_width'):
layout += f'android:defaultWidth="{default_width}dp" '
if is_number_option_valid(default_height, 'default_height'):
layout += f'android:defaultHeight="{default_height}dp" '
if is_number_option_valid(min_height, 'min_height'):
layout += f'android:minHeight="{min_height}dp" '
if is_number_option_valid(min_width, 'min_width'):
layout += f'android:minWidth="{min_width}dp" '
if gravity:
layout += f'android:gravity="{gravity}" '
layout += '/>'
multi_window_options['ANDROID_MULTI_WINDOW_PROPERTIES'] = layout
return multi_window_options
PLATFORM_SETTINGS_FORMAT = """
# Auto Generated from last cmake project generation ({generation_timestamp})
[settings]
platform={platform}
game_projects={project_path}
asset_deploy_mode={asset_mode}
asset_deploy_type={asset_type}
[android]
android_sdk_path={android_sdk_path}
embed_assets_in_apk={embed_assets_in_apk}
is_unit_test={is_unit_test}
"""
NATIVE_CMAKE_SECTION_ANDROID_FORMAT = """
externalNativeBuild {{
cmake {{
buildStagingDirectory "."
version "{cmake_version}"
path "{absolute_cmakelist_path}"
}}
}}
"""
NATIVE_CMAKE_SECTION_DEFAULT_CONFIG_NDK_FORMAT_STR = """
ndk {{
abiFilters '{abi}'
}}
"""
NATIVE_CMAKE_SECTION_BUILD_TYPE_CONFIG_FORMAT_STR = """
externalNativeBuild {{
cmake {{
{targets_section}
arguments {arguments}
}}
}}
"""
CUSTOM_GRADLE_COPY_NATIVE_CONFIG_FORMAT_STR = """
task copyNativeLibs{config}(type: Copy) {{
delete 'outputs/native-lib/{abi}'
from fileTree(dir: 'build/intermediates/cmake/{config_lower}/obj/arm64-v8a/{config_lower}', include: '**/*.so', exclude: 'lib{project_name}.GameLauncher.so' )
into 'outputs/native-lib/{abi}'
}}
compile{config}Sources.dependsOn copyNativeLibs{config}
copyNativeLibs{config}.mustRunAfter {{
tasks.findAll {{ task->task.name.contains('externalNativeBuild{config}') }}
}}
"""
CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_FORMAT_STR = """
task copyNativeArtifacts{config}(type: Copy) {{
from fileTree(dir: 'build/intermediates/cmake/{config_lower}/obj/arm64-v8a/{config_lower}', include: '{file_includes}' )
into '{asset_layout_folder}'
}}
compile{config}Sources.dependsOn copyNativeArtifacts{config}
copyNativeArtifacts{config}.mustRunAfter {{
tasks.findAll {{ task->task.name.contains('externalNativeBuild{config}') }}
}}
"""
CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR = """
task syncLYLayoutMode{config}(type:Exec) {{
workingDir '{working_dir}'
commandLine '{python_full_path}', 'layout_tool.py', '--project-path', '{project_path}', '-p', 'Android', '-a', '{asset_type}', '-m', '{asset_mode}', '--create-layout-root', '-l', '{asset_layout_folder}'
}}
compile{config}Sources.dependsOn syncLYLayoutMode{config}
"""
PROJECT_DEPENDENCIES_VALUE_FORMAT = """
dependencies {{
{dependencies}
api 'androidx.core:core:1.1.0'
}}
"""
OVERRIDE_JAVA_SOURCESET_STR = """
java {{
srcDirs = ['{absolute_azandroid_path}', 'src/main/java']
}}
"""
class AndroidSigningConfig(object):
"""
Class to manage android signing configs
"""
def __init__(self, store_file, store_password, key_alias, key_password):
if not store_file:
raise common.LmbrCmdError(f"Keystore file not supplied for signing configuration",
common.ERROR_CODE_INVALID_PARAMETER)
if not os.path.isfile(store_file):
raise common.LmbrCmdError(f"Missing/Invalid keystore file {store_file} for signing config",
common.ERROR_CODE_INVALID_PARAMETER)
self.store_file = store_file.replace('\\', '/')
if not store_password:
raise common.LmbrCmdError(f"Keystore password not supplied for signing configuration",
common.ERROR_CODE_INVALID_PARAMETER)
self.store_password = store_password
if not key_alias:
raise common.LmbrCmdError(f"Signing key alias not supplied for signing configuration",
common.ERROR_CODE_INVALID_PARAMETER)
self.key_alias = key_alias
if not key_password:
raise common.LmbrCmdError(f"Signing key password not supplied for signing configuration",
common.ERROR_CODE_INVALID_PARAMETER)
self.key_password = key_password
def to_template_string(self, tabs):
tab_prefix = ' '*4*tabs
return f"""
{tab_prefix}storeFile file('{self.store_file}')
{tab_prefix}storePassword '{self.store_password}'
{tab_prefix}keyPassword '{self.key_password}'
{tab_prefix}keyAlias '{self.key_alias}'"""
class AndroidProjectGenerator(object):
"""
Class the manages the process to generate an android project folder in order to build with gradle/android studio
"""
def __init__(self, engine_root, build_dir, android_ndk_path, android_sdk_path, android_sdk_version, android_ndk_platform,
project_path, third_party_path, cmake_version, override_cmake_path, override_gradle_path, override_ninja_path,
android_sdk_build_tool_version, include_assets_in_apk, asset_mode, asset_type, signing_config, is_test_project=False,
overwrite_existing=True):
"""
Initialize the object with all the required parameters needed to create an Android Project. The parameters should be verified before initializing this object
:param engine_root: The engine root that contains the engine
:param build_dir: The target folder under the where the android project folder will be created
:param android_ndk_path: The path to the ANDROID_NDK used for building the native android code
:param android_sdk_path: The path to the ANDROID_SDK used for building the android java code
:param android_sdk_version: The android platform version number to use for the Android SDK related builds
:param android_ndk_platform: The android platform version number to use for the Android NDK related builds
:param project_path: The path to the project
:param third_party_path: The required path to the lumberyard 3rd party path
:param cmake_version: The version number of cmake that will be used by gradle
:param override_cmake_path: The override path to cmake if it does not exists in the system path
:param override_gradle_path: The override path to gradle if it does not exists in the system path
:param override_ninja_path: The override path to ninja if it does not exists in the system path
:param android_sdk_build_tool_version: The preferred android SDK build-tool version. Will default to the first one detected in the android sdk path
:param include_assets_in_apk:
:param asset_mode:
:param asset_type:
:param signing_config: Optional signing configuration arguments
:param is_test_project: Flag to indicate if this is a unit test runner project. (If true, project_path, asset_mode, asset_type, and include_assets_in_apk are ignored)
:param overwrite_existing: Flag to overwrite existing project files when being generated, or skip if they already exist.
"""
self.env = {}
self.engine_root = engine_root
self.build_dir = build_dir
self.android_ndk_path = android_ndk_path
self.android_sdk_path = android_sdk_path
self.android_project_builder_path = self.engine_root / 'Code/Tools/Android/ProjectBuilder'
self.android_sdk_version = android_sdk_version
self.android_sdk_build_tool_version = android_sdk_build_tool_version
self.android_ndk_platform = android_ndk_platform
self.project_path = project_path
self.third_party_path = third_party_path
self.cmake_version = cmake_version
self.override_cmake_path = override_cmake_path
self.override_gradle_path = override_gradle_path
self.override_ninja_path = override_ninja_path
self.include_assets_in_apk = include_assets_in_apk
self.asset_mode = asset_mode
self.asset_type = asset_type
self.signing_config = signing_config
self.is_test_project = is_test_project
self.overwrite_existing = overwrite_existing
def execute(self):
"""
Execute the android project creation workflow
"""
# Prepare the working build directory
self.build_dir.mkdir(parents=True, exist_ok=True)
self.create_platform_settings()
self.create_default_local_properties()
project_names = self.patch_and_transfer_android_libs()
project_names.extend(self.create_lumberyard_app(project_names))
root_gradle_env = {
'SDK_VER': self.android_sdk_version,
'NDK_PLATFORM_VER': self.android_ndk_platform,
'SDK_BUILD_TOOL_VER': self.android_sdk_build_tool_version,
'LY_ENGINE_ROOT': common.normalize_path_for_settings(self.engine_root)
}
# Generate the gradle build script
self.create_file_from_project_template(src_template_file='root.build.gradle.in',
template_env=root_gradle_env,
dst_file=self.build_dir / 'build.gradle')
self.write_settings_gradle(project_names)
self.prepare_gradle_wrapper()
def create_file_from_project_template(self, src_template_file, template_env, dst_file):
"""
Create a file from an android template file
:param src_template_file: The name of the template file that is located under Code/Tools/Android/ProjectBuilder
:param template_env: The dictionary that contains the template substitution values
:param dst_file: The target concrete file to write to
"""
src_template_file_path = self.android_project_builder_path / src_template_file
if not dst_file.exists() or self.overwrite_existing:
default_local_properties_content = common.load_template_file(template_file_path=src_template_file_path,
template_env=template_env)
dst_file.write_text(default_local_properties_content,
encoding=common.DEFAULT_TEXT_WRITE_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
logging.info('Generated default {}'.format(dst_file.name))
else:
logging.info('Skipped {} (file exists)'.format(dst_file.name))
def prepare_gradle_wrapper(self):
"""
Generate the gradle wrapper by calling the validated version of gradle.
"""
logging.info('Preparing Gradle Wrapper')
if self.override_gradle_path:
gradle_wrapper_cmd = [self.override_gradle_path]
else:
gradle_wrapper_cmd = ['gradle.bat' if platform.system() == 'Windows' else 'gradle']
gradle_wrapper_cmd.extend(['wrapper', '-p', str(self.build_dir.resolve())])
proc_result = subprocess.run(gradle_wrapper_cmd,
shell=True)
if proc_result.returncode != 0:
raise common.LmbrCmdError("Gradle was unable to generate a gradle wrapper for this project (code {}): {}"
.format(proc_result.returncode, proc_result.stderr or ""),
common.ERROR_CODE_ERROR_NOT_SUPPORTED)
def create_platform_settings(self):
"""
Create the 'platform.settings' file for the deployment script to use
"""
if self.is_test_project:
platform_settings_content = PLATFORM_SETTINGS_FORMAT.format(generation_timestamp=str(datetime.datetime.now().strftime("%c")),
platform='android',
project_path=self.project_path,
asset_mode='',
asset_type='',
android_sdk_path=str(self.android_sdk_path),
embed_assets_in_apk=True,
is_unit_test=True)
else:
platform_settings_content = PLATFORM_SETTINGS_FORMAT.format(generation_timestamp=str(datetime.datetime.now().strftime("%c")),
platform='android',
project_path=self.project_path,
asset_mode=self.asset_mode,
asset_type=self.asset_type,
android_sdk_path=str(self.android_sdk_path),
embed_assets_in_apk=str(self.include_assets_in_apk),
is_unit_test=False)
platform_settings_file = self.build_dir / 'platform.settings'
platform_settings_file.open('w').write(platform_settings_content)
def create_default_local_properties(self):
"""
Create the default 'local.properties' file in the build folder
"""
template_android_ndk_path = common.normalize_path_for_settings(self.android_ndk_path, True)
template_android_sdk_path = common.normalize_path_for_settings(self.android_sdk_path, True)
if self.override_cmake_path:
# The cmake dir references the base cmake folder, not the executable path itself, so resolve to the base folder
template_cmake_path = common.normalize_path_for_settings(pathlib.Path(self.override_cmake_path).parent.parent, True)
else:
template_cmake_path = None
local_properties_env = {
"GENERATION_TIMESTAMP": str(datetime.datetime.now().strftime("%c")),
"ANDROID_NDK_PATH": template_android_ndk_path,
"ANDROID_SDK_PATH": template_android_sdk_path,
"CMAKE_DIR_LINE": f'cmake.dir={template_cmake_path}' if template_cmake_path else ''
}
self.create_file_from_project_template(src_template_file='local.properties.in',
template_env=local_properties_env,
dst_file=self.build_dir / 'local.properties')
def patch_and_transfer_android_libs(self):
"""
Patch and transfer android libraries from the android SDK path based on the rules outlined in Code/Tools/Android/ProjectBuilder/android_libraries.json
"""
# The android_libraries.json is templatized and needs to be provided the following environment for processing
# before we can process it.
android_libraries_substitution_table = {
"ANDROID_SDK_HOME": common.normalize_path_for_settings(self.android_sdk_path, False),
"ANDROID_NDK_HOME": common.normalize_path_for_settings(self.android_ndk_path, False),
"ANDROID_SDK_VERSION": "android-".format(self.android_sdk_version)
}
android_libraries_template_json_path = self.android_project_builder_path / ANDROID_LIBRARIES_JSON_FILE
android_libraries_template_json_content = android_libraries_template_json_path.resolve(strict=True) \
.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
android_libraries_json_content = string.Template(android_libraries_template_json_content) \
.substitute(android_libraries_substitution_table)
android_libraries_json = json.loads(android_libraries_json_content)
# Process the android library rules
libs_to_patch = []
for libName, value in android_libraries_json.items():
# The library is in different places depending on the revision, so we must check multiple paths.
src_dir = None
for path in value['srcDir']:
path = string.Template(path).substitute(self.env)
if os.path.exists(path):
src_dir = path
break
if not src_dir:
raise common.LmbrCmdError('[ERROR] Failed to find library - {} - in path(s) [{}]. Please download the '
'library from the Android SDK Manager and run this command again.'
.format(libName, ", ".join(string.Template(path).substitute(self.env) for path in value['srcDir'])))
if 'patches' in value:
lib_to_patch = self._Library(libName, src_dir, self.overwrite_existing, self.signing_config)
for patch in value['patches']:
file_to_patch = self._File(patch['path'])
for change in patch['changes']:
line_num = change['line']
old_lines = change['old']
new_lines = change['new']
for oldLine in old_lines[:-1]:
change = self._Change(line_num, oldLine, (new_lines.pop() if new_lines else None))
file_to_patch.add_change(change)
line_num += 1
else:
change = self._Change(line_num, old_lines[-1], ('\n'.join(new_lines) if new_lines else None))
file_to_patch.add_change(change)
lib_to_patch.add_file_to_patch(file_to_patch)
lib_to_patch.dependencies = value.get('dependencies', [])
lib_to_patch.build_dependencies = value.get('buildDependencies', [])
libs_to_patch.append(lib_to_patch)
patched_lib_names = []
# Patch the libraries
for lib in libs_to_patch:
lib.process_patch_lib(android_project_builder_path=self.android_project_builder_path,
dest_root=self.build_dir)
patched_lib_names.append(lib.name)
return patched_lib_names
def create_lumberyard_app(self, project_dependencies):
"""
This will create the main lumberyard 'app' which will be packaged as an APK.
:param project_dependencies: Local project dependencies that may have been detected previously during construction of the android project folder
:returns List (of one) project name for the gradle build properties (see write_settings_gradle)
"""
az_android_dst_path = self.build_dir / APP_NAME
# We must always delete 'src' any existing copied AzAndroid projects since building may pick up stale java sources
lumberyard_app_src = az_android_dst_path / 'src'
if lumberyard_app_src.exists():
common.remove_dir_path(lumberyard_app_src)
logging.debug("Copying AzAndroid to '%s'", az_android_dst_path.resolve())
# The folder structure from the base lib needs to be mapped to a structure that gradle can process as a
# build project, and we need to generate some additional files
# Prepare the target folder
az_android_dst_path.mkdir(parents=True, exist_ok=True)
# Prepare the 'PROJECT_DEPENDENCIES' environment variable
gradle_project_dependencies = [f" api project(path: ':{project_dependency}')" for project_dependency in project_dependencies]
template_engine_root = common.normalize_path_for_settings(self.engine_root)
template_third_party_path = common.normalize_path_for_settings(self.third_party_path)
template_ndk_path = common.normalize_path_for_settings(self.android_ndk_path)
gradle_build_env = dict()
engine_root_as_path= pathlib.Path(self.engine_root)
absolute_cmakelist_path = (engine_root_as_path / 'CMakeLists.txt').resolve().as_posix()
absolute_azandroid_path = (engine_root_as_path / 'Code/Framework/AzAndroid/java').resolve().as_posix()
gradle_build_env['TARGET_TYPE'] = 'application'
gradle_build_env['PROJECT_DEPENDENCIES'] = PROJECT_DEPENDENCIES_VALUE_FORMAT.format(dependencies='\n'.join(gradle_project_dependencies))
gradle_build_env['NATIVE_CMAKE_SECTION_ANDROID'] = NATIVE_CMAKE_SECTION_ANDROID_FORMAT.format(cmake_version=str(self.cmake_version), absolute_cmakelist_path=absolute_cmakelist_path)
gradle_build_env['NATIVE_CMAKE_SECTION_DEFAULT_CONFIG'] = NATIVE_CMAKE_SECTION_DEFAULT_CONFIG_NDK_FORMAT_STR.format(abi=ANDROID_ARCH)
gradle_build_env['OVERRIDE_JAVA_SOURCESET'] = OVERRIDE_JAVA_SOURCESET_STR.format(absolute_azandroid_path=absolute_azandroid_path)
gradle_build_env['OPTIONAL_JNI_SRC_LIB_SET'] = ', "outputs/native-lib"'
for native_config in BUILD_CONFIGURATIONS:
native_config_upper = native_config.upper()
native_config_lower = native_config.lower()
# Prepare the cmake argument list based on the collected android settings and each build config
cmake_argument_list = [
'"-GNinja"',
f'"-S{template_engine_root}"',
f'"-DCMAKE_BUILD_TYPE={native_config_lower}"',
f'"-DCMAKE_TOOLCHAIN_FILE={template_engine_root}/cmake/Platform/Android/Toolchain_Android.cmake"',
f'"-DLY_3RDPARTY_PATH={template_third_party_path}"']
if not self.is_test_project:
cmake_argument_list.append(f'"-DLY_PROJECTS={pathlib.PurePath(self.project_path).as_posix()}"')
else:
cmake_argument_list.append('"-DLY_TEST_PROJECT=1"')
cmake_argument_list.extend([
f'"-DANDROID_NATIVE_API_LEVEL={self.android_ndk_platform}"',
f'"-DLY_NDK_DIR={template_ndk_path}"',
'"-DANDROID_STL=c++_shared"',
'"-Wno-deprecated"',
])
if native_config == 'Release':
cmake_argument_list.append('"-DLY_MONOLITHIC_GAME=1"')
if self.override_ninja_path:
cmake_argument_list.append(f'"-DCMAKE_MAKE_PROGRAM={common.normalize_path_for_settings(self.override_ninja_path)}"')
# Query the project_path from the project.json file
project_name = common.read_project_name_from_project_json(self.project_path)
# Prepare the config-specific section to place the cmake argument list in the build.gradle for the app
gradle_build_env[f'NATIVE_CMAKE_SECTION_{native_config_upper}_CONFIG'] = \
NATIVE_CMAKE_SECTION_BUILD_TYPE_CONFIG_FORMAT_STR.format(targets_section=f'targets "{project_name}.GameLauncher"'
if project_name and not self.is_test_project else "", arguments=','.join(cmake_argument_list))
if project_name:
# Prepare the config-specific section to copy the related .so files that are marked as dependencies for the target
# (launcher) since gradle will not include them automatically for APK import
gradle_build_env[f'CUSTOM_GRADLE_COPY_NATIVE_{native_config_upper}_LIB_TASK'] = \
CUSTOM_GRADLE_COPY_NATIVE_CONFIG_FORMAT_STR.format(config=native_config,
config_lower=native_config_lower,
project_name=project_name,
abi=ANDROID_ARCH,
optional_test_excludes=",'*.Tests.so'" if not self.is_test_project else "")
if self.is_test_project:
gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = \
CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_FORMAT_STR.format(config=native_config,
config_lower=native_config_lower,
asset_layout_folder=(self.build_dir / 'app/src/main/assets').resolve().as_posix(),
file_includes='Test.Assets/**/*.*')
else:
# Copy over settings registry files from the Registry folder with build output directory
gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = \
CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_FORMAT_STR.format(config=native_config,
config_lower=native_config_lower,
asset_layout_folder=(self.build_dir / 'app/src/main/assets').resolve().as_posix(),
file_includes='**/Registry/*.setreg')
if self.include_assets_in_apk:
if not self.is_test_project:
gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] += \
CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR.format(working_dir=common.normalize_path_for_settings(self.engine_root / 'cmake/Tools'),
python_full_path=common.normalize_path_for_settings(self.engine_root / 'python' / PYTHON_SCRIPT),
asset_type=self.asset_type,
project_path=self.project_path.as_posix(),
asset_mode=self.asset_mode if native_config != 'Release' else 'PAK',
asset_layout_folder=(self.build_dir / 'app/src/main/assets').resolve().as_posix(),
config=native_config)
else:
gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = ''
if self.signing_config:
gradle_build_env[f'SIGNING_{native_config_upper}_CONFIG'] = f'signingConfig signingConfigs.{native_config_lower}' if self.signing_config else ''
else:
gradle_build_env[f'SIGNING_{native_config_upper}_CONFIG'] = ''
if self.signing_config:
gradle_build_env['SIGNING_CONFIGS'] = f"""
signingConfigs {{
debug {{{self.signing_config.to_template_string(3)}}}
profile {{{self.signing_config.to_template_string(3)}}}
release {{{self.signing_config.to_template_string(3)}}}
}}
"""
else:
gradle_build_env['SIGNING_CONFIGS'] = ""
az_android_gradle_file = az_android_dst_path / 'build.gradle'
self.create_file_from_project_template(src_template_file='build.gradle.in',
template_env=gradle_build_env,
dst_file=az_android_dst_path / 'build.gradle')
# Generate a AndroidManifest.xml and write to ${az_android_dst_path}/src/main/AndroidManifest.xml
dest_src_main_path = az_android_dst_path / 'src/main'
dest_src_main_path.mkdir(parents=True)
az_android_package_env = AndroidProjectManifestEnvironment(engine_root=self.engine_root,
project_path=self.project_path,
android_sdk_version_number=self.android_sdk_version,
android_ndk_platform_number=self.android_ndk_platform,
is_test=self.is_test_project)
self.create_file_from_project_template(src_template_file=ANDROID_MANIFEST_FILE,
template_env=az_android_package_env,
dst_file=dest_src_main_path / ANDROID_MANIFEST_FILE)
# Apply the 'android_builder.json' rules to copy over additional files to the target
self.apply_android_builder_rules(az_android_dst_path=az_android_dst_path,
az_android_package_env=az_android_package_env)
self.resolve_icon_overrides(az_android_dst_path=az_android_dst_path,
az_android_package_env=az_android_package_env)
self.resolve_splash_overrides(az_android_dst_path=az_android_dst_path,
az_android_package_env=az_android_package_env)
self.clear_unused_assets(az_android_dst_path=az_android_dst_path,
az_android_package_env=az_android_package_env)
return [APP_NAME]
def write_settings_gradle(self, project_list):
"""
Generate and write the 'settings.gradle' and 'gradle.properties file at the root of the android project folder
:param project_list: List of dependent projects to include in the gradle build
"""
settings_gradle_lines = [f"include ':{project_name}'" for project_name in project_list]
settings_gradle_content = '\n'.join(settings_gradle_lines)
settings_gradle_file = self.build_dir / 'settings.gradle'
settings_gradle_file.write_text(settings_gradle_content,
encoding=common.DEFAULT_TEXT_READ_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
logging.info("Generated settings.gradle -> %s", str(settings_gradle_file.resolve()))
# Write the default gradle.properties
# TODO: Add substitution entries here if variables are added to gradle.properties.in
# Refer to the Code/Tools/Android/ProjectBuilder/gradle.properties.in for reference.
grade_properties_env = {}
gradle_properties_file = self.build_dir / 'gradle.properties'
self.create_file_from_project_template(src_template_file='gradle.properties.in',
template_env=grade_properties_env,
dst_file=gradle_properties_file)
logging.info("Generated gradle.properties -> %s", str(gradle_properties_file.resolve()))
def apply_android_builder_rules(self, az_android_dst_path, az_android_package_env):
"""
Apply the 'android_builder.json' rule file that was used by WAF to prepare the gradle application apk file.
:param az_android_dst_path: The target application folder underneath the android target folder
:param az_android_package_env: The template environment to use to process all the source template files
"""
android_builder_json_path = self.android_project_builder_path / 'android_builder.json'
android_builder_json_content = common.load_template_file(template_file_path=android_builder_json_path,
template_env=az_android_package_env)
android_builder_json = json.loads(android_builder_json_content)
# Legacy files that don't need to be copied to the path (not needed for APK processing)
skip_files = ['wscript']
def _copy(src_file, dst_path, dst_is_directory):
"""
Perform a specialized copy
:param src_file: Source file to copy (relative to ${android_project_builder_path})
:param dst_path: The destination to copy to
:param dst_is_directory: Flag to indicate if the destination is a path or a file
"""
if src_file in skip_files:
# Filter out files that shouldn't be copied
return
src_path = self.android_project_builder_path / src_file
resolved_src = src_path.resolve(strict=True)
if imghdr.what(resolved_src) in ('rgb', 'gif', 'pbm', 'ppm', 'tiff', 'rast', 'xbm', 'jpeg', 'bmp', 'png'):
# If the source file is a binary asset, then perform a copy to the target path
logging.debug("Copy Binary file %s -> %s", str(src_path.resolve(strict=True)), str(dst_path.resolve()))
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resolved_src, dst_path.resolve())
else:
if dst_is_directory:
# If the dst_path is a directory, then we are copying the file to that directory
dst_path.mkdir(parents=True, exist_ok=True)
dst_file = dst_path / src_file
else:
# Otherwise, we are copying the file to the dst_path directly. A renaming may occur
dst_path.parent.mkdir(parents=True, exist_ok=True)
dst_file = dst_path
project_activity_for_game_content = common.load_template_file(template_file_path=src_path,
template_env=az_android_package_env)
dst_file.write_text(project_activity_for_game_content)
logging.debug("Copy/Update file %s -> %s", str(src_path.resolve(strict=True)), str(dst_path.resolve()))
def _process_dict(node, dst_path):
"""
Process a node from the android_builder.json file
:param node: The node to process
:param dst_path: The destination path derived from the node
"""
assert isinstance(node, dict), f"Node for {android_builder_json_path} expected to be a dictionary"
for key, value in node.items():
if isinstance(value, str):
_copy(key, dst_path / value, False)
elif isinstance(value, list):
for item in value:
assert isinstance(node, dict), f"Unexpected type found in '{android_builder_json_path}'. Only lists of strings are supported"
_copy(item, dst_path / key, True)
elif isinstance(value, dict):
_process_dict(value, dst_path / key)
else:
assert False, f"Unexpected type '{type(value)}' found in '{android_builder_json_path}'. Only str, list, and dict is supported"
_process_dict(android_builder_json, az_android_dst_path)
def construct_source_resource_path(self, source_path):
"""
Helper to construct the source path to an asset override such as
application icons or splash screen images
:param source_path: Source path or file to attempt to locate
:return: The path to the resource file
"""
if os.path.isabs(source_path):
# Always return itself if the path is already and absolute path
return pathlib.Path(source_path)
game_gem_resources = self.project_path / 'Gem' / 'Resources'
if game_gem_resources.is_dir(game_gem_resources):
# If the source is relative and the game gem's resource is present, construct the path based on that
return game_gem_resources / source_path
raise common.LmbrCmdError(f'Unable to locate resources folder for project at path "{self.project_path}"')
def resolve_icon_overrides(self, az_android_dst_path, az_android_package_env):
"""
Resolve any icon overrides
:param az_android_dst_path: The destination android path (app project folder)
:param az_android_package_env: Dictionary of env values to retrieve override information
"""
dst_resource_path = az_android_dst_path / 'src/main/res'
icon_overrides = az_android_package_env['ICONS']
if not icon_overrides:
return
# 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:
src_default_icon_file = self.construct_source_resource_path(default_icon)
default_icon_target_dir = dst_resource_path / MIPMAP_PATH_PREFIX
default_icon_target_dir.mkdir(parents=True, exist_ok=True)
dst_default_icon_file = default_icon_target_dir / APP_ICON_NAME
shutil.copyfile(src_default_icon_file.resolve(), dst_default_icon_file.resolve())
os.chmod(dst_default_icon_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
else:
logging.debug(f'No default icon override specified for project_at path {self.project_path}')
# process each of the resolution overrides
warnings = []
for resolution in ANDROID_RESOLUTION_SETTINGS:
target_directory = dst_resource_path / f'{MIPMAP_PATH_PREFIX}-{resolution}'
target_directory.mkdir(parent=True, exist_ok=True)
# 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(f'No icon override found for "{resolution}". Either supply one for "{resolution}" or a '
f'"default" in the android_settings "icon" section of the project.json file for {self.project_path}')
# if only the resolution is unspecified, remove the resolution specific version from the project
else:
logging.debug(f'Default icon being used for "{resolution}" in {self.project_path}', resolution)
common.remove_dir_path(target_directory)
continue
src_icon_file = self.construct_source_resource_path(icon_source)
dst_icon_file = target_directory / APP_ICON_NAME
shutil.copyfile(src_icon_file.resolve(), dst_icon_file.resolve())
os.chmod(dst_icon_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
# guard against spamming warnings in the case the icon override block is full of garbage and no actual overrides
if len(warnings) != len(ANDROID_RESOLUTION_SETTINGS):
for warning_msg in warnings:
logging.warning(warning_msg)
def resolve_splash_overrides(self, az_android_dst_path, az_android_package_env):
"""
Resolve any splash screen overrides
:param az_android_dst_path: The destination android path (app project folder)
:param az_android_package_env: Dictionary of env values to retrieve override information
"""
dst_resource_path = az_android_dst_path / 'src/main/res'
splash_overrides = az_android_package_env['SPLASH_SCREEN']
if not splash_overrides:
return
orientation = az_android_package_env['ORIENTATION']
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 = f'Splash screen overrides specified for "{orientation_key}" when desired orientation ' \
f'is set to "{ORIENTATION_FLAG_TO_KEY_MAP[orientation]}" in project {self.project_path}. ' \
f'These overrides will be ignored.'
# if a default splash image is specified for this orientation, then copy it into the generic drawable-<orientation> folder
default_splash_img = oriented_splashes.get('default', None)
if default_splash_img is not None:
if unused_override_warning:
logging.warning(unused_override_warning)
continue
src_default_splash_img_file = self.construct_source_resource_path(default_splash_img)
dst_default_splash_img_dir = dst_resource_path / orientation_path_prefix
dst_default_splash_img_dir.mkdir(parents=True, exist_ok=True)
dst_default_splash_img_file = dst_default_splash_img_dir / APP_SPLASH_NAME
shutil.copyfile(src_default_splash_img_file.resolve(), dst_default_splash_img_file.resolve())
os.chmod(dst_default_splash_img_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
else:
logging.debug(f'No default splash screen override specified for "%s" orientation in %s', orientation_key,
self.project_path)
# 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(ANDROID_RESOLUTION_SETTINGS)
valid_resolutions.discard('xxxhdpi')
for resolution in valid_resolutions:
target_directory = dst_resource_path / f'{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 = f"{orientation_key}-{resolution}"
warnings.append(f'No splash screen override found for "{section}". Either supply one for "{resolution}" '
f'or a "default" in the android_settings "splash_screen-{orientation_key}" section of the '
f'project.json file for {self.project_path}.')
else:
# if only the resolution is unspecified, remove the resolution specific version from the project
logging.debug(f'Default splash screen being used for "{orientation_key}-{resolution}" in {self.project_path}')
common.remove_dir_path(target_directory)
continue
src_splash_img_file = self.construct_source_resource_path(splash_img_source)
dst_splash_img_file = target_directory / APP_SPLASH_NAME
shutil.copyfile(src_splash_img_file.resolve(), dst_splash_img_file.resolve())
os.chmod(dst_splash_img_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
# 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:
logging.warning(unused_override_warning)
else:
for warning_msg in warnings:
logging.warning(warning_msg)
@staticmethod
def clear_unused_assets(az_android_dst_path, az_android_package_env):
"""
micro-optimization to clear assets from the final bundle that won't be used
:param az_android_dst_path: The destination android path (app project folder)
:param az_android_package_env: Dictionary of env values to retrieve override information
"""
orientation = az_android_package_env['ORIENTATION']
if orientation == ORIENTATION_LANDSCAPE:
path_prefix = 'drawable-land'
elif orientation == ORIENTATION_PORTRAIT:
path_prefix = 'drawable-port'
else:
return
# Prepare all the sub-folders to clear
clear_folders = [path_prefix]
clear_folders.extend([f'{path_prefix}-{resolution}' for resolution in ANDROID_RESOLUTION_SETTINGS if resolution != 'xxxhdpi'])
# Clear out the base folder
dst_resource_path = az_android_dst_path / 'src/main/res'
for clear_folder in clear_folders:
target_directory = dst_resource_path / clear_folder
if target_directory.is_dir():
logging.debug("Clearing folder %s", target_directory)
common.remove_dir_path(target_directory)
class _Library:
"""
Library class to manage the library node in android_libraries.json
"""
def __init__(self, name, path, overwrite_existing, signing_config=None):
self.name = name
self.path = path
self.signing_config = signing_config
self.overwrite_existing = overwrite_existing
self.patch_files = []
self.dependencies = []
self.build_dependencies = []
def add_file_to_patch(self, file):
self.patch_files.append(file)
def process_patch_lib(self, android_project_builder_path, dest_root):
"""
Perform the patch logic on the library node of 'android_libraries.json' (root level)
:param android_project_builder_path: Path to the Android/ProjectBuilder path for the templates
:param dest_root: The target android project folder
"""
# Clear out any existing target path's src and recreate
dst_path = dest_root / self.name
dst_path_src = dst_path / 'src'
if dst_path_src.exists():
common.remove_dir_path(dst_path_src)
dst_path.mkdir(parents=True, exist_ok=True)
logging.debug("Copying library '{}' to '{}'".format(self.name, dst_path))
# The folder structure from the base lib needs to be mapped to a structure that gradle can process as a
# build project, and we need to generate some additional files
# Generate the gradle build script for the library based on the build.gradle.in template file
gradle_dependencies = []
if self.build_dependencies:
gradle_dependencies.extend([f" api '{build_dependency}'" for build_dependency in self.build_dependencies])
if self.dependencies:
gradle_dependencies.extend([f" api project(path: ':{dependency}')" for dependency in self.dependencies])
if gradle_dependencies:
project_dependencies = "dependencies {{\n{}\n}}".format('\n'.join(gradle_dependencies))
else:
project_dependencies = ""
# Prepare an environment for a basic, no-native (cmake) gradle project (java only)
build_gradle_env = {
'PROJECT_DEPENDENCIES': project_dependencies,
'TARGET_TYPE': 'library',
'NATIVE_CMAKE_SECTION_DEFAULT_CONFIG': '',
'NATIVE_CMAKE_SECTION_ANDROID': '',
'NATIVE_CMAKE_SECTION_DEBUG_CONFIG': '',
'NATIVE_CMAKE_SECTION_PROFILE_CONFIG': '',
'NATIVE_CMAKE_SECTION_RELEASE_CONFIG': '',
'OVERRIDE_JAVA_SOURCESET': '',
'OPTIONAL_JNI_SRC_LIB_SET': '',
'CUSTOM_APPLY_ASSET_LAYOUT_DEBUG_TASK': '',
'CUSTOM_APPLY_ASSET_LAYOUT_PROFILE_TASK': '',
'CUSTOM_APPLY_ASSET_LAYOUT_RELEASE_TASK': '',
'CUSTOM_GRADLE_COPY_NATIVE_DEBUG_LIB_TASK': '',
'CUSTOM_GRADLE_COPY_NATIVE_PROFILE_LIB_TASK': '',
'CUSTOM_GRADLE_COPY_NATIVE_RELEASE_LIB_TASK': '',
'SIGNING_CONFIGS': '',
'SIGNING_DEBUG_CONFIG': '',
'SIGNING_PROFILE_CONFIG': '',
'SIGNING_RELEASE_CONFIG': ''
}
build_gradle_content = common.load_template_file(template_file_path=android_project_builder_path / 'build.gradle.in',
template_env=build_gradle_env)
dest_gradle_script_file = dst_path / 'build.gradle'
if not dest_gradle_script_file.exists() or self.overwrite_existing:
dest_gradle_script_file.write_text(build_gradle_content,
encoding=common.DEFAULT_TEXT_WRITE_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
src_path = pathlib.Path(self.path)
# Prepare a 'src/main' folder
dst_src_main_path = dst_path / 'src/main'
dst_src_main_path.mkdir(parents=True, exist_ok=True)
# Prepare a copy mapping list of tuples to process the copying of files and perform the straight file
# copying
library_copy_subfolder_pairs = [('res', 'res'),
('src', 'java')]
for copy_subfolder_pair in library_copy_subfolder_pairs:
src_subfolder = copy_subfolder_pair[0]
dst_subfolder = copy_subfolder_pair[1]
# {SRC}/{src_subfolder}/ -> {DST}/src/main/{dst_subfolder}/
src_library_res_path = src_path / src_subfolder
if not src_library_res_path.exists():
continue
dst_library_res_path = dst_src_main_path / dst_subfolder
shutil.copytree(src_library_res_path.resolve(),
dst_library_res_path.resolve(),
copy_function=shutil.copyfile)
# Process the files identified for patching
for file in self.patch_files:
input_file_path = src_path / file.path
if file.path == ANDROID_MANIFEST_FILE:
# Special case: AndroidManifest.xml does not go under the java/ parent path
output_file_path = dst_src_main_path / ANDROID_MANIFEST_FILE
else:
output_subpath = f"java{file.path[3:]}" # Strip out the 'src' from the library json and replace it with the target 'java' sub-path folder heading
output_file_path = dst_src_main_path / output_subpath
logging.debug(" Patching file '%s'", os.path.basename(file.path))
with open(input_file_path.resolve()) as input_file:
lines = input_file.readlines()
with open(output_file_path.resolve(), '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]))
class _File:
"""
Helper class to manage individual files for each library (_Library) and their changes
"""
def __init__(self, path):
self.path = path
self.changes = []
def add_change(self, change):
self.changes.append(change)
class _Change:
"""
Helper class to manage a change/patch as defined in android_libraries.json
"""
def __init__(self, line, old, new):
self.line = line
self.old = old
self.new = new
ANDROID_PLATFORM_PATTERN = re.compile(r'([\w\d]*-)?(\d+\d*)') # Regex to handle android platform naming for both SDKs and NDKs
def validate_android_platform_input(input_android_platform, platform_variable_type, min_version, max_version):
"""
Helper tool to support android platform number inputs and perform min/max version validation
:param input_android_platform: The inpuit argument to evaluate
:param platform_variable_type: The type of platform version to validate (android sdk / android ndk)
:param min_version: The minimum version to validate against
:param max_version: The maximum version to validate against
:return: The int version of the extracted platform number from the input
"""
# Validate the platform number's format and against the supported versions
platform_number_match = ANDROID_PLATFORM_PATTERN.search(input_android_platform)
if not platform_number_match or not platform_number_match.group(2) or (platform_number_match.group(1) and platform_number_match.group(1) != 'android-'):
raise common.LmbrCmdError(f"Invalid {platform_variable_type} version value ({input_android_platform}). It must be "
f"either 'XX' or android-'XX' where 'XX' is a platform number.",
common.ERROR_CODE_INVALID_PARAMETER)
android_platform_number = int(platform_number_match.group(2))
if android_platform_number < min_version:
raise common.LmbrCmdError(f"Invalid {platform_variable_type} version value ({input_android_platform}) is less than the minimum "
f"supported version ({min_version}).",
common.ERROR_CODE_INVALID_PARAMETER)
if android_platform_number > max_version:
raise common.LmbrCmdError(f"Invalid {platform_variable_type} version value ({input_android_platform}) is greater than the maximum "
f"supported version ({max_version}).",
common.ERROR_CODE_INVALID_PARAMETER)
return android_platform_number
ANDROID_SDK_ENV_NAME = 'ANDROID_SDK'
ANDROID_SDK_MIN_PLATFORM = 28
ANDROID_SDK_MAX_PLATFORM = 29
def verify_android_sdk(android_sdk_platform, argument_name, override_android_sdk_path=None, preferred_sdk_build_tools_ver=None):
"""
Verify the android sdk and the requested platform platform against the android sdk path
:param android_sdk_platform: The android sdk platform to use (e.g. '28' or 'android-28')
:param argument_name: The name of the argument for descriptive errors to present
:param override_android_sdk_path: The location of the android SDK path if not set through the environment variable
:param preferred_sdk_build_tools_ver: Option prefered built tool version under the android SDK if available. Will fallback to the first one discovered
:returns tuple of the verified android sdk platform number, path to the Android SDK path and the build tool version
"""
android_sdk_platform_number = validate_android_platform_input(input_android_platform=android_sdk_platform,
platform_variable_type='android sdk',
min_version=ANDROID_SDK_MIN_PLATFORM,
max_version=ANDROID_SDK_MAX_PLATFORM)
# Get the candidate android sdk path from either the override argument or the system environment variable
if override_android_sdk_path:
check_android_sdk_path = override_android_sdk_path
else:
check_android_sdk_path = os.environ.get(ANDROID_SDK_ENV_NAME)
if not check_android_sdk_path:
raise common.LmbrCmdError(f"Android SDK path not set. Make sure that either the '{ANDROID_SDK_ENV_NAME}' environment is "
f"set or it is passed in through the {argument_name} argument")
# The android sdk folder structure is expected to have a 'platforms' sub folder based on the android sdk-platform number
check_android_sdk_path = pathlib.Path(check_android_sdk_path)
android_sdk_platforms_path = check_android_sdk_path / 'platforms'
if not android_sdk_platforms_path.is_dir():
raise common.LmbrCmdError(f"Invalid Android SDK path '{str(check_android_sdk_path)}': Missing 'platforms' directory.")
# Collect the available platform numbers from the platforms subdirectory
validated_android_platforms = []
for dir_item in android_sdk_platforms_path.iterdir():
if not dir_item.is_dir():
continue
check_file = dir_item / 'package.xml'
if check_file.is_file():
validated_android_platforms.append(dir_item.name)
if not validated_android_platforms:
raise common.LmbrCmdError(f"Invalid Android SDK path '{str(check_android_sdk_path)}': Unable to find any android platforms.")
# Normalize the android_sdk argument to fit the same folder name pattern
android_sdk_platform_name = f'android-{android_sdk_platform_number}'
if android_sdk_platform_name not in validated_android_platforms:
raise common.LmbrCmdError(f"Android SDK platform {android_sdk_platform_name} is not a valid for the android SDK located under '{str(check_android_sdk_path)}'")
# Enumerate through the build tools under android sdk
android_sdk_build_tools_dir = check_android_sdk_path / 'build-tools'
if not android_sdk_build_tools_dir.is_dir():
raise common.LmbrCmdError(f"Invalid Android SDK path '{str(check_android_sdk_path)}': Unable to find any built-tools folder.")
supported_build_tools = [str(build_tool.name) for build_tool in android_sdk_build_tools_dir.iterdir() if build_tool.is_dir()]
if not supported_build_tools:
raise common.LmbrCmdError(f"Invalid Android SDK path '{str(check_android_sdk_path)}': Unable to find any built-tools.")
if preferred_sdk_build_tools_ver:
if preferred_sdk_build_tools_ver in supported_build_tools:
validated_build_tool = preferred_sdk_build_tools_ver
else:
validated_build_tool = supported_build_tools[0]
logging.warning("Unable to locate android sdk build tool version {preferred_sdk_build_tools_ver}. Defaulting to version {validated_build_tool}")
else:
validated_build_tool = supported_build_tools[0]
return android_sdk_platform_number, check_android_sdk_path, validated_build_tool
ANDROID_NDK_ENV_NAME = 'ANDROID_NDK'
ANDROID_NDK_MIN_PLATFORM = 21
ANDROID_NDK_MAX_PLATFORM = 29
ANDROID_NDK_SOURCE_PROPERTIES_REVISION_PATTERN = re.compile(r'Pkg.Revision\s*=\s*(\d+.\d+.\d+)')
def verify_android_ndk(android_ndk_platform, argument_name, override_android_ndk_path=None):
"""
Verify the android ndk and requested platform against the android ndk path
:param android_ndk_platform: The android ndk platform to use (e.g. '21' or 'android-21')
:param argument_name: The name of the argument for descriptive errors to present
:param override_android_ndk_path: The location of the android NDK path if not set through the environment variable
:returns tuple of the verified android ndk platform number and the Path to the Android SDK path and the
"""
android_ndk_platform_number = validate_android_platform_input(input_android_platform=android_ndk_platform,
platform_variable_type='android ndk',
min_version=ANDROID_NDK_MIN_PLATFORM,
max_version=ANDROID_NDK_MAX_PLATFORM)
# Get the candidate android ndk path from either the override argument or the system environment variable
if override_android_ndk_path:
check_android_ndk_path = str(override_android_ndk_path)
else:
check_android_ndk_path = os.environ.get(ANDROID_NDK_ENV_NAME)
if not check_android_ndk_path:
raise common.LmbrCmdError(f"Android NDK path not set. Make sure that either the {ANDROID_NDK_ENV_NAME} environment "
f"is set or it is passed in through the {argument_name} argument")
check_android_ndk_path = pathlib.Path(check_android_ndk_path)
# Validate the android ndk path
# Determine the NDK revision by reading the source.properties file
ndk_source_properties_file = check_android_ndk_path / 'source.properties'
if not ndk_source_properties_file.is_file():
raise common.LmbrCmdError(f"Invalid Android NDK path '{str(check_android_ndk_path)}'. Missing 'source.properties' file.",
common.ERROR_CODE_INVALID_PARAMETER)
ndk_source_properties_file_content = ndk_source_properties_file.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
errors=common.ENCODING_ERROR_HANDLINGS)
ndk_revision_match = ANDROID_NDK_SOURCE_PROPERTIES_REVISION_PATTERN.search(ndk_source_properties_file_content)
if not ndk_revision_match:
raise common.LmbrCmdError(f"Invalid Android NDK path '{str(check_android_ndk_path)}'. Unable to extract version from 'source.properties' file.",
common.ERROR_CODE_INVALID_PARAMETER)
ndk_revision_number = LooseVersion(ndk_revision_match.group(1))
logging.info(f"Detected Android NDK Revision {str(ndk_revision_number)}")
# Collect the supported android platforms from the required 'platforms' folder under the ndk path
android_ndk_platforms_path = check_android_ndk_path / 'platforms'
if not android_ndk_platforms_path.is_dir():
raise common.LmbrCmdError(f"Invalid Android NDK path '{str(check_android_ndk_path)}'. Missing 'platforms' folder.",
common.ERROR_CODE_INVALID_PARAMETER)
validated_android_platforms = []
for dir_item in android_ndk_platforms_path.iterdir():
if not dir_item.is_dir():
continue
api_version_match = ANDROID_PLATFORM_PATTERN.search(dir_item.name)
if not api_version_match or api_version_match.group(1) != 'android-':
continue
check_lib_path = dir_item / 'arch-arm64/usr/lib'
if check_lib_path.is_dir():
validated_android_platforms.append(dir_item.name)
# For NDK revisions 19 and up, there is a mapping file for version numbers that map to other version.
platforms_map_aliases = {}
if ndk_revision_number >= LooseVersion('19.0.0'):
platforms_map_file = check_android_ndk_path / 'meta/platforms.json'
if platforms_map_file.exists():
with open(platforms_map_file, 'r') as platforms_map_file_handle:
platforms_map_file_json = json.load(platforms_map_file_handle)
platforms_map_aliases = platforms_map_file_json['aliases']
elif validated_android_platforms:
# Revisions before 19 does not have a mapping file for API versions, they fall back to the previous one
# So we need to make a mapping file that does the same
platforms_map_aliases = {}
validated_android_platforms.sort()
max_supported_api_number = int(ANDROID_PLATFORM_PATTERN.search(validated_android_platforms[-1]).group(2))
for validated_android_platform in validated_android_platforms:
current_api_version = int(ANDROID_PLATFORM_PATTERN.search(validated_android_platform).group(2))
next_api_version = current_api_version + 1
while f'android-{next_api_version}' not in validated_android_platforms and next_api_version <= max_supported_api_number:
platforms_map_aliases[str(next_api_version)] = current_api_version
next_api_version += 1
# Go through the aliases and add to the validated platforms if it is mapped to an existing platform
for alias_key, alias_value in platforms_map_aliases.items():
if not ANDROID_PLATFORM_PATTERN.search(f'android-{alias_key}'):
# Skip any non android-XX (XX = number) aliases
continue
aliased_platform_key = f'android-{alias_value}'
if aliased_platform_key in validated_android_platforms:
validated_android_platforms.append(f'android-{alias_key}')
if not validated_android_platforms:
raise common.LmbrCmdError(f"Invalid Android NDK path {str(check_android_ndk_path)}")
# Verify the ndk platform against the ndk path
android_ndk_platform_name = f'android-{android_ndk_platform_number}'
if android_ndk_platform_name not in validated_android_platforms:
raise common.LmbrCmdError(f"Android NDK platform {android_ndk_platform_name} is not a valid for the Android NDK located under '{str(check_android_ndk_path)}'")
return android_ndk_platform_number, check_android_ndk_path
ADB_TARGET = 'adb.exe' if platform.system() == 'Windows' else 'adb'
def resolve_adb_tool(android_sdk_path):
"""
Resolve the location of the adb tool based on the input Android SDK Path
:param android_sdk_path: The android SDK path to search for the adb tool
:return: The absolute path to the adb tool
"""
if isinstance(android_sdk_path, str):
android_sdk_path = pathlib.Path(android_sdk_path)
check_adb_target = android_sdk_path / 'platform-tools' / ADB_TARGET
if not check_adb_target.exists():
raise common.LmbrCmdError(f"Invalid Android SDK path '{str(android_sdk_path)}': Unable to locate '{ADB_TARGET}'.")
return check_adb_target
class AdbTool(common.CommandLineExec):
"""
Custom ADB command line processor
"""
def __init__(self, android_sdk_path):
check_adb_target = resolve_adb_tool(android_sdk_path)
super().__init__(str(check_adb_target))
self.is_connected = False
self.device_filter = None
def get_connected_device_serial_ids(self):
"""
Get the connected android device serial numbers through adb
:return: List of device serial numbers of android devices currently connected
"""
ret, devices_result, _ = super().exec(['devices'], capture_stdout=True)
if ret != 0:
raise common.LmbrCmdError("Unable to get device list from adb")
connected_device_serials = []
device_result_lines = [device_result_line.strip() for device_result_line in devices_result.split('\n') if
device_result_line]
for device_result_line in device_result_lines:
match_result = re.match(r'([\w-]+)\s+(device)', device_result_line)
if match_result:
device_id = match_result.group(1)
connected_device_serials.append(device_id)
return connected_device_serials
def connect(self, device_filter=None):
"""
Start the adb server
:param device_filter: Any device to filter subsequent commands to in situations where there may be multiple devices connected
"""
if self.is_connected:
raise common.LmbrCmdError("Adb connection already started")
super().exec(['start-server'])
if device_filter:
# If a device serial id was passed in, then verify if its valid
device_matched = False
connected_serial_ids = self.get_connected_device_serial_ids()
for connected_serial_id in connected_serial_ids:
if device_filter == connected_serial_id:
device_matched = True
break
if not device_matched:
raise common.LmbrCmdError(f"Invalid device serial {device_filter}. The current connected device serial ids are : {','.join(connected_serial_ids)}")
self.device_filter = device_filter
self.is_connected = True
def disconnect(self):
"""
Stop the adb server
"""
super().exec(['kill-server'])
self.is_connected = False
self.device_filter = None
def exec(self, arguments, capture_stdout=False, cwd=None):
"""
Wrapper to the base 'exec' call which may append an optional device filter to the adb calls
:param arguments: 'arguments' to pass to the base exec
:param capture_stdout: 'capture_stdout' to pass to the base exec
:param cwd: 'cwd' to pass to the base exec
:return: Result of the call (see common.CommandLineExec.exec)
"""
if self.device_filter:
adb_params = ['-s', self.device_filter]
adb_params.extend(arguments)
else:
adb_params = arguments
return super().exec(adb_params, capture_stdout, cwd)
def popen(self, arguments, cwd=None):
"""
Wrapper to the base 'popen' call which may append an optional device filter to the adb calls
:param arguments: 'arguments' to pass to the base popen
:param cwd: 'cwd' to pass to the base exec
:return: Result of the call (see common.CommandLineExec.popen)
"""
if self.device_filter:
adb_params = ['-s', self.device_filter]
adb_params.extend(arguments)
else:
adb_params = arguments
return super().popen(adb_params, cwd)