android_support.py 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770
  1. #
  2. # Copyright (c) Contributors to the Open 3D Engine Project.
  3. # For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import imghdr
  9. import configparser
  10. import datetime
  11. import fnmatch
  12. import logging
  13. import os
  14. import json
  15. import platform
  16. import re
  17. import shutil
  18. import stat
  19. import string
  20. import sys
  21. import subprocess
  22. import pathlib
  23. from packaging.version import Version
  24. # Resolve the common python module
  25. ROOT_DEV_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', '..', '..'))
  26. if ROOT_DEV_PATH not in sys.path:
  27. sys.path.append(ROOT_DEV_PATH)
  28. from cmake.Tools import common
  29. from cmake.Tools.layout_tool import remove_link
  30. ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP = {
  31. '4.2.2': {'min_gradle_version': '6.7.1',
  32. 'sdk_build': '30.0.3',
  33. 'default_ndk': '21.4.7075529',
  34. 'min_cmake_version': '3.20'},
  35. '7.3.1': {'min_gradle_version': '7.5.1',
  36. 'sdk_build': '33.0.0',
  37. 'default_ndk': '25.1.8937393',
  38. 'min_cmake_version': '3.24'},
  39. }
  40. APP_NAME = 'app'
  41. ANDROID_MANIFEST_FILE = 'AndroidManifest.xml'
  42. ANDROID_LIBRARIES_JSON_FILE = 'android_libraries.json'
  43. BUILD_CONFIGURATIONS = ['Debug', 'Profile', 'Release']
  44. # We currently only support arm64-v8a
  45. ANDROID_ARCH = 'arm64-v8a'
  46. ANDROID_RESOLUTION_SETTINGS = ('mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi')
  47. DEFAULT_CONFIG_CHANGES = [
  48. 'keyboard',
  49. 'keyboardHidden',
  50. 'orientation',
  51. 'screenSize',
  52. 'smallestScreenSize',
  53. 'screenLayout',
  54. 'uiMode',
  55. ]
  56. TEST_RUNNER_PROJECT = 'AzTestRunner'
  57. TEST_RUNNER_PACKAGE_NAME = 'org.o3de.tests'
  58. # Android Orientation Constants
  59. ORIENTATION_LANDSCAPE = 1 << 0
  60. ORIENTATION_PORTRAIT = 1 << 1
  61. ORIENTATION_ALL = (ORIENTATION_LANDSCAPE | ORIENTATION_PORTRAIT)
  62. ORIENTATION_FLAG_TO_KEY_MAP = {
  63. ORIENTATION_LANDSCAPE: 'land',
  64. ORIENTATION_PORTRAIT: 'port',
  65. }
  66. ORIENTATION_MAPPING = {
  67. 'landscape': ORIENTATION_LANDSCAPE,
  68. 'reverseLandscape': ORIENTATION_LANDSCAPE,
  69. 'sensorLandscape': ORIENTATION_LANDSCAPE,
  70. 'userLandscape': ORIENTATION_LANDSCAPE,
  71. 'portrait': ORIENTATION_PORTRAIT,
  72. 'reversePortrait': ORIENTATION_PORTRAIT,
  73. 'sensorPortrait': ORIENTATION_PORTRAIT,
  74. 'userPortrait': ORIENTATION_PORTRAIT
  75. }
  76. MIPMAP_PATH_PREFIX = 'mipmap'
  77. APP_ICON_NAME = 'app_icon.png'
  78. APP_SPLASH_NAME = 'app_splash.png'
  79. PYTHON_SCRIPT = 'python.cmd' if platform.system() == 'Windows' else 'python.sh'
  80. ANDROID_LAUNCHER_NAME_PATTERN = "{project_name}.GameLauncher"
  81. class AndroidProjectManifestEnvironment(object):
  82. """
  83. This class manages the environment for the AndroidManifest.xml template file, based on project settings and environments
  84. that were passed in or calculated from the command line arguments.
  85. """
  86. def __init__(self, engine_root, project_path, android_sdk_version_number, oculus_project:bool, is_test:bool):
  87. """
  88. Initialize the object with the project specific parameters and values for the game project
  89. :param engine_root: The path where the engine is located
  90. :param project_path: The path were the project is located
  91. :param android_sdk_version_number: The android SDK platform version
  92. :param oculus_project: Indicates if it's an oculus project
  93. :param is_test: Indicates if theAzTestRunner application should be run
  94. """
  95. try:
  96. if is_test:
  97. # The AzTestRunner project.json is located under {engine_root}/Code/Tools/AzTestRunner/Platform/Android/android_project.json
  98. project_properties_path = engine_root / 'Code' / 'Tools' / 'AzTestRunner' / 'Platform' / 'Android' / 'android_project.json'
  99. assert project_properties_path.is_file(), f'Missing required android settings file {project_properties_path.resolve()}'
  100. project_properties_content = project_properties_path.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
  101. errors=common.ENCODING_ERROR_HANDLINGS)
  102. project_json = json.loads(project_properties_content)
  103. android_settings = project_json['android_settings']
  104. else:
  105. # O3DE projects have both a project.json and an android_project.json files (unless its internal)
  106. project_properties_path = project_path / 'project.json'
  107. assert project_properties_path.is_file(), f'Missing required project settings file {project_properties_path.resolve()}'
  108. project_properties_content = project_properties_path.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
  109. errors=common.ENCODING_ERROR_HANDLINGS)
  110. project_json = json.loads(project_properties_content)
  111. android_project_properties_path = project_path / 'Platform' / 'Android' / 'android_project.json'
  112. if android_project_properties_path.is_file():
  113. android_project_properties_content = android_project_properties_path.read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
  114. errors=common.ENCODING_ERROR_HANDLINGS)
  115. android_project_json = json.loads(android_project_properties_content)
  116. android_settings = android_project_json['android_settings']
  117. else:
  118. android_settings = project_json['android_settings']
  119. self.project_path = project_path
  120. project_name = project_json['project_name']
  121. product_name = project_json.get('product_name', project_name)
  122. package_name = android_settings["package_name"]
  123. package_path = package_name.replace('.', '/')
  124. project_activity = f'{TEST_RUNNER_PROJECT}Activity' if is_test else f'{project_name}Activity'
  125. # Multiview options require special processing
  126. multi_window_options = AndroidProjectManifestEnvironment.process_android_multi_window_options(android_settings)
  127. oculus_intent_filter_category = '<category android:name="com.oculus.intent.category.VR" />' if oculus_project else ''
  128. self.internal_dict = {
  129. 'ANDROID_PACKAGE': package_name,
  130. 'ANDROID_PACKAGE_PATH': package_path,
  131. 'ANDROID_VERSION_NUMBER': android_settings["version_number"],
  132. "ANDROID_VERSION_NAME": android_settings["version_name"],
  133. "ANDROID_SCREEN_ORIENTATION": android_settings["orientation"],
  134. 'ANDROID_APP_NAME': TEST_RUNNER_PROJECT if is_test else product_name, # external facing name
  135. 'ANDROID_PROJECT_NAME': TEST_RUNNER_PROJECT if is_test else project_name, # internal facing name
  136. 'ANDROID_PROJECT_ACTIVITY': project_activity,
  137. 'ANDROID_LAUNCHER_NAME': TEST_RUNNER_PROJECT if is_test else ANDROID_LAUNCHER_NAME_PATTERN.format(project_name=project_name),
  138. 'ANDROID_CONFIG_CHANGES': multi_window_options['ANDROID_CONFIG_CHANGES'],
  139. 'ANDROID_APP_PUBLIC_KEY': android_settings.get('app_public_key', 'NoKey'),
  140. 'ANDROID_APP_OBFUSCATOR_SALT': android_settings.get('app_obfuscator_salt', ''),
  141. 'ANDROID_USE_MAIN_OBB': android_settings.get('use_main_obb', 'false'),
  142. 'ANDROID_USE_PATCH_OBB': android_settings.get('use_patch_obb', 'false'),
  143. 'ANDROID_ENABLE_KEEP_SCREEN_ON': android_settings.get('enable_keep_screen_on', 'false'),
  144. 'ANDROID_DISABLE_IMMERSIVE_MODE': android_settings.get('disable_immersive_mode', 'false'),
  145. 'ANDROID_TARGET_SDK_VERSION': android_sdk_version_number,
  146. 'ICONS': android_settings.get('icons', None),
  147. 'SPLASH_SCREEN': android_settings.get('splash_screen', None),
  148. 'ANDROID_MULTI_WINDOW': multi_window_options['ANDROID_MULTI_WINDOW'],
  149. 'ANDROID_MULTI_WINDOW_PROPERTIES': multi_window_options['ANDROID_MULTI_WINDOW_PROPERTIES'],
  150. 'SAMSUNG_DEX_KEEP_ALIVE': multi_window_options['SAMSUNG_DEX_KEEP_ALIVE'],
  151. 'SAMSUNG_DEX_LAUNCH_WIDTH': multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'],
  152. 'SAMSUNG_DEX_LAUNCH_HEIGHT': multi_window_options['SAMSUNG_DEX_LAUNCH_HEIGHT'],
  153. 'OCULUS_INTENT_FILTER_CATEGORY': oculus_intent_filter_category,
  154. 'ANDROID_MANIFEST_PACKAGE_OPTION': f'package="{package_name}"', # Legacy gradle 4.x support
  155. }
  156. except KeyError as e:
  157. raise common.LmbrCmdError(f"Missing key from android project settings for project at {project_path}:'{e}' ")
  158. def __getitem__(self, item):
  159. return self.internal_dict.get(item)
  160. @staticmethod
  161. def process_android_multi_window_options(game_project_android_settings):
  162. """
  163. Perform custom processing for game projects that have custom 'multi_window_options' in their project.json definition
  164. :param game_project_android_settings: The parsed out android settings from the game's project.json
  165. :return: Dictionary of attributes for any optional multiview option detected from the android settings
  166. """
  167. def is_number_option_valid(value, name):
  168. if value:
  169. if isinstance(value, int):
  170. return True
  171. else:
  172. logging.warning('[WARN] Invalid value for property "%s", expected whole number', name)
  173. return False
  174. def get_int_attribute(settings, key_name):
  175. settings_value = settings.get(key_name, None)
  176. if not settings_value:
  177. return None
  178. if not isinstance(settings_value, int):
  179. logging.warning('[WARN] Invalid value for property "%s", expected whole number', key_name)
  180. return None
  181. return settings_value
  182. multi_window_options = {
  183. 'SAMSUNG_DEX_LAUNCH_WIDTH': '',
  184. 'SAMSUNG_DEX_LAUNCH_HEIGHT': '',
  185. 'SAMSUNG_DEX_KEEP_ALIVE': '',
  186. 'ANDROID_CONFIG_CHANGES': '|'.join(DEFAULT_CONFIG_CHANGES),
  187. 'ANDROID_MULTI_WINDOW_PROPERTIES': '',
  188. 'ANDROID_MULTI_WINDOW': '',
  189. 'ORIENTATION': ORIENTATION_ALL
  190. }
  191. multi_window_settings = game_project_android_settings.get('multi_window_options', None)
  192. if not multi_window_settings:
  193. # If there are no multi-window options, then set the orientation to the orientation attribute if set, otherwise use the default 'ALL' orientation
  194. requested_orientation = game_project_android_settings['orientation']
  195. multi_window_options['ORIENTATION'] = ORIENTATION_MAPPING.get(requested_orientation, ORIENTATION_ALL)
  196. return multi_window_options
  197. launch_in_fullscreen = False
  198. # the Samsung DEX specific values can be added regardless of target API and multi-window support
  199. samsung_dex_options = multi_window_settings.get('samsung_dex_options', None)
  200. if samsung_dex_options:
  201. launch_in_fullscreen = samsung_dex_options.get('launch_in_fullscreen', False)
  202. # setting the launch window size in DEX mode since launching in fullscreen is strictly tied
  203. # to multi-window being enabled
  204. launch_width = get_int_attribute(samsung_dex_options, 'launch_width')
  205. launch_height = get_int_attribute(samsung_dex_options, 'launch_height')
  206. # both have to be specified otherwise they are ignored
  207. if launch_width and launch_height:
  208. multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'] = (f'<meta-data '
  209. f'android:name="com.samsung.android.sdk.multiwindow.dex.launchwidth" '
  210. f'android:value="{launch_width}"'
  211. f'/>')
  212. multi_window_options['SAMSUNG_DEX_LAUNCH_HEIGHT'] = (f'<meta-data '
  213. f'android:name="com.samsung.android.sdk.multiwindow.dex.launchheight" '
  214. f'android:value="{launch_height}"'
  215. f'/>')
  216. keep_alive = samsung_dex_options.get('keep_alive', None)
  217. if keep_alive in (True, False):
  218. multi_window_options['SAMSUNG_DEX_KEEP_ALIVE'] = f'<meta-data ' \
  219. f'android:name="com.samsung.android.keepalive.density" ' \
  220. f'android:value="{str(keep_alive).lower()}" ' \
  221. f'/>'
  222. multi_window_enabled = multi_window_settings.get('enabled', False)
  223. # the option to change the display resolution was added in API 24 as well, these changes are sent as density changes
  224. multi_window_options['ANDROID_CONFIG_CHANGES'] = '|'.join(DEFAULT_CONFIG_CHANGES + ['density'])
  225. # if targeting above the min API level the default value for this attribute is true so we need to explicitly disable it
  226. multi_window_options['ANDROID_MULTI_WINDOW'] = f'android:resizeableActivity="{str(multi_window_enabled).lower()}"'
  227. if not multi_window_enabled:
  228. return multi_window_options
  229. # remove the DEX launch window size if requested to launch in fullscreen mode
  230. if launch_in_fullscreen:
  231. multi_window_options['SAMSUNG_DEX_LAUNCH_WIDTH'] = ''
  232. multi_window_options['SAMSUNG_DEX_LAUNCH_HEIGHT'] = ''
  233. default_width = multi_window_settings.get('default_width', None)
  234. default_height = multi_window_settings.get('default_height', None)
  235. min_width = multi_window_settings.get('min_width', None)
  236. min_height = multi_window_settings.get('min_height', None)
  237. gravity = multi_window_settings.get('gravity', None)
  238. layout = ''
  239. if any([default_width, default_height, min_width, min_height, gravity]):
  240. layout = '<layout '
  241. # the default width/height values are respected as launch values in DEX mode so they should
  242. # be ignored if the intention is to launch in fullscreen when running in DEX mode
  243. if not launch_in_fullscreen:
  244. if is_number_option_valid(default_width, 'default_width'):
  245. layout += f'android:defaultWidth="{default_width}dp" '
  246. if is_number_option_valid(default_height, 'default_height'):
  247. layout += f'android:defaultHeight="{default_height}dp" '
  248. if is_number_option_valid(min_height, 'min_height'):
  249. layout += f'android:minHeight="{min_height}dp" '
  250. if is_number_option_valid(min_width, 'min_width'):
  251. layout += f'android:minWidth="{min_width}dp" '
  252. if gravity:
  253. layout += f'android:gravity="{gravity}" '
  254. layout += '/>'
  255. multi_window_options['ANDROID_MULTI_WINDOW_PROPERTIES'] = layout
  256. return multi_window_options
  257. PLATFORM_SETTINGS_FORMAT = """
  258. # Auto Generated from last cmake project generation ({generation_timestamp})
  259. [settings]
  260. platform={platform}
  261. game_projects={project_path}
  262. asset_deploy_mode={asset_mode}
  263. asset_deploy_type={asset_type}
  264. [android]
  265. android_sdk_path={android_sdk_path}
  266. embed_assets_in_apk={embed_assets_in_apk}
  267. is_unit_test={is_unit_test}
  268. android_gradle_plugin={android_gradle_plugin_version}
  269. """
  270. NATIVE_CMAKE_SECTION_ANDROID_FORMAT = """
  271. externalNativeBuild {{
  272. cmake {{
  273. buildStagingDirectory "{native_build_path}"
  274. version "{cmake_version}"
  275. path "{absolute_cmakelist_path}"
  276. }}
  277. }}
  278. """
  279. NATIVE_CMAKE_SECTION_DEFAULT_CONFIG_NDK_FORMAT_STR = """
  280. ndk {{
  281. abiFilters '{abi}'
  282. }}
  283. """
  284. NATIVE_CMAKE_SECTION_BUILD_TYPE_CONFIG_FORMAT_STR = """
  285. externalNativeBuild {{
  286. cmake {{
  287. {targets_section}
  288. arguments {arguments}
  289. }}
  290. }}
  291. """
  292. CUSTOM_GRADLE_COPY_NATIVE_CONFIG_FORMAT_STR = """
  293. task copyNativeLibs{config}(type: Copy) {{
  294. logger.info('Deleting outputs/native-lib/{abi}')
  295. delete 'outputs/native-lib/{abi}'
  296. from fileTree(dir: 'build/intermediates/cmake/{config_lower}/obj/arm64-v8a/{config_lower}',
  297. include: '**/*.so', exclude: 'lib{project_name}.GameLauncher.so')
  298. into 'outputs/native-lib/{abi}'
  299. eachFile {{
  300. logger.info('Copying {{}} to outputs/native-lib/{abi}', it.name)
  301. }}
  302. }}
  303. merge{config}JniLibFolders.dependsOn copyNativeLibs{config}
  304. copyNativeLibs{config}.mustRunAfter {{
  305. tasks.findAll {{ task->task.name.contains('externalNativeBuild{config}') }}
  306. }}
  307. """
  308. CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_FORMAT_STR = """
  309. task copyNativeArtifacts{config}(type: Copy) {{
  310. from fileTree(dir: 'build/intermediates/cmake/{config_lower}/obj/arm64-v8a/{config_lower}', include: '{file_includes}' )
  311. into '{asset_layout_folder}'
  312. }}
  313. compile{config}Sources.dependsOn copyNativeArtifacts{config}
  314. """
  315. CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_DEPENDENCY_FORMAT_STR = """
  316. copyNativeArtifacts{config}.mustRunAfter {{
  317. tasks.findAll {{ task->task.name.contains('syncLYLayoutMode{config}') }}
  318. }}
  319. """
  320. CUSTOM_GRADLE_COPY_REGISTRY_FOLDER_FORMAT_STR = """
  321. task copyRegistryFolder{config}(type: Copy) {{
  322. from ('build/intermediates/cmake/{config_lower}/obj/arm64-v8a/{config_lower}/Registry')
  323. into ('{asset_layout_folder}/registry')
  324. include ('*.setreg')
  325. }}
  326. merge{config}Assets.dependsOn copyRegistryFolder{config}
  327. """
  328. CUSTOM_GRADLE_COPY_REGISTRY_FOLDER_DEPENDENCY_FORMAT_STR = """
  329. copyRegistryFolder{config}.mustRunAfter {{
  330. tasks.findAll {{ task->task.name.contains('syncLYLayoutMode{config}') }}
  331. }}
  332. """
  333. CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR = """
  334. task syncLYLayoutMode{config}(type:Exec) {{
  335. workingDir '{working_dir}'
  336. 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}'
  337. }}
  338. compile{config}Sources.dependsOn syncLYLayoutMode{config}
  339. syncLYLayoutMode{config}.mustRunAfter {{
  340. tasks.findAll {{ task->task.name.contains('externalNativeBuild{config}') }}
  341. }}
  342. """
  343. PROJECT_DEPENDENCIES_VALUE_FORMAT = """
  344. dependencies {{
  345. {dependencies}
  346. api 'androidx.core:core:1.1.0'
  347. }}
  348. """
  349. OVERRIDE_JAVA_SOURCESET_STR = """
  350. java {{
  351. srcDirs = ['{absolute_azandroid_path}', 'src/main/java']
  352. }}
  353. """
  354. class AndroidSigningConfig(object):
  355. """
  356. Class to manage android signing configs
  357. """
  358. def __init__(self, store_file, store_password, key_alias, key_password):
  359. if not store_file:
  360. raise common.LmbrCmdError(f"Keystore file not supplied for signing configuration",
  361. common.ERROR_CODE_INVALID_PARAMETER)
  362. if not os.path.isfile(store_file):
  363. raise common.LmbrCmdError(f"Missing/Invalid keystore file {store_file} for signing config",
  364. common.ERROR_CODE_INVALID_PARAMETER)
  365. self.store_file = store_file.replace('\\', '/')
  366. if not store_password:
  367. raise common.LmbrCmdError(f"Keystore password not supplied for signing configuration",
  368. common.ERROR_CODE_INVALID_PARAMETER)
  369. self.store_password = store_password
  370. if not key_alias:
  371. raise common.LmbrCmdError(f"Signing key alias not supplied for signing configuration",
  372. common.ERROR_CODE_INVALID_PARAMETER)
  373. self.key_alias = key_alias
  374. if not key_password:
  375. raise common.LmbrCmdError(f"Signing key password not supplied for signing configuration",
  376. common.ERROR_CODE_INVALID_PARAMETER)
  377. self.key_password = key_password
  378. def to_template_string(self, tabs):
  379. tab_prefix = ' '*4*tabs
  380. return f"""
  381. {tab_prefix}storeFile file('{self.store_file}')
  382. {tab_prefix}storePassword '{self.store_password}'
  383. {tab_prefix}keyPassword '{self.key_password}'
  384. {tab_prefix}keyAlias '{self.key_alias}'"""
  385. class AndroidProjectGenerator(object):
  386. """
  387. Class the manages the process to generate an android project folder in order to build with gradle/android studio
  388. """
  389. def __init__(self, engine_root, build_dir, android_sdk_path, build_tool, android_sdk_platform, android_native_api_level, android_ndk,
  390. project_path, third_party_path, cmake_version, override_cmake_path, override_gradle_path, gradle_version, gradle_plugin_version,
  391. override_ninja_path, include_assets_in_apk, asset_mode, asset_type, signing_config, native_build_path, vulkan_validation_path,
  392. extra_cmake_configure_args, is_test_project=False,
  393. overwrite_existing=True, unity_build_enabled=False,
  394. oculus_project=False):
  395. """
  396. Initialize the object with all the required parameters needed to create an Android Project. The parameters should be verified before initializing this object
  397. :param engine_root: The engine root that contains the engine
  398. :param build_dir: The target folder under the where the android project folder will be created
  399. :param android_sdk_path: The path to the ANDROID_SDK used for building the android java code
  400. :param build_tool: The android SDK build-tool version.
  401. :param android_sdk_platform: The android sdk platform version number to use for the Android SDK related builds
  402. :param android_native_api_level:The android native API level (ANDROID_NATIVE_API_LEVEL) to set
  403. :param android_ndk: The android ndk version number to use for the native builds
  404. :param project_path: The path to the project
  405. :param third_party_path: The required path to the lumberyard 3rd party path
  406. :param cmake_version: The version number of cmake that will be used by gradle
  407. :param override_cmake_path: The override path to cmake if it does not exists in the system path
  408. :param override_gradle_path: The override path to gradle if it does not exists in the system path
  409. :param gradle_version: The detected version of gradle being used
  410. :param gradle_plugin_version: The android gradle plugin version
  411. :param override_ninja_path: The override path to ninja if it does not exists in the system path
  412. :param include_assets_in_apk:
  413. :param asset_mode:
  414. :param asset_type:
  415. :param signing_config: Optional signing configuration arguments
  416. :param native_build_path: Override the native build staging path in gradle
  417. :param vulkan_validation_path: Override the path to where the Vulkan Validation Layers libraries are (required when using NDK r23+)
  418. :param extra_cmake_configure_args Additional arguments to supply cmake when configuring a project
  419. :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)
  420. :param overwrite_existing: Flag to overwrite existing project files when being generated, or skip if they already exist.
  421. :param unity_build_enabled: Flag to enable unity build.
  422. :param oculus_project: Flag to indicate if this is an oculus project
  423. """
  424. self.env = {}
  425. self.engine_root = engine_root
  426. self.build_dir = build_dir
  427. self.android_sdk_path = android_sdk_path
  428. self.android_project_builder_path = self.engine_root / 'Code/Tools/Android/ProjectBuilder'
  429. self.android_sdk_platform = android_sdk_platform
  430. self.android_sdk_build_tool_version = build_tool.version
  431. self.android_ndk = android_ndk
  432. self.android_ndk_version = android_ndk.version
  433. self.android_native_api_level = android_native_api_level
  434. self.project_path = project_path
  435. self.third_party_path = third_party_path
  436. self.cmake_version = cmake_version
  437. self.override_cmake_path = override_cmake_path
  438. self.override_gradle_path = override_gradle_path
  439. self.gradle_version = gradle_version
  440. self.gradle_plugin_version = gradle_plugin_version
  441. self.override_ninja_path = override_ninja_path
  442. self.include_assets_in_apk = include_assets_in_apk
  443. self.native_build_path = native_build_path
  444. self.vulkan_validation_path = vulkan_validation_path
  445. self.extra_cmake_configure_args = extra_cmake_configure_args
  446. self.asset_mode = asset_mode
  447. self.asset_type = asset_type
  448. self.signing_config = signing_config
  449. self.is_test_project = is_test_project
  450. self.overwrite_existing = overwrite_existing
  451. self.unity_build_enabled = unity_build_enabled
  452. self.oculus_project = oculus_project
  453. def execute(self):
  454. """
  455. Execute the android project creation workflow
  456. """
  457. # Prepare the working build directory
  458. self.build_dir.mkdir(parents=True, exist_ok=True)
  459. self.create_platform_settings()
  460. self.create_default_local_properties()
  461. project_names = self.patch_and_transfer_android_libs()
  462. project_names.extend(self.create_lumberyard_app(project_names))
  463. root_gradle_env = {
  464. 'ANDROID_GRADLE_PLUGIN_VERSION': str(self.gradle_plugin_version),
  465. 'SDK_VER': self.android_sdk_platform,
  466. 'MIN_SDK_VER': self.android_sdk_platform,
  467. 'NDK_VERSION': self.android_ndk_version,
  468. 'SDK_BUILD_TOOL_VER': self.android_sdk_build_tool_version,
  469. 'LY_ENGINE_ROOT': common.normalize_path_for_settings(self.engine_root)
  470. }
  471. # Generate the gradle build script
  472. self.create_file_from_project_template(src_template_file='root.build.gradle.in',
  473. template_env=root_gradle_env,
  474. dst_file=self.build_dir / 'build.gradle')
  475. self.write_settings_gradle(project_names)
  476. self.prepare_gradle_wrapper()
  477. def create_file_from_project_template(self, src_template_file, template_env, dst_file):
  478. """
  479. Create a file from an android template file
  480. :param src_template_file: The name of the template file that is located under Code/Tools/Android/ProjectBuilder
  481. :param template_env: The dictionary that contains the template substitution values
  482. :param dst_file: The target concrete file to write to
  483. """
  484. src_template_file_path = self.android_project_builder_path / src_template_file
  485. if not dst_file.exists() or self.overwrite_existing:
  486. default_local_properties_content = common.load_template_file(template_file_path=src_template_file_path,
  487. template_env=template_env)
  488. dst_file.write_text(default_local_properties_content,
  489. encoding=common.DEFAULT_TEXT_WRITE_ENCODING,
  490. errors=common.ENCODING_ERROR_HANDLINGS)
  491. logging.info('Generated default {}'.format(dst_file.name))
  492. else:
  493. logging.info('Skipped {} (file exists)'.format(dst_file.name))
  494. def prepare_gradle_wrapper(self):
  495. """
  496. Generate the gradle wrapper by calling the validated version of gradle.
  497. """
  498. logging.info('Preparing Gradle Wrapper')
  499. if self.override_gradle_path:
  500. gradle_wrapper_cmd = [self.override_gradle_path]
  501. else:
  502. gradle_wrapper_cmd = ['gradle']
  503. gradle_wrapper_cmd.extend(['wrapper', '-p', str(self.build_dir.resolve())])
  504. proc_result = subprocess.run(gradle_wrapper_cmd,
  505. shell=(platform.system() == 'Windows'))
  506. if proc_result.returncode != 0:
  507. raise common.LmbrCmdError("Gradle was unable to generate a gradle wrapper for this project (code {}): {}"
  508. .format(proc_result.returncode, proc_result.stderr or ""),
  509. common.ERROR_CODE_ERROR_NOT_SUPPORTED)
  510. def create_platform_settings(self):
  511. """
  512. Create the 'platform.settings' file for the deployment script to use
  513. """
  514. if self.is_test_project:
  515. platform_settings_content = PLATFORM_SETTINGS_FORMAT.format(generation_timestamp=str(datetime.datetime.now().strftime("%c")),
  516. platform='android',
  517. project_path=self.project_path,
  518. asset_mode='',
  519. asset_type='',
  520. android_sdk_path=str(self.android_sdk_path),
  521. embed_assets_in_apk=True,
  522. is_unit_test=True,
  523. android_gradle_plugin_version=self.gradle_plugin_version)
  524. else:
  525. platform_settings_content = PLATFORM_SETTINGS_FORMAT.format(generation_timestamp=str(datetime.datetime.now().strftime("%c")),
  526. platform='android',
  527. project_path=self.project_path,
  528. asset_mode=self.asset_mode,
  529. asset_type=self.asset_type,
  530. android_sdk_path=str(self.android_sdk_path),
  531. embed_assets_in_apk=str(self.include_assets_in_apk),
  532. is_unit_test=False,
  533. android_gradle_plugin_version=self.gradle_plugin_version)
  534. platform_settings_file = self.build_dir / 'platform.settings'
  535. # Check if there already exists the build folder and a 'platform.settings' file. If there is an android gradle
  536. # plugin version set and it is different than the one configured here, we will always overwrite it since
  537. # there could be significant differences from one plug-in to the next
  538. if platform_settings_file.is_file():
  539. config = configparser.ConfigParser()
  540. config.read([str(platform_settings_file.resolve(strict=True))])
  541. if config.has_option('android', 'android_gradle_plugin'):
  542. exist_agp_version = config.get('android', 'android_gradle_plugin')
  543. if exist_agp_version != self.gradle_plugin_version:
  544. self.overwrite_existing = True
  545. platform_settings_file.open('w').write(platform_settings_content)
  546. def create_default_local_properties(self):
  547. """
  548. Create the default 'local.properties' file in the build folder
  549. """
  550. template_android_sdk_path = common.normalize_path_for_settings(self.android_sdk_path, True)
  551. if self.override_cmake_path:
  552. # The cmake dir references the base cmake folder, not the executable path itself, so resolve to the base folder
  553. template_cmake_path = common.normalize_path_for_settings(pathlib.Path(self.override_cmake_path).parent.parent, True)
  554. else:
  555. template_cmake_path = None
  556. local_properties_env = {
  557. "GENERATION_TIMESTAMP": str(datetime.datetime.now().strftime("%c")),
  558. "ANDROID_SDK_PATH": template_android_sdk_path,
  559. "CMAKE_DIR_LINE": f'cmake.dir={template_cmake_path}' if template_cmake_path else ''
  560. }
  561. self.create_file_from_project_template(src_template_file='local.properties.in',
  562. template_env=local_properties_env,
  563. dst_file=self.build_dir / 'local.properties')
  564. def patch_and_transfer_android_libs(self):
  565. """
  566. Patch and transfer android libraries from the android SDK path based on the rules outlined in Code/Tools/Android/ProjectBuilder/android_libraries.json
  567. """
  568. # The android_libraries.json is templatized and needs to be provided the following environment for processing
  569. # before we can process it.
  570. android_libraries_substitution_table = {
  571. "ANDROID_SDK_HOME": common.normalize_path_for_settings(self.android_sdk_path, False),
  572. "ANDROID_SDK_VERSION": f"android-{self.android_sdk_platform}"
  573. }
  574. android_libraries_template_json_path = self.android_project_builder_path / ANDROID_LIBRARIES_JSON_FILE
  575. android_libraries_template_json_content = android_libraries_template_json_path.resolve(strict=True) \
  576. .read_text(encoding=common.DEFAULT_TEXT_READ_ENCODING,
  577. errors=common.ENCODING_ERROR_HANDLINGS)
  578. android_libraries_json_content = string.Template(android_libraries_template_json_content) \
  579. .substitute(android_libraries_substitution_table)
  580. android_libraries_json = json.loads(android_libraries_json_content)
  581. # Process the android library rules
  582. libs_to_patch = []
  583. for libName, value in android_libraries_json.items():
  584. # The library is in different places depending on the revision, so we must check multiple paths.
  585. src_dir = None
  586. for path in value['srcDir']:
  587. path = string.Template(path).substitute(self.env)
  588. if os.path.exists(path):
  589. src_dir = path
  590. break
  591. if not src_dir:
  592. raise common.LmbrCmdError('[ERROR] Failed to find library - {} - in path(s) [{}]. Please download the '
  593. 'library from the Android SDK Manager and run this command again.'
  594. .format(libName, ", ".join(string.Template(path).substitute(self.env) for path in value['srcDir'])))
  595. if 'patches' in value:
  596. lib_to_patch = self._Library(libName, src_dir, self.overwrite_existing, self.signing_config)
  597. for patch in value['patches']:
  598. file_to_patch = self._File(patch['path'])
  599. for change in patch['changes']:
  600. line_num = change['line']
  601. old_lines = change['old']
  602. new_lines = change['new']
  603. for oldLine in old_lines[:-1]:
  604. change = self._Change(line_num, oldLine, (new_lines.pop() if new_lines else None))
  605. file_to_patch.add_change(change)
  606. line_num += 1
  607. else:
  608. change = self._Change(line_num, old_lines[-1], ('\n'.join(new_lines) if new_lines else None))
  609. file_to_patch.add_change(change)
  610. lib_to_patch.add_file_to_patch(file_to_patch)
  611. lib_to_patch.dependencies = value.get('dependencies', [])
  612. lib_to_patch.build_dependencies = value.get('buildDependencies', [])
  613. libs_to_patch.append(lib_to_patch)
  614. patched_lib_names = []
  615. # Patch the libraries
  616. for lib in libs_to_patch:
  617. lib.process_patch_lib(android_project_builder_path=self.android_project_builder_path,
  618. dest_root=self.build_dir)
  619. patched_lib_names.append(lib.name)
  620. return patched_lib_names
  621. def create_lumberyard_app(self, project_dependencies):
  622. """
  623. This will create the main lumberyard 'app' which will be packaged as an APK.
  624. :param project_dependencies: Local project dependencies that may have been detected previously during construction of the android project folder
  625. :returns List (of one) project name for the gradle build properties (see write_settings_gradle)
  626. """
  627. az_android_dst_path = self.build_dir / APP_NAME
  628. # We must always delete 'src' any existing copied AzAndroid projects since building may pick up stale java sources
  629. lumberyard_app_src = az_android_dst_path / 'src'
  630. if lumberyard_app_src.exists():
  631. # special case the 'assets' directory before cleaning the whole directory tree
  632. remove_link(lumberyard_app_src / 'main' / 'assets')
  633. common.remove_dir_path(lumberyard_app_src)
  634. logging.debug("Copying AzAndroid to '%s'", az_android_dst_path.resolve())
  635. # The folder structure from the base lib needs to be mapped to a structure that gradle can process as a
  636. # build project, and we need to generate some additional files
  637. # Prepare the target folder
  638. az_android_dst_path.mkdir(parents=True, exist_ok=True)
  639. # Prepare the 'PROJECT_DEPENDENCIES' environment variable
  640. gradle_project_dependencies = [f" api project(path: ':{project_dependency}')" for project_dependency in project_dependencies]
  641. template_engine_root = common.normalize_path_for_settings(self.engine_root)
  642. template_third_party_path = common.normalize_path_for_settings(self.third_party_path)
  643. template_ndk_path = common.normalize_path_for_settings(os.path.join(self.android_sdk_path, self.android_ndk.location))
  644. template_unity_build = 1 if self.unity_build_enabled else 0
  645. native_build_path = pathlib.Path(self.native_build_path).resolve().as_posix() if self.native_build_path else '.'
  646. gradle_build_env = dict()
  647. engine_root_as_path= pathlib.Path(self.engine_root)
  648. absolute_cmakelist_path = (engine_root_as_path / 'CMakeLists.txt').resolve().as_posix()
  649. absolute_azandroid_path = (engine_root_as_path / 'Code/Framework/AzAndroid/java').resolve().as_posix()
  650. gradle_build_env['TARGET_TYPE'] = 'application'
  651. gradle_build_env['PROJECT_DEPENDENCIES'] = PROJECT_DEPENDENCIES_VALUE_FORMAT.format(dependencies='\n'.join(gradle_project_dependencies))
  652. gradle_build_env['NATIVE_CMAKE_SECTION_ANDROID'] = NATIVE_CMAKE_SECTION_ANDROID_FORMAT.format(cmake_version=str(self.cmake_version), native_build_path=native_build_path, absolute_cmakelist_path=absolute_cmakelist_path)
  653. gradle_build_env['NATIVE_CMAKE_SECTION_DEFAULT_CONFIG'] = NATIVE_CMAKE_SECTION_DEFAULT_CONFIG_NDK_FORMAT_STR.format(abi=ANDROID_ARCH)
  654. gradle_build_env['OVERRIDE_JAVA_SOURCESET'] = OVERRIDE_JAVA_SOURCESET_STR.format(absolute_azandroid_path=absolute_azandroid_path)
  655. gradle_build_env['OPTIONAL_JNI_SRC_LIB_SET'] = ', "outputs/native-lib"'
  656. for native_config in BUILD_CONFIGURATIONS:
  657. native_config_upper = native_config.upper()
  658. native_config_lower = native_config.lower()
  659. # Prepare the cmake argument list based on the collected android settings and each build config
  660. cmake_argument_list = [
  661. '"-GNinja"',
  662. f'"-S{template_engine_root}"',
  663. f'"-DCMAKE_BUILD_TYPE={native_config_lower}"',
  664. f'"-DCMAKE_TOOLCHAIN_FILE={template_engine_root}/cmake/Platform/Android/Toolchain_android.cmake"',
  665. f'"-DLY_3RDPARTY_PATH={template_third_party_path}"',
  666. f'"-DLY_UNITY_BUILD={template_unity_build}"']
  667. if self.vulkan_validation_path:
  668. cmake_argument_list.append(f'"-DLY_ANDROID_VULKAN_VALIDATION_PATH={pathlib.PurePath(self.vulkan_validation_path).as_posix()}"')
  669. if not self.is_test_project:
  670. cmake_argument_list.append(f'"-DLY_PROJECTS={pathlib.PurePath(self.project_path).as_posix()}"')
  671. else:
  672. cmake_argument_list.append('"-DLY_TEST_PROJECT=1"')
  673. cmake_argument_list.extend([
  674. f'"-DANDROID_NATIVE_API_LEVEL={self.android_native_api_level}"',
  675. f'"-DLY_NDK_DIR={template_ndk_path}"',
  676. '"-DANDROID_STL=c++_shared"',
  677. '"-Wno-deprecated"',
  678. ])
  679. if native_config == 'Release':
  680. cmake_argument_list.append('"-DLY_MONOLITHIC_GAME=1"')
  681. if self.override_ninja_path:
  682. cmake_argument_list.append(f'"-DCMAKE_MAKE_PROGRAM={common.normalize_path_for_settings(self.override_ninja_path)}"')
  683. if self.oculus_project:
  684. cmake_argument_list.append('"-DANDROID_USE_OCULUS_OPENXR=ON"')
  685. if self.extra_cmake_configure_args:
  686. cmake_argument_list.extend(map(json.dumps, self.extra_cmake_configure_args))
  687. # Query the project_path from the project.json file
  688. project_name = common.read_project_name_from_project_json(self.project_path)
  689. # Prepare the config-specific section to place the cmake argument list in the build.gradle for the app
  690. gradle_build_env[f'NATIVE_CMAKE_SECTION_{native_config_upper}_CONFIG'] = \
  691. NATIVE_CMAKE_SECTION_BUILD_TYPE_CONFIG_FORMAT_STR.format(arguments=','.join(cmake_argument_list),
  692. targets_section=f'targets "{project_name}.GameLauncher"'
  693. if project_name and not self.is_test_project else 'targets "TEST_SUITE_main"')
  694. if project_name:
  695. # Prepare the config-specific section to copy the related .so files that are marked as dependencies for the target
  696. # (launcher) since gradle will not include them automatically for APK import
  697. gradle_build_env[f'CUSTOM_GRADLE_COPY_NATIVE_{native_config_upper}_LIB_TASK'] = \
  698. CUSTOM_GRADLE_COPY_NATIVE_CONFIG_FORMAT_STR.format(config=native_config,
  699. config_lower=native_config_lower,
  700. project_name=project_name,
  701. abi=ANDROID_ARCH,
  702. optional_test_excludes=",'*.Tests.so'" if not self.is_test_project else "")
  703. if self.is_test_project:
  704. gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = \
  705. CUSTOM_GRADLE_COPY_NATIVE_CONFIG_BUILD_ARTIFACTS_FORMAT_STR.format(config=native_config,
  706. config_lower=native_config_lower,
  707. asset_layout_folder=(self.build_dir / 'app/src/main/assets').resolve().as_posix(),
  708. file_includes='Test.Assets/**/*.*')
  709. else:
  710. # If assets must be included inside the APK do the assets layout under
  711. # 'main' folder so they will be included into the APK. Otherwise
  712. # do the layout under a different folder so it's created, but not
  713. # copied into the APK.
  714. if self.include_assets_in_apk:
  715. layout_folder = 'app/src/main/assets'
  716. else:
  717. layout_folder = 'app/src/assets'
  718. gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = \
  719. CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR.format(working_dir=common.normalize_path_for_settings(self.engine_root / 'cmake/Tools'),
  720. python_full_path=common.normalize_path_for_settings(self.engine_root / 'python' / PYTHON_SCRIPT),
  721. asset_type=self.asset_type,
  722. project_path=self.project_path.as_posix(),
  723. asset_mode=self.asset_mode if native_config != 'Release' else 'PAK',
  724. asset_layout_folder=(self.build_dir / layout_folder).resolve().as_posix(),
  725. config=native_config)
  726. # Copy over settings registry files from the Registry folder with build output directory
  727. gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] += \
  728. CUSTOM_GRADLE_COPY_REGISTRY_FOLDER_FORMAT_STR.format(config=native_config,
  729. config_lower=native_config_lower,
  730. asset_layout_folder=(self.build_dir / layout_folder).resolve().as_posix())
  731. gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] += \
  732. CUSTOM_GRADLE_COPY_REGISTRY_FOLDER_DEPENDENCY_FORMAT_STR.format(config=native_config)
  733. if self.signing_config:
  734. gradle_build_env[f'SIGNING_{native_config_upper}_CONFIG'] = f'signingConfig signingConfigs.{native_config_lower}' if self.signing_config else ''
  735. else:
  736. gradle_build_env[f'SIGNING_{native_config_upper}_CONFIG'] = ''
  737. if self.signing_config:
  738. gradle_build_env['SIGNING_CONFIGS'] = f"""
  739. signingConfigs {{
  740. debug {{{self.signing_config.to_template_string(3)}}}
  741. profile {{{self.signing_config.to_template_string(3)}}}
  742. release {{{self.signing_config.to_template_string(3)}}}
  743. }}
  744. """
  745. else:
  746. gradle_build_env['SIGNING_CONFIGS'] = ""
  747. gradle_build_env['PROJECT_NAMESPACE_OPTION'] = ""
  748. az_android_gradle_file = az_android_dst_path / 'build.gradle'
  749. self.create_file_from_project_template(src_template_file='build.gradle.in',
  750. template_env=gradle_build_env,
  751. dst_file=az_android_gradle_file)
  752. # Generate a AndroidManifest.xml and write to ${az_android_dst_path}/src/main/AndroidManifest.xml
  753. dest_src_main_path = az_android_dst_path / 'src/main'
  754. dest_src_main_path.mkdir(parents=True)
  755. az_android_package_env = AndroidProjectManifestEnvironment(engine_root=self.engine_root,
  756. project_path=self.project_path,
  757. android_sdk_version_number=self.android_sdk_platform,
  758. oculus_project=self.oculus_project,
  759. is_test=self.is_test_project)
  760. self.create_file_from_project_template(src_template_file=ANDROID_MANIFEST_FILE,
  761. template_env=az_android_package_env,
  762. dst_file=dest_src_main_path / ANDROID_MANIFEST_FILE)
  763. # Apply the 'android_builder.json' rules to copy over additional files to the target
  764. self.apply_android_builder_rules(az_android_dst_path=az_android_dst_path,
  765. az_android_package_env=az_android_package_env)
  766. self.resolve_icon_overrides(az_android_dst_path=az_android_dst_path,
  767. az_android_package_env=az_android_package_env)
  768. self.resolve_splash_overrides(az_android_dst_path=az_android_dst_path,
  769. az_android_package_env=az_android_package_env)
  770. self.clear_unused_assets(az_android_dst_path=az_android_dst_path,
  771. az_android_package_env=az_android_package_env)
  772. return [APP_NAME]
  773. def write_settings_gradle(self, project_list):
  774. """
  775. Generate and write the 'settings.gradle' and 'gradle.properties file at the root of the android project folder
  776. :param project_list: List of dependent projects to include in the gradle build
  777. """
  778. settings_gradle_lines = [f"include ':{project_name}'" for project_name in project_list]
  779. settings_gradle_content = '\n'.join(settings_gradle_lines)
  780. settings_gradle_file = self.build_dir / 'settings.gradle'
  781. settings_gradle_file.write_text(settings_gradle_content,
  782. encoding=common.DEFAULT_TEXT_READ_ENCODING,
  783. errors=common.ENCODING_ERROR_HANDLINGS)
  784. logging.info("Generated settings.gradle -> %s", str(settings_gradle_file.resolve()))
  785. # Write the default gradle.properties
  786. # TODO: Add substitution entries here if variables are added to gradle.properties.in
  787. # Refer to the Code/Tools/Android/ProjectBuilder/gradle.properties.in for reference.
  788. grade_properties_env = {}
  789. grade_properties_env['GRADLE_JVM_ARGS'] = ''
  790. gradle_properties_file = self.build_dir / 'gradle.properties'
  791. self.create_file_from_project_template(src_template_file='gradle.properties.in',
  792. template_env=grade_properties_env,
  793. dst_file=gradle_properties_file)
  794. logging.info("Generated gradle.properties -> %s", str(gradle_properties_file.resolve()))
  795. def apply_android_builder_rules(self, az_android_dst_path, az_android_package_env):
  796. """
  797. Apply the 'android_builder.json' rule file that was used by WAF to prepare the gradle application apk file.
  798. :param az_android_dst_path: The target application folder underneath the android target folder
  799. :param az_android_package_env: The template environment to use to process all the source template files
  800. """
  801. android_builder_json_path = self.android_project_builder_path / 'android_builder.json'
  802. android_builder_json_content = common.load_template_file(template_file_path=android_builder_json_path,
  803. template_env=az_android_package_env)
  804. android_builder_json = json.loads(android_builder_json_content)
  805. # Legacy files that don't need to be copied to the path (not needed for APK processing)
  806. skip_files = ['wscript']
  807. def _copy(src_file, dst_path, dst_is_directory):
  808. """
  809. Perform a specialized copy
  810. :param src_file: Source file to copy (relative to ${android_project_builder_path})
  811. :param dst_path: The destination to copy to
  812. :param dst_is_directory: Flag to indicate if the destination is a path or a file
  813. """
  814. if src_file in skip_files:
  815. # Filter out files that shouldn't be copied
  816. return
  817. src_path = self.android_project_builder_path / src_file
  818. resolved_src = src_path.resolve(strict=True)
  819. if imghdr.what(resolved_src) in ('rgb', 'gif', 'pbm', 'ppm', 'tiff', 'rast', 'xbm', 'jpeg', 'bmp', 'png'):
  820. # If the source file is a binary asset, then perform a copy to the target path
  821. logging.debug("Copy Binary file %s -> %s", str(src_path.resolve(strict=True)), str(dst_path.resolve()))
  822. dst_path.parent.mkdir(parents=True, exist_ok=True)
  823. shutil.copyfile(resolved_src, dst_path.resolve())
  824. else:
  825. if dst_is_directory:
  826. # If the dst_path is a directory, then we are copying the file to that directory
  827. dst_path.mkdir(parents=True, exist_ok=True)
  828. dst_file = dst_path / src_file
  829. else:
  830. # Otherwise, we are copying the file to the dst_path directly. A renaming may occur
  831. dst_path.parent.mkdir(parents=True, exist_ok=True)
  832. dst_file = dst_path
  833. project_activity_for_game_content = common.load_template_file(template_file_path=src_path,
  834. template_env=az_android_package_env)
  835. dst_file.write_text(project_activity_for_game_content)
  836. logging.debug("Copy/Update file %s -> %s", str(src_path.resolve(strict=True)), str(dst_path.resolve()))
  837. def _process_dict(node, dst_path):
  838. """
  839. Process a node from the android_builder.json file
  840. :param node: The node to process
  841. :param dst_path: The destination path derived from the node
  842. """
  843. assert isinstance(node, dict), f"Node for {android_builder_json_path} expected to be a dictionary"
  844. for key, value in node.items():
  845. if isinstance(value, str):
  846. _copy(key, dst_path / value, False)
  847. elif isinstance(value, list):
  848. for item in value:
  849. assert isinstance(node, dict), f"Unexpected type found in '{android_builder_json_path}'. Only lists of strings are supported"
  850. _copy(item, dst_path / key, True)
  851. elif isinstance(value, dict):
  852. _process_dict(value, dst_path / key)
  853. else:
  854. assert False, f"Unexpected type '{type(value)}' found in '{android_builder_json_path}'. Only str, list, and dict is supported"
  855. _process_dict(android_builder_json, az_android_dst_path)
  856. def construct_source_resource_path(self, source_path):
  857. """
  858. Helper to construct the source path to an asset override such as
  859. application icons or splash screen images
  860. :param source_path: Source path or file to attempt to locate
  861. :return: The path to the resource file
  862. """
  863. if os.path.isabs(source_path):
  864. # Always return itself if the path is already and absolute path
  865. return pathlib.Path(source_path)
  866. game_gem_resources = self.project_path / 'Gem' / 'Resources'
  867. if game_gem_resources.is_dir(game_gem_resources):
  868. # If the source is relative and the game gem's resource is present, construct the path based on that
  869. return game_gem_resources / source_path
  870. raise common.LmbrCmdError(f'Unable to locate resources folder for project at path "{self.project_path}"')
  871. def resolve_icon_overrides(self, az_android_dst_path, az_android_package_env):
  872. """
  873. Resolve any icon overrides
  874. :param az_android_dst_path: The destination android path (app project folder)
  875. :param az_android_package_env: Dictionary of env values to retrieve override information
  876. """
  877. dst_resource_path = az_android_dst_path / 'src/main/res'
  878. icon_overrides = az_android_package_env['ICONS']
  879. if not icon_overrides:
  880. return
  881. # if a default icon is specified, then copy it into the generic mipmap folder
  882. default_icon = icon_overrides.get('default', None)
  883. if default_icon is not None:
  884. src_default_icon_file = self.construct_source_resource_path(default_icon)
  885. default_icon_target_dir = dst_resource_path / MIPMAP_PATH_PREFIX
  886. default_icon_target_dir.mkdir(parents=True, exist_ok=True)
  887. dst_default_icon_file = default_icon_target_dir / APP_ICON_NAME
  888. shutil.copyfile(src_default_icon_file.resolve(), dst_default_icon_file.resolve())
  889. os.chmod(dst_default_icon_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
  890. else:
  891. logging.debug(f'No default icon override specified for project_at path {self.project_path}')
  892. # process each of the resolution overrides
  893. warnings = []
  894. for resolution in ANDROID_RESOLUTION_SETTINGS:
  895. target_directory = dst_resource_path / f'{MIPMAP_PATH_PREFIX}-{resolution}'
  896. target_directory.mkdir(parent=True, exist_ok=True)
  897. # get the current resolution icon override
  898. icon_source = icon_overrides.get(resolution, default_icon)
  899. if icon_source is default_icon:
  900. # if both the resolution and the default are unspecified, warn the user but do nothing
  901. if icon_source is None:
  902. warnings.append(f'No icon override found for "{resolution}". Either supply one for "{resolution}" or a '
  903. f'"default" in the android_settings "icon" section of the project.json file for {self.project_path}')
  904. # if only the resolution is unspecified, remove the resolution specific version from the project
  905. else:
  906. logging.debug(f'Default icon being used for "{resolution}" in {self.project_path}', resolution)
  907. common.remove_dir_path(target_directory)
  908. continue
  909. src_icon_file = self.construct_source_resource_path(icon_source)
  910. dst_icon_file = target_directory / APP_ICON_NAME
  911. shutil.copyfile(src_icon_file.resolve(), dst_icon_file.resolve())
  912. os.chmod(dst_icon_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
  913. # guard against spamming warnings in the case the icon override block is full of garbage and no actual overrides
  914. if len(warnings) != len(ANDROID_RESOLUTION_SETTINGS):
  915. for warning_msg in warnings:
  916. logging.warning(warning_msg)
  917. def resolve_splash_overrides(self, az_android_dst_path, az_android_package_env):
  918. """
  919. Resolve any splash screen overrides
  920. :param az_android_dst_path: The destination android path (app project folder)
  921. :param az_android_package_env: Dictionary of env values to retrieve override information
  922. """
  923. dst_resource_path = az_android_dst_path / 'src/main/res'
  924. splash_overrides = az_android_package_env['SPLASH_SCREEN']
  925. if not splash_overrides:
  926. return
  927. orientation = az_android_package_env['ORIENTATION']
  928. drawable_path_prefix = 'drawable-'
  929. for orientation_flag, orientation_key in ORIENTATION_FLAG_TO_KEY_MAP.items():
  930. orientation_path_prefix = drawable_path_prefix + orientation_key
  931. oriented_splashes = splash_overrides.get(orientation_key, {})
  932. unused_override_warning = None
  933. if (orientation & orientation_flag) == 0:
  934. unused_override_warning = f'Splash screen overrides specified for "{orientation_key}" when desired orientation ' \
  935. f'is set to "{ORIENTATION_FLAG_TO_KEY_MAP[orientation]}" in project {self.project_path}. ' \
  936. f'These overrides will be ignored.'
  937. # if a default splash image is specified for this orientation, then copy it into the generic drawable-<orientation> folder
  938. default_splash_img = oriented_splashes.get('default', None)
  939. if default_splash_img is not None:
  940. if unused_override_warning:
  941. logging.warning(unused_override_warning)
  942. continue
  943. src_default_splash_img_file = self.construct_source_resource_path(default_splash_img)
  944. dst_default_splash_img_dir = dst_resource_path / orientation_path_prefix
  945. dst_default_splash_img_dir.mkdir(parents=True, exist_ok=True)
  946. dst_default_splash_img_file = dst_default_splash_img_dir / APP_SPLASH_NAME
  947. shutil.copyfile(src_default_splash_img_file.resolve(), dst_default_splash_img_file.resolve())
  948. os.chmod(dst_default_splash_img_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
  949. else:
  950. logging.debug(f'No default splash screen override specified for "%s" orientation in %s', orientation_key,
  951. self.project_path)
  952. # process each of the resolution overrides
  953. warnings = []
  954. # The xxxhdpi resolution is only for application icons, its overkill to include them for drawables... for now
  955. valid_resolutions = set(ANDROID_RESOLUTION_SETTINGS)
  956. valid_resolutions.discard('xxxhdpi')
  957. for resolution in valid_resolutions:
  958. target_directory = dst_resource_path / f'{orientation_path_prefix}-{resolution}'
  959. # get the current resolution splash image override
  960. splash_img_source = oriented_splashes.get(resolution, default_splash_img)
  961. if splash_img_source is default_splash_img:
  962. # if both the resolution and the default are unspecified, warn the user but do nothing
  963. if splash_img_source is None:
  964. section = f"{orientation_key}-{resolution}"
  965. warnings.append(f'No splash screen override found for "{section}". Either supply one for "{resolution}" '
  966. f'or a "default" in the android_settings "splash_screen-{orientation_key}" section of the '
  967. f'project.json file for {self.project_path}.')
  968. else:
  969. # if only the resolution is unspecified, remove the resolution specific version from the project
  970. logging.debug(f'Default splash screen being used for "{orientation_key}-{resolution}" in {self.project_path}')
  971. common.remove_dir_path(target_directory)
  972. continue
  973. src_splash_img_file = self.construct_source_resource_path(splash_img_source)
  974. dst_splash_img_file = target_directory / APP_SPLASH_NAME
  975. shutil.copyfile(src_splash_img_file.resolve(), dst_splash_img_file.resolve())
  976. os.chmod(dst_splash_img_file.resolve(), stat.S_IWRITE | stat.S_IREAD)
  977. # guard against spamming warnings in the case the splash override block is full of garbage and no actual overrides
  978. if len(warnings) != len(valid_resolutions):
  979. if unused_override_warning:
  980. logging.warning(unused_override_warning)
  981. else:
  982. for warning_msg in warnings:
  983. logging.warning(warning_msg)
  984. @staticmethod
  985. def clear_unused_assets(az_android_dst_path, az_android_package_env):
  986. """
  987. micro-optimization to clear assets from the final bundle that won't be used
  988. :param az_android_dst_path: The destination android path (app project folder)
  989. :param az_android_package_env: Dictionary of env values to retrieve override information
  990. """
  991. orientation = az_android_package_env['ORIENTATION']
  992. if orientation == ORIENTATION_LANDSCAPE:
  993. path_prefix = 'drawable-land'
  994. elif orientation == ORIENTATION_PORTRAIT:
  995. path_prefix = 'drawable-port'
  996. else:
  997. return
  998. # Prepare all the sub-folders to clear
  999. clear_folders = [path_prefix]
  1000. clear_folders.extend([f'{path_prefix}-{resolution}' for resolution in ANDROID_RESOLUTION_SETTINGS if resolution != 'xxxhdpi'])
  1001. # Clear out the base folder
  1002. dst_resource_path = az_android_dst_path / 'src/main/res'
  1003. for clear_folder in clear_folders:
  1004. target_directory = dst_resource_path / clear_folder
  1005. if target_directory.is_dir():
  1006. logging.debug("Clearing folder %s", target_directory)
  1007. common.remove_dir_path(target_directory)
  1008. class _Library:
  1009. """
  1010. Library class to manage the library node in android_libraries.json
  1011. """
  1012. def __init__(self, name, path, overwrite_existing, signing_config=None):
  1013. self.name = name
  1014. self.path = path
  1015. self.signing_config = signing_config
  1016. self.overwrite_existing = overwrite_existing
  1017. self.patch_files = []
  1018. self.dependencies = []
  1019. self.build_dependencies = []
  1020. def add_file_to_patch(self, file):
  1021. self.patch_files.append(file)
  1022. def process_patch_lib(self, android_project_builder_path, dest_root):
  1023. """
  1024. Perform the patch logic on the library node of 'android_libraries.json' (root level)
  1025. :param android_project_builder_path: Path to the Android/ProjectBuilder path for the templates
  1026. :param dest_root: The target android project folder
  1027. """
  1028. # Clear out any existing target path's src and recreate
  1029. dst_path = dest_root / self.name
  1030. dst_path_src = dst_path / 'src'
  1031. if dst_path_src.exists():
  1032. common.remove_dir_path(dst_path_src)
  1033. dst_path.mkdir(parents=True, exist_ok=True)
  1034. logging.debug("Copying library '{}' to '{}'".format(self.name, dst_path))
  1035. # The folder structure from the base lib needs to be mapped to a structure that gradle can process as a
  1036. # build project, and we need to generate some additional files
  1037. # Generate the gradle build script for the library based on the build.gradle.in template file
  1038. gradle_dependencies = []
  1039. if self.build_dependencies:
  1040. gradle_dependencies.extend([f" api '{build_dependency}'" for build_dependency in self.build_dependencies])
  1041. if self.dependencies:
  1042. gradle_dependencies.extend([f" api project(path: ':{dependency}')" for dependency in self.dependencies])
  1043. if gradle_dependencies:
  1044. project_dependencies = "dependencies {{\n{}\n}}".format('\n'.join(gradle_dependencies))
  1045. else:
  1046. project_dependencies = ""
  1047. # Prepare an environment for a basic, no-native (cmake) gradle project (java only)
  1048. build_gradle_env = {
  1049. 'PROJECT_DEPENDENCIES': project_dependencies,
  1050. 'TARGET_TYPE': 'library',
  1051. 'NATIVE_CMAKE_SECTION_DEFAULT_CONFIG': '',
  1052. 'NATIVE_CMAKE_SECTION_ANDROID': '',
  1053. 'NATIVE_CMAKE_SECTION_DEBUG_CONFIG': '',
  1054. 'NATIVE_CMAKE_SECTION_PROFILE_CONFIG': '',
  1055. 'NATIVE_CMAKE_SECTION_RELEASE_CONFIG': '',
  1056. 'OVERRIDE_JAVA_SOURCESET': '',
  1057. 'OPTIONAL_JNI_SRC_LIB_SET': '',
  1058. 'CUSTOM_APPLY_ASSET_LAYOUT_DEBUG_TASK': '',
  1059. 'CUSTOM_APPLY_ASSET_LAYOUT_PROFILE_TASK': '',
  1060. 'CUSTOM_APPLY_ASSET_LAYOUT_RELEASE_TASK': '',
  1061. 'CUSTOM_GRADLE_COPY_NATIVE_DEBUG_LIB_TASK': '',
  1062. 'CUSTOM_GRADLE_COPY_NATIVE_PROFILE_LIB_TASK': '',
  1063. 'CUSTOM_GRADLE_COPY_NATIVE_RELEASE_LIB_TASK': '',
  1064. 'SIGNING_CONFIGS': '',
  1065. 'SIGNING_DEBUG_CONFIG': '',
  1066. 'SIGNING_PROFILE_CONFIG': '',
  1067. 'SIGNING_RELEASE_CONFIG': '',
  1068. 'PROJECT_NAMESPACE_OPTION': ''
  1069. }
  1070. build_gradle_content = common.load_template_file(template_file_path=android_project_builder_path / 'build.gradle.in',
  1071. template_env=build_gradle_env)
  1072. dest_gradle_script_file = dst_path / 'build.gradle'
  1073. if not dest_gradle_script_file.exists() or self.overwrite_existing:
  1074. dest_gradle_script_file.write_text(build_gradle_content,
  1075. encoding=common.DEFAULT_TEXT_WRITE_ENCODING,
  1076. errors=common.ENCODING_ERROR_HANDLINGS)
  1077. src_path = pathlib.Path(self.path)
  1078. # Prepare a 'src/main' folder
  1079. dst_src_main_path = dst_path / 'src/main'
  1080. dst_src_main_path.mkdir(parents=True, exist_ok=True)
  1081. # Prepare a copy mapping list of tuples to process the copying of files and perform the straight file
  1082. # copying
  1083. library_copy_subfolder_pairs = [('res', 'res'),
  1084. ('src', 'java')]
  1085. for copy_subfolder_pair in library_copy_subfolder_pairs:
  1086. src_subfolder = copy_subfolder_pair[0]
  1087. dst_subfolder = copy_subfolder_pair[1]
  1088. # {SRC}/{src_subfolder}/ -> {DST}/src/main/{dst_subfolder}/
  1089. src_library_res_path = src_path / src_subfolder
  1090. if not src_library_res_path.exists():
  1091. continue
  1092. dst_library_res_path = dst_src_main_path / dst_subfolder
  1093. shutil.copytree(src_library_res_path.resolve(),
  1094. dst_library_res_path.resolve(),
  1095. copy_function=shutil.copyfile)
  1096. # Process the files identified for patching
  1097. for file in self.patch_files:
  1098. input_file_path = src_path / file.path
  1099. if file.path == ANDROID_MANIFEST_FILE:
  1100. # Special case: AndroidManifest.xml does not go under the java/ parent path
  1101. output_file_path = dst_src_main_path / ANDROID_MANIFEST_FILE
  1102. else:
  1103. 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
  1104. output_file_path = dst_src_main_path / output_subpath
  1105. logging.debug(" Patching file '%s'", os.path.basename(file.path))
  1106. with open(input_file_path.resolve()) as input_file:
  1107. lines = input_file.readlines()
  1108. with open(output_file_path.resolve(), 'w') as outFile:
  1109. for replace in file.changes:
  1110. lines[replace.line] = str.replace(lines[replace.line], replace.old,
  1111. (replace.new if replace.new else ""), 1)
  1112. outFile.write(''.join([line for line in lines if line]))
  1113. class _File:
  1114. """
  1115. Helper class to manage individual files for each library (_Library) and their changes
  1116. """
  1117. def __init__(self, path):
  1118. self.path = path
  1119. self.changes = []
  1120. def add_change(self, change):
  1121. self.changes.append(change)
  1122. class _Change:
  1123. """
  1124. Helper class to manage a change/patch as defined in android_libraries.json
  1125. """
  1126. def __init__(self, line, old, new):
  1127. self.line = line
  1128. self.old = old
  1129. self.new = new
  1130. ANDROID_SDK_ENV_NAME = 'ANDROID_SDK'
  1131. def resolve_adb_tool(android_sdk_path):
  1132. """
  1133. Resolve the location of the adb tool based on the input Android SDK Path
  1134. :param android_sdk_path: The android SDK path to search for the adb tool
  1135. :return: The absolute path to the adb tool
  1136. """
  1137. if isinstance(android_sdk_path, str):
  1138. android_sdk_path = pathlib.Path(android_sdk_path)
  1139. file_found = False
  1140. for executable_path_ext in common.PLATFORM_EXECUTABLE_EXTENSIONS:
  1141. check_adb_target = android_sdk_path / 'platform-tools' / f'adb{executable_path_ext}'
  1142. if check_adb_target.is_file():
  1143. file_found = True
  1144. break
  1145. if not file_found:
  1146. raise common.LmbrCmdError(f"Invalid Android SDK path '{str(android_sdk_path)}': Unable to locate 'adb'.")
  1147. return check_adb_target
  1148. class AdbTool(common.CommandLineExec):
  1149. """
  1150. Custom ADB command line processor
  1151. """
  1152. def __init__(self, android_sdk_path):
  1153. check_adb_target = resolve_adb_tool(android_sdk_path)
  1154. super().__init__(str(check_adb_target))
  1155. self.is_connected = False
  1156. self.device_filter = None
  1157. def get_connected_device_serial_ids(self):
  1158. """
  1159. Get the connected android device serial numbers through adb
  1160. :return: List of device serial numbers of android devices currently connected
  1161. """
  1162. ret, devices_result, _ = super().exec(['devices'], capture_stdout=True)
  1163. if ret != 0:
  1164. raise common.LmbrCmdError("Unable to get device list from adb")
  1165. connected_device_serials = []
  1166. device_result_lines = [device_result_line.strip() for device_result_line in devices_result.split('\n') if
  1167. device_result_line]
  1168. for device_result_line in device_result_lines:
  1169. match_result = re.match(r'([\w-]+)\s+(device)', device_result_line)
  1170. if match_result:
  1171. device_id = match_result.group(1)
  1172. connected_device_serials.append(device_id)
  1173. return connected_device_serials
  1174. def connect(self, device_filter=None):
  1175. """
  1176. Start the adb server
  1177. :param device_filter: Any device to filter subsequent commands to in situations where there may be multiple devices connected
  1178. """
  1179. if self.is_connected:
  1180. raise common.LmbrCmdError("Adb connection already started")
  1181. super().exec(['start-server'])
  1182. if device_filter:
  1183. # If a device serial id was passed in, then verify if its valid
  1184. device_matched = False
  1185. connected_serial_ids = self.get_connected_device_serial_ids()
  1186. for connected_serial_id in connected_serial_ids:
  1187. if device_filter == connected_serial_id:
  1188. device_matched = True
  1189. break
  1190. if not device_matched:
  1191. raise common.LmbrCmdError(f"Invalid device serial {device_filter}. The current connected device serial ids are : {','.join(connected_serial_ids)}")
  1192. self.device_filter = device_filter
  1193. self.is_connected = True
  1194. def disconnect(self):
  1195. """
  1196. Stop the adb server
  1197. """
  1198. super().exec(['kill-server'])
  1199. self.is_connected = False
  1200. self.device_filter = None
  1201. def exec(self, arguments, capture_stdout=False, cwd=None):
  1202. """
  1203. Wrapper to the base 'exec' call which may append an optional device filter to the adb calls
  1204. :param arguments: 'arguments' to pass to the base exec
  1205. :param capture_stdout: 'capture_stdout' to pass to the base exec
  1206. :param cwd: 'cwd' to pass to the base exec
  1207. :return: Result of the call (see common.CommandLineExec.exec)
  1208. """
  1209. if self.device_filter:
  1210. adb_params = ['-s', self.device_filter]
  1211. adb_params.extend(arguments)
  1212. else:
  1213. adb_params = arguments
  1214. return super().exec(adb_params, capture_stdout, cwd)
  1215. def popen(self, arguments, cwd=None):
  1216. """
  1217. Wrapper to the base 'popen' call which may append an optional device filter to the adb calls
  1218. :param arguments: 'arguments' to pass to the base popen
  1219. :param cwd: 'cwd' to pass to the base exec
  1220. :return: Result of the call (see common.CommandLineExec.popen)
  1221. """
  1222. if self.device_filter:
  1223. adb_params = ['-s', self.device_filter]
  1224. adb_params.extend(arguments)
  1225. else:
  1226. adb_params = arguments
  1227. return super().popen(adb_params, cwd)
  1228. class AndroidGradlePluginInfo(object):
  1229. def __init__(self, android_gradle_plugin_version):
  1230. if android_gradle_plugin_version not in ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.keys():
  1231. raise common.LmbrCmdError(f"Android Gradle Plugin version {android_gradle_plugin_version} is not supported. "
  1232. f"Only the following version(s) are supported: {','.join(ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.keys())}")
  1233. details = ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP[android_gradle_plugin_version]
  1234. self.default_sdk_build_tools_version = Version(details.get('sdk_build'))
  1235. self.default_ndk_version = Version(details.get('default_ndk'))
  1236. self.min_gradle_version = Version(details.get('min_gradle_version'))
  1237. self.min_cmake_version = Version(details.get('min_cmake_version'))
  1238. max_cmake_version_number = details.get('max_cmake_version')
  1239. self.max_cmake_version = None if max_cmake_version_number is None else Version(max_cmake_version_number)
  1240. class AndroidSDKResolver(object):
  1241. """
  1242. Class that manages the Android SDK tool to validate, install packages (e.g. built tools, sdk platforms, ndk, etc)
  1243. """
  1244. class BasePackage(object):
  1245. def __init__(self, components):
  1246. self.path = components[0]
  1247. self.version = Version(components[1].strip().replace(' ', '.')) # Fix for versions that have spaces between the version number and potential non-numeric versioning (PEP-0440)
  1248. self.description = components[2]
  1249. class InstalledPackage(BasePackage):
  1250. def __init__(self, installed_package_components):
  1251. super().__init__(installed_package_components)
  1252. assert len(installed_package_components) == 4, '4 sections expected for installed package components (path, version, description, location)'
  1253. self.location = installed_package_components[3]
  1254. class AvailablePackage(BasePackage):
  1255. def __init__(self, available_package_components):
  1256. super().__init__(available_package_components)
  1257. assert len(available_package_components) == 3, '3 sections expected for installed package components (path, version, description)'
  1258. class AvailableUpdate(BasePackage):
  1259. def __init__(self, available_update_components):
  1260. super().__init__(available_update_components)
  1261. assert len(available_update_components) == 3, '3 sections expected for installed package components (path, version, available)'
  1262. def __init__(self, android_sdk_path, command_line_tools_version):
  1263. self.android_sdk_path = android_sdk_path or os.environ.get(ANDROID_SDK_ENV_NAME)
  1264. if not self.android_sdk_path:
  1265. raise common.LmbrCmdError(f"Android SDK path not set or it was not passed into the command to generate the android project")
  1266. if not os.path.isdir(self.android_sdk_path):
  1267. raise common.LmbrCmdError(f"Android SDK path {self.android_sdk_path} is not valid")
  1268. sdk_root = pathlib.Path(self.android_sdk_path)
  1269. tools_path = sdk_root / 'cmdline-tools'
  1270. if tools_path.exists():
  1271. tools_path = tools_path / command_line_tools_version
  1272. if not tools_path.exists():
  1273. raise common.LmbrCmdError(f"The desired version of the Android 'cmdline-tools' ({command_line_tools_version}) is not detected")
  1274. else:
  1275. tools_path = sdk_root / 'tools'
  1276. ext = ''
  1277. if platform.system() == 'Windows':
  1278. ext = '.bat'
  1279. self.sdk_manager_path = tools_path / 'bin' / f'sdkmanager{ext}'
  1280. if not self.sdk_manager_path.is_file():
  1281. raise common.LmbrCmdError(f"Android SDK path {self.android_sdk_path} is not valid or complete. Missing {self.sdk_manager_path}")
  1282. self.sdk_manager = common.CommandLineExec(str(self.sdk_manager_path.resolve()))
  1283. self.installed_packages = {}
  1284. self.available_packages = {}
  1285. self.available_updates = {}
  1286. self.refresh_sdk_installation()
  1287. def call_sdk_manager(self, arguments, action):
  1288. result_code, result_stdout, result_stderr = self.sdk_manager.exec(arguments, capture_stdout=True, suppress_stderr=False)
  1289. if result_code != 0:
  1290. # Provide a more concise error if a known execption is thrown indicating the wrong version of JAVA is set in the environment
  1291. if "java.lang.UnsupportedClassVersionError" in result_stderr:
  1292. err_msg = "The current installed java runtime is not compatible with the sdkmanager. Make sure that " \
  1293. "java runtime version 11 is installed and JAVA_HOME is set to that path."
  1294. else:
  1295. err_msg = result_stderr
  1296. raise common.LmbrCmdError(f"An error occurred while {action}: \n{err_msg}")
  1297. return result_stdout
  1298. def refresh_sdk_installation(self):
  1299. """
  1300. Utilize the sdk_manager command line tool from the Android SDK to collect / refresh the list of
  1301. installed, available, and updateable packages that are managed by the android SDK.
  1302. """
  1303. self.installed_packages = {}
  1304. self.available_packages = {}
  1305. self.available_updates = {}
  1306. def _factory_installed_package(package_map, item_components):
  1307. package_map[item_components[0]] = AndroidSDKResolver.InstalledPackage(item_components)
  1308. def _factory_available_package(package_map, item_components):
  1309. package_map[item_components[0]] = AndroidSDKResolver.AvailablePackage(item_components)
  1310. def _factory_available_update(package_map, item_components):
  1311. package_map[item_components[0]] = AndroidSDKResolver.AvailableUpdate(item_components)
  1312. # Use the SDK manager to collect the available and installed packages
  1313. result_stdout = self.call_sdk_manager(['--list'], "retrieving package list")
  1314. current_append_map = None
  1315. current_item_factory = None
  1316. for package_item in result_stdout.split('\n'):
  1317. package_item_stripped = package_item.strip()
  1318. if not package_item_stripped:
  1319. continue
  1320. if '|' not in package_item_stripped:
  1321. if package_item_stripped.upper() == 'INSTALLED PACKAGES:':
  1322. current_append_map = self.installed_packages
  1323. current_item_factory = _factory_installed_package
  1324. elif package_item_stripped.upper() == 'AVAILABLE PACKAGES:':
  1325. current_append_map = self.available_packages
  1326. current_item_factory = _factory_available_package
  1327. elif package_item_stripped.upper() == 'AVAILABLE UPDATES:':
  1328. current_append_map = self.available_updates
  1329. current_item_factory = _factory_available_update
  1330. else:
  1331. current_append_map = None
  1332. current_item_factory = None
  1333. continue
  1334. item_parts = [split.strip() for split in package_item_stripped.split('|')]
  1335. if len(item_parts) < 3:
  1336. continue
  1337. elif item_parts[1].upper() in ('VERSION', 'INSTALLED', '-------'):
  1338. continue
  1339. elif current_append_map is None:
  1340. continue
  1341. if current_append_map is not None and current_item_factory is not None:
  1342. current_item_factory(current_append_map, item_parts)
  1343. def is_package_installed(self, search_package_path):
  1344. """
  1345. Check if a package path to see if its a package that is installed. The path can use wildcard '*'s
  1346. The function will return a list of the results that match the package paths, ordered by the newest version first
  1347. """
  1348. def _package_sort(package):
  1349. return package.version
  1350. package_detail_result_list = []
  1351. for installed_package_path, installed_package_details in self.installed_packages.items():
  1352. if fnmatch.fnmatch(installed_package_path, search_package_path):
  1353. package_detail_result_list.append(installed_package_details)
  1354. package_detail_result_list.sort(reverse=True, key=_package_sort)
  1355. return package_detail_result_list
  1356. def is_package_available(self, search_package_path):
  1357. """
  1358. Check if a package path to see if its an available package to install. The path can use wildcard '*'s
  1359. The function will return a list of the results that match the package paths, ordered by the newest version first
  1360. """
  1361. def _package_sort(package):
  1362. return package.version
  1363. package_detail_result_list = []
  1364. for available_package_path, available_package_details in self.available_packages.items():
  1365. if fnmatch.fnmatch(available_package_path, search_package_path):
  1366. package_detail_result_list.append(available_package_details)
  1367. package_detail_result_list.sort(reverse=True, key=_package_sort)
  1368. return package_detail_result_list
  1369. def install_package(self, package_install_path, package_description):
  1370. """
  1371. Install a package based on the path of an available android sdk package
  1372. """
  1373. # Skip installation if the package is already installed
  1374. package_result_list = self.is_package_installed(package_install_path)
  1375. if package_result_list:
  1376. installed_package_detail = package_result_list[0]
  1377. logging.info(f"{installed_package_detail.description} (version {installed_package_detail.version}) Detected")
  1378. return installed_package_detail
  1379. # Make sure the package name is available
  1380. package_result_list = self.is_package_available(package_install_path)
  1381. if not package_result_list:
  1382. raise common.LmbrCmdError(f"Invalid Android SDK Package {package_description}: Bad package path {package_install_path}")
  1383. # Reverse sort and pick the first item, which should be the latest (if the install path contains wildcards)
  1384. def _available_sort(item):
  1385. return item.path
  1386. package_result_list.sort(reverse=True, key=_available_sort)
  1387. available_package_to_install = package_result_list[0] # For multiple hits, resolve to the first item which will be the latest version
  1388. # Perform the package installation
  1389. logging.info(f"Installing {available_package_to_install.description} ...")
  1390. self.call_sdk_manager(['--install', available_package_to_install.path], f"installing package {available_package_to_install.path}")
  1391. # Refresh the tracked SDK Contents
  1392. self.refresh_sdk_installation()
  1393. # Get the package details to verify
  1394. package_result_list = self.is_package_installed(package_install_path)
  1395. if package_result_list:
  1396. installed_package_detail = package_result_list[0]
  1397. logging.info(f"{installed_package_detail.description} (version {installed_package_detail.version}) Installed")
  1398. return installed_package_detail
  1399. else:
  1400. raise common.LmbrCmdError(f"Unable to verify package at {available_package_to_install.path}")