Explorar o código

Android Build Scripts Update (#17078)

Add new android-specific o3de script commands to support Android project generation scripts for Android Gradle Plugin 8.0+

* android-configure
* android-generate

Signed-off-by: Steve Pham <[email protected]>
Steve Pham hai 1 ano
pai
achega
e302882cb7

+ 2 - 0
.gitignore

@@ -2,6 +2,7 @@
 .venv/
 .vs/
 .vscode/
+.gradle/
 __pycache__
 /[Bb]uild
 [Oo]ut/**
@@ -21,3 +22,4 @@ server*.cfg
 _savebackup/
 *.swatches
 /imgui.ini
+.command_settings

+ 1 - 1
Code/Tools/Android/ProjectBuilder/AndroidManifest.xml

@@ -2,7 +2,7 @@
 <!-- BEGIN_INCLUDE(manifest) -->
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
-    package="${ANDROID_PACKAGE}"
+    ${ANDROID_MANIFEST_PACKAGE_OPTION}
     android:versionCode="${ANDROID_VERSION_NUMBER}"
     android:versionName="${ANDROID_VERSION_NAME}">
 

+ 1 - 0
Code/Tools/Android/ProjectBuilder/build.gradle.in

@@ -8,6 +8,7 @@
 apply plugin: "com.android.${TARGET_TYPE}"
 
 android {
+    ${PROJECT_NAMESPACE_OPTION}
 ${SIGNING_CONFIGS}
     compileSdkVersion sdkVer
     buildToolsVersion buildToolsVer

+ 2 - 2
Code/Tools/Android/ProjectBuilder/gradle.properties.in

@@ -19,8 +19,8 @@ org.gradle.parallel=true
 # there are a large number of sub-projects
 org.gradle.configureondemand=false
 
-# bump the JVM memory limits due to the size of Lumberyard. Defaults: -Xmx1280m -XX:MaxPermSize=256m
-org.gradle.jvmargs=
+# Customize the jvm arguments to control things like memory limits.
+org.gradle.jvmargs=${GRADLE_JVM_ARGS}
 
 # required to use Android X libraries
 android.useAndroidX=true

+ 69 - 1
cmake/Platform/Android/LYWrappers_android.cmake

@@ -6,4 +6,72 @@
 #
 #
 
-include(cmake/Platform/Common/LYWrappers_default.cmake)
+set(LY_STRIP_DEBUG_SYMBOLS FALSE CACHE BOOL "Flag to strip debug symbols from the (non-debug) output binaries")
+
+# Check if 'llvm-strip' is available so that debug symbols can be stripped from output libraries and executables.
+find_program(LLVM_STRIP_TOOL llvm-strip)
+if (NOT LLVM_STRIP_TOOL)
+    message(WARNING "Unable to locate 'strip' tool needed to strip debug symbols from the output target(s). "
+                    "Debug symbols will not be removed from output libraries and executables.")
+endif()
+
+function(ly_apply_platform_properties target)
+    # Noop
+endfunction()
+
+function(ly_handle_custom_output_directory target output_subdirectory)
+
+    if(output_subdirectory)
+        set_target_properties(${target} PROPERTIES
+            RUNTIME_OUTPUT_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${output_subdirectory}
+            LIBRARY_OUTPUT_DIRECTORY ${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/${output_subdirectory}
+        )
+
+        foreach(conf ${CMAKE_CONFIGURATION_TYPES})
+            string(TOUPPER ${conf} UCONF)
+            set_target_properties(${target} PROPERTIES
+                RUNTIME_OUTPUT_DIRECTORY_${UCONF} ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_${UCONF}}/${output_subdirectory}
+                LIBRARY_OUTPUT_DIRECTORY_${UCONF} ${CMAKE_LIBRARY_OUTPUT_DIRECTORY_${UCONF}}/${output_subdirectory}
+            )
+        endforeach()
+
+    endif()
+
+endfunction()
+
+#! ly_apply_debug_strip_options: Apply debug stripping options to the target output for non-debug configurations.
+#
+#\arg:target Name of the target to perform a post-build stripping of any debug symbol)
+function(ly_apply_debug_strip_options target)
+
+    if (NOT LLVM_STRIP_TOOL)
+        return()
+    endif()
+
+    # If the target is IMPORTED, then there is no post-build process
+    get_target_property(is_imported ${target} IMPORTED)
+    if (${is_imported})
+        return()
+    endif()
+
+    # Check the target type
+    get_target_property(target_type ${target} TYPE)
+
+    # This script only supports executables, applications, modules, and shared libraries
+    if (NOT ${target_type} STREQUAL "MODULE_LIBRARY" AND
+        NOT ${target_type} STREQUAL "SHARED_LIBRARY" AND
+        NOT ${target_type} STREQUAL "EXECUTABLE" AND
+        NOT ${target_type} STREQUAL "APPLICATION")
+        return()
+    endif()
+
+    add_custom_command(TARGET ${target} POST_BUILD
+        COMMAND "${CMAKE_COMMAND}" -P "${LY_ROOT_FOLDER}/cmake/Platform/Android/StripDebugSymbols.cmake"
+                ${LLVM_STRIP_TOOL}
+                "$<TARGET_FILE:${target}>"
+                ${target_type}
+        COMMENT "Stripping debug symbols ..."
+        VERBATIM
+    )
+
+endfunction()

+ 29 - 0
cmake/Platform/Android/StripDebugSymbols.cmake

@@ -0,0 +1,29 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+if(${CMAKE_ARGC} LESS 5)
+    message(FATAL_ERROR "StripDebugSymbols script called with the wrong number of arguments: ${CMAKE_ARGC}")
+endif()
+
+# cmake command arguments (not including the first 2 arguments of '-P', 'StripDebugSymbols.cmake')
+
+set(LLVM_STRIP_TOOL ${CMAKE_ARGV3})          # LLVM_STRIP_TOOL      : The location of the llvm strip tool (e.g. <ndk-root>/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-strip.exe)
+set(STRIP_TARGET ${CMAKE_ARGV4})            # STRIP_TARGET        : The built binary to process
+set(TARGET_TYPE ${CMAKE_ARGV5})             # TARGET_TYPE         : The target type (STATIC_LIBRARY, MODULE_LIBRARY, SHARED_LIBRARY, EXECUTABLE, or APPLICATION)
+
+# This script only supports executables, applications, modules and shared libraries
+if (NOT ${TARGET_TYPE} STREQUAL "MODULE_LIBRARY" AND
+    NOT ${TARGET_TYPE} STREQUAL "SHARED_LIBRARY" AND
+    NOT ${TARGET_TYPE} STREQUAL "EXECUTABLE" AND
+    NOT ${TARGET_TYPE} STREQUAL "APPLICATION")
+    return()
+endif()
+
+message(VERBOSE "Stripping debug symbols from ${STRIP_TARGET}")
+
+execute_process(COMMAND ${LLVM_STRIP_TOOL} --strip-debug ${STRIP_TARGET})

+ 244 - 0
cmake/Tools/Platform/Android/android_post_build.py

@@ -0,0 +1,244 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import argparse
+import re
+import shutil
+import sys
+
+import platform
+import logging
+
+from packaging.version import Version
+from pathlib import Path
+
+logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.INFO)
+logger = logging.getLogger('o3de.android')
+
+ANDROID_ARCH = 'arm64-v8a'
+
+ASSET_MODE_PAK     = 'PAK'
+ASSET_MODE_LOOSE   = 'LOOSE'
+SUPPORTED_ASSET_MODES = [ASSET_MODE_PAK, ASSET_MODE_LOOSE]
+ASSET_PLATFORM_KEY = 'android'
+
+SUPPORTED_BUILD_CONFIGS = ['debug', 'profile', 'release']
+
+MINIMUM_ANDROID_GRADLE_PLUGIN_VER = Version("4.2")
+
+IS_PLATFORM_WINDOWS = platform.system() == 'Windows'
+
+PAK_FILE_INSTRUCTIONS = "Make sure to create release bundles (Pak files) before building and deploying to an android device. Refer to " \
+                        "https://www.docs.o3de.org/docs/user-guide/packaging/asset-bundler/bundle-assets-for-release/ for more" \
+                        "information."
+
+
+class AndroidPostBuildError(Exception):
+    pass
+
+
+def create_link(src: Path, tgt: Path):
+    """
+    Create a link/junction depending on the source type. If this is a file, then perform file copy from the
+    source to the target. If it is a directory, then create a junction(windows) or symlink(linux/mac) from the source to
+    the target
+
+    :param src: The source to link from
+    :param tgt: The target tp link to
+    """
+
+    assert src.exists()
+    assert not tgt.exists()
+
+    try:
+        if src.is_file():
+            tgt.hardlink_to(src)
+            logger.info(f"Created hard link {str(src)} to {str(tgt)}")
+        else:
+            if IS_PLATFORM_WINDOWS:
+                import _winapi
+                _winapi.CreateJunction(str(src.resolve().absolute()), str(tgt.resolve().absolute()))
+                logger.info(f'Created Junction {str(src)} => {str(tgt)}')
+            else:
+                tgt.symlink_to(src, target_is_directory=True)
+                logger.info(f'Created symbolic link {str(src)} => {str(tgt)}')
+
+    except OSError as os_err:
+        raise AndroidPostBuildError(f"Error trying to link  {src} => {tgt} : {os_err}")
+
+
+def safe_clear_folder(target_folder: Path) -> None:
+    """
+    Safely clean the contents of a folder. If items are links/junctions, then attempt to unlink, but if the
+    items are non-linked, then perform a deletion.
+
+    :param target_folder:   Folder to safely clear
+    :param delete_folders:  Optional set of folders to perform a delete on instead of an unlink
+    """
+
+    if not target_folder.exists():
+        logger.warn(f"Nothing to clean. '{target_folder}' does not exist")
+        return
+
+    if not target_folder.is_dir():
+        logger.warn(f"Unable to clean '{target_folder}', target is not a directory")
+        return
+
+    for target_item in target_folder.iterdir():
+        try:
+            target_item.unlink()
+        except OSError as os_err:
+            raise AndroidPostBuildError(f"Error trying to unlink/delete {target_item}: {os_err}")
+
+
+def synchronize_folders(src: Path, tgt: Path) -> None:
+    """
+    Create a copy of a source folder 'src' to a target folder 'tgt', but use the following rules:
+    1. Make sure that a 'tgt' folder exists
+    2. For each item in the 'src' folder, call 'copy_or_create_link' to apply a copy or link of the items to the
+       target folder 'tgt'
+
+    :param src:             The source folder to synchronize from
+    :param tgt:             The target folder to synchronize to
+    """
+
+    assert not tgt.is_file()
+    assert src.is_dir()
+
+    # Make sure the target folder exists
+    tgt.mkdir(parents=True, exist_ok=True)
+
+    # Iterate through the items in the source folder and copy to the target folder
+    processed = 0
+    for src_item in src.iterdir():
+        tgt_item = tgt / src_item.name
+        create_link(src_item, tgt_item)
+        processed += 1
+
+    logger.info(f"{processed} items from {src} linked/copied to {tgt}")
+
+
+def apply_pak_layout(project_root: Path, asset_bundle_folder: str, target_layout_root: Path) -> None:
+    """
+    Apply the pak folder layout to the target assets folder
+
+    :param project_root:            The project root folder to base the search for the location of the pak files (Bundle)
+    :param asset_bundle_folder:     The sub path within the project root folder of the location of the pak files
+    :param target_layout_root:      The target layout destination of the pak files
+    """
+
+    src_pak_file_full_path = project_root / asset_bundle_folder
+
+    # Make sure that the source bundle folder where we look up the paks exist
+    if not src_pak_file_full_path.is_dir():
+        raise AndroidPostBuildError(f"Pak files are expected at location {src_pak_file_full_path}, but the folder doesnt exist. {PAK_FILE_INSTRUCTIONS}")
+
+    # Make sure that we have at least the engine_android.pak file
+    has_engine_android_pak = False
+    for pak_dir_item in src_pak_file_full_path.iterdir():
+        if pak_dir_item.is_file and str(pak_dir_item.name).lower() == f'engine_{ASSET_PLATFORM_KEY}.pak':
+            has_engine_android_pak = True
+            break
+    if not has_engine_android_pak:
+        raise AndroidPostBuildError(f"Unable to located the required 'engine_android.pak' file at location specified at {src_pak_file_full_path}. "
+                                    f"{PAK_FILE_INSTRUCTIONS}")
+
+    # Clear out the target folder first
+    safe_clear_folder(target_layout_root)
+
+    # Copy/Link the contents to the target folder
+    synchronize_folders(src_pak_file_full_path, target_layout_root)
+
+
+def apply_loose_layout(project_root: Path, target_layout_root: Path) -> None:
+    """
+    Apply the loose assets folder layout rules to the target assets folder
+
+    :param project_root:            The project folder root to look for the loose assets
+    :param target_layout_root:      The target layout destination of the loose assets
+    """
+
+    android_cache_folder = project_root / 'Cache' / ASSET_PLATFORM_KEY
+    engine_json_marker = android_cache_folder / 'engine.json'
+    if not engine_json_marker.is_file():
+        raise AndroidPostBuildError(f"Assets have not been built for this project at ({project_root}) yet. "
+                                    f"Please run the AssetProcessor for this project first.")
+
+    # Clear out the target folder first
+    safe_clear_folder(target_layout_root)
+
+    # Copy/Link the contents to the target folder
+    synchronize_folders(android_cache_folder, target_layout_root)
+
+
+def post_build_action(android_app_root: Path, project_root: Path, gradle_version: Version,
+                      asset_mode: str, asset_bundle_folder: str):
+    """
+    Perform the post-build logic for android native builds that will prepare the output folders by laying out the asset files
+    to their locations before the APK is generated.
+
+    :param android_app_root:    The root path of the 'app' project within the Android Gradle build script
+    :param project_root:        The root of the project that the APK is being built for
+    :param gradle_version:      The version of Gradle used to build the APK (for validation)
+    :param asset_mode:          The desired asset mode to determine the layout rules
+    :param asset_bundle_folder: (For PAK asset modes) the location of where the PAK files are expected.
+    """
+
+    if not android_app_root.is_dir():
+        raise AndroidPostBuildError(f"Invalid Android Gradle build path: {android_app_root} is not a directory or does not exist.")
+
+    if gradle_version < MINIMUM_ANDROID_GRADLE_PLUGIN_VER:
+        raise AndroidPostBuildError(f"Android gradle plugin versions below version {MINIMUM_ANDROID_GRADLE_PLUGIN_VER} is not supported.")
+    logger.info(f"Applying post-build for android gradle plugin version {gradle_version}")
+
+    # Validate the build directory exists
+    app_build_root = android_app_root / 'build'
+    if not app_build_root.is_dir():
+        raise AndroidPostBuildError(f"Android gradle build path: {app_build_root} is not a directory or does not exist.")
+
+    target_layout_root = android_app_root / 'src' / 'main' / 'assets'
+
+    if asset_mode == ASSET_MODE_LOOSE:
+
+        apply_loose_layout(project_root=project_root,
+                           target_layout_root=target_layout_root)
+
+    elif asset_mode == ASSET_MODE_PAK:
+
+        apply_pak_layout(project_root=project_root,
+                         target_layout_root=target_layout_root,
+                         asset_bundle_folder=asset_bundle_folder)
+
+    else:
+        raise AndroidPostBuildError(f"Invalid Asset Mode '{asset_mode}'.")
+
+
+if __name__ == '__main__':
+
+    try:
+        parser = argparse.ArgumentParser(description="Android post Gradle build step handler")
+        parser.add_argument('android_app_root', type=str, help="The base of the 'app' in the O3DE generated gradle script.")
+        parser.add_argument('--project-root', type=str, help="The project root.", required=True)
+        parser.add_argument('--gradle-version', type=str, help="The version of Gradle.", required=True)
+        parser.add_argument('--asset-mode', type=str, help="The asset mode of deployment", default=ASSET_MODE_LOOSE, choices=SUPPORTED_ASSET_MODES)
+        parser.add_argument('--asset-bundle-folder', type=str, help="The sub folder from the project root where the pak files are located (For Pak Asset Mode)",
+                            default="AssetBundling/Bundles")
+
+        args = parser.parse_args(sys.argv[1:])
+
+        post_build_action(android_app_root=Path(args.android_app_root),
+                          project_root=Path(args.project_root),
+                          gradle_version=Version(args.gradle_version),
+                          asset_mode=args.asset_mode,
+                          asset_bundle_folder=args.asset_bundle_folder)
+
+        exit(0)
+
+    except AndroidPostBuildError as err:
+        logging.error(str(err))
+        exit(1)

+ 8 - 2
cmake/Tools/Platform/Android/android_support.py

@@ -185,8 +185,9 @@ class AndroidProjectManifestEnvironment(object):
                 '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'],
+                'OCULUS_INTENT_FILTER_CATEGORY':    oculus_intent_filter_category,
 
-                'OCULUS_INTENT_FILTER_CATEGORY':    oculus_intent_filter_category
+                'ANDROID_MANIFEST_PACKAGE_OPTION': f'package="{package_name}"',  # Legacy gradle 4.x support
             }
         except KeyError as e:
             raise common.LmbrCmdError(f"Missing key from android project settings for project at {project_path}:'{e}' ")
@@ -944,6 +945,8 @@ class AndroidProjectGenerator(object):
         else:
             gradle_build_env['SIGNING_CONFIGS'] = ""
 
+        gradle_build_env['PROJECT_NAMESPACE_OPTION'] = ""
+
         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,
@@ -996,6 +999,8 @@ class AndroidProjectGenerator(object):
         # 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 = {}
+        grade_properties_env['GRADLE_JVM_ARGS'] = ''
+
         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,
@@ -1346,7 +1351,8 @@ class AndroidProjectGenerator(object):
                 'SIGNING_CONFIGS': '',
                 'SIGNING_DEBUG_CONFIG': '',
                 'SIGNING_PROFILE_CONFIG': '',
-                'SIGNING_RELEASE_CONFIG': ''
+                'SIGNING_RELEASE_CONFIG': '',
+                'PROJECT_NAMESPACE_OPTION': ''
             }
             build_gradle_content = common.load_template_file(template_file_path=android_project_builder_path / 'build.gradle.in',
                                                              template_env=build_gradle_env)

+ 184 - 0
cmake/Tools/Platform/Android/unit_test_android_post_build.py

@@ -0,0 +1,184 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import pathlib
+import platform
+import shutil
+
+from cmake.Tools.Platform.Android import android_post_build
+
+
+def test_copy_or_create_link(tmpdir):
+
+    tmpdir.ensure('src/level1', dir=True)
+
+    src_file_l1 = tmpdir.join('src/level1/file_l1')
+    src_file_l1.write('file_l1')
+    src_file_l2 = tmpdir.join('src/level1/file_l2')
+    src_file_l2.write('file_l2')
+    src_file_1 = tmpdir.join('src/file_1')
+    src_file_1.write('file_1')
+    src_level1 = tmpdir.join('src/level1')
+
+    tmpdir.ensure('tgt', dir=True)
+    tgt_level1 = tmpdir.join('tgt/level1')
+    tgt_file_1 = tmpdir.join('tgt/file_1')
+
+    android_post_build.create_link(pathlib.Path(src_file_1.realpath()),
+                                   pathlib.Path(tgt_file_1.realpath()))
+    android_post_build.create_link(pathlib.Path(src_level1.realpath()),
+                                   pathlib.Path(tgt_level1.realpath()))
+
+    assert pathlib.Path(tgt_level1.realpath()).exists()
+    assert pathlib.Path(tgt_file_1.realpath()).exists()
+
+
+def test_safe_clear_folder(tmpdir):
+
+    tmpdir.ensure('src/level1', dir=True)
+
+    src_file1 = tmpdir.join('src/level1/file_l1')
+    src_file1.write('file_l1')
+    src_file2 = tmpdir.join('src/level1/file_l2')
+    src_file2.write('file_l2')
+    src_file3 = tmpdir.join('src/file_1')
+    src_file3.write('file_1')
+    src_level1 = tmpdir.join('src/level1')
+
+    tmpdir.ensure('tgt', dir=True)
+    tgt = tmpdir.join('tgt')
+    tgt_level1 = tmpdir.join('tgt/level1')
+    tgt_file1 = tmpdir.join('tgt/file_1')
+
+    if platform.system() == "Windows":
+        import _winapi
+        _winapi.CreateJunction(str(src_level1.realpath()), str(tgt_level1.realpath()))
+    else:
+        pathlib.Path(src_level1.realpath()).symlink_to(tgt_level1.realpath())
+
+    shutil.copy2(src_file3.realpath(), tgt_file1.realpath())
+
+    target_path = pathlib.Path(tgt.realpath())
+    android_post_build.safe_clear_folder(target_path)
+
+    assert not pathlib.Path(tgt_level1.realpath()).exists()
+    assert not pathlib.Path(tgt_file1.realpath()).exists()
+    assert pathlib.Path(src_file1.realpath()).is_file()
+    assert pathlib.Path(src_file2.realpath()).is_file()
+    assert pathlib.Path(src_file3.realpath()).is_file()
+
+
+def test_copy_folder_with_linked_directories(tmpdir):
+
+    tmpdir.ensure('src/level1', dir=True)
+
+    src_path = tmpdir.join('src')
+    src_file_l1 = tmpdir.join('src/level1/file_l1')
+    src_file_l1.write('file_l1')
+    src_file_l2 = tmpdir.join('src/level1/file_l2')
+    src_file_l2.write('file_l2')
+    src_file_1 = tmpdir.join('src/file_1')
+    src_file_1.write('file_1')
+
+    tmpdir.ensure('tgt', dir=True)
+    tgt_path = tmpdir.join('tgt')
+
+    android_post_build.synchronize_folders(pathlib.Path(src_path.realpath()),
+                                           pathlib.Path(tgt_path.realpath()))
+
+    tgt_level1 = tmpdir.join('tgt/level1')
+    tgt_file_1 = tmpdir.join('tgt/file_1')
+
+    assert pathlib.Path(tgt_level1.realpath()).exists()
+    assert pathlib.Path(tgt_file_1.realpath()).exists()
+
+
+def test_apply_pak_layout_invalid_src_folder(tmpdir):
+
+    tmpdir.ensure('src', dir=True)
+    src_path = pathlib.Path(tmpdir.join('src').realpath())
+    tmpdir.ensure('dst/android/app', dir=True)
+    tgt_path = pathlib.Path(tmpdir.join('dst/android/app/assets').realpath())
+
+    try:
+        android_post_build.apply_pak_layout(project_root=src_path,
+                                            asset_bundle_folder="Cache",
+                                            target_layout_root=tgt_path)
+    except android_post_build.AndroidPostBuildError as e:
+        assert 'folder doesnt exist' in str(e)
+    else:
+        assert False, "Error expected"
+
+
+def test_apply_pak_layout_no_engine_pak_file(tmpdir):
+    tmpdir.ensure('src/Cache', dir=True)
+    src_path = pathlib.Path(tmpdir.join('src').realpath())
+    tmpdir.ensure('dst/android/app', dir=True)
+    tgt_path = pathlib.Path(tmpdir.join('dst/android/app/assets').realpath())
+
+    try:
+        android_post_build.apply_pak_layout(project_root=src_path,
+                                            asset_bundle_folder="Cache",
+                                            target_layout_root=tgt_path)
+    except android_post_build.AndroidPostBuildError as e:
+        assert 'engine_android.pak' in str(e)
+    else:
+        assert False, "Error expected"
+
+
+def test_apply_pak_layout_success(tmpdir):
+
+    tmpdir.ensure('src/Cache', dir=True)
+    src_path = pathlib.Path(tmpdir.join('src').realpath())
+
+    test_engine_pak = tmpdir.join('src/Cache/engine_android.pak')
+    test_engine_pak.write('engine')
+
+    tmpdir.ensure('dst/android/app', dir=True)
+    tgt_path = pathlib.Path(tmpdir.join('dst/android/app/assets').realpath())
+
+    android_post_build.apply_pak_layout(project_root=src_path,
+                                        asset_bundle_folder="Cache",
+                                        target_layout_root=tgt_path)
+
+    validate_engine_android_pak = tmpdir.join('dst/android/app/assets/engine_android.pak')
+    assert pathlib.Path(validate_engine_android_pak.realpath()).exists()
+
+
+def test_apply_loose_layout_no_loose_assets(tmpdir):
+
+    tmpdir.ensure('src/Cache/android', dir=True)
+    src_path = pathlib.Path(tmpdir.join('src').realpath())
+    tmpdir.ensure('dst/android/app', dir=True)
+    tgt_path = pathlib.Path(tmpdir.join('dst/android/app/assets').realpath())
+
+    try:
+        android_post_build.apply_loose_layout(project_root=src_path,
+                                              target_layout_root=tgt_path)
+    except android_post_build.AndroidPostBuildError as e:
+        assert 'Assets have not been built' in str(e)
+    else:
+        assert False, "Error expected"
+
+
+def test_apply_loose_layout_success(tmpdir):
+
+    tmpdir.ensure('src/Cache/android', dir=True)
+    src_path = pathlib.Path(tmpdir.join('src').realpath())
+
+    test_engine_pak = tmpdir.join('src/Cache/android/engine.json')
+    test_engine_pak.write('engine')
+
+    tmpdir.ensure('dst/android/app', dir=True)
+    tgt_path = pathlib.Path(tmpdir.join('dst/android/app/assets').realpath())
+
+    android_post_build.apply_loose_layout(project_root=src_path,
+                                          target_layout_root=tgt_path)
+
+    validate_engine_android_pak = tmpdir.join('dst/android/app/assets/engine.json')
+    assert pathlib.Path(validate_engine_android_pak.realpath()).exists()

+ 5 - 1
scripts/o3de.py

@@ -32,7 +32,7 @@ def add_args(parser: argparse.ArgumentParser) -> None:
     o3de_package_dir = (script_dir / 'o3de').resolve()
     # add the scripts/o3de directory to the front of the sys.path
     sys.path.insert(0, str(o3de_package_dir))
-    from o3de import engine_properties, engine_template, gem_properties, \
+    from o3de import android, engine_properties, engine_template, gem_properties, \
         global_project, register, print_registration, get_registration, \
         enable_gem, disable_gem, project_properties, sha256, download, \
         export_project, repo, repo_properties
@@ -84,6 +84,10 @@ def add_args(parser: argparse.ArgumentParser) -> None:
     # modify remote repo
     repo_properties.add_args(subparsers)
 
+    # Android
+    android.add_args(subparsers)
+
+
 if __name__ == "__main__":
     # parse the command line args
     the_parser = argparse.ArgumentParser()

+ 625 - 0
scripts/o3de/o3de/android.py

@@ -0,0 +1,625 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+import argparse
+import collections
+import logging
+import os
+import pathlib
+
+from o3de import command_utils, manifest, utils, android_support
+from getpass import getpass
+
+ENGINE_PATH = pathlib.Path(__file__).parents[3]
+O3DE_SCRIPT_PATH = f"{ENGINE_PATH}{os.sep}scripts{os.sep}o3de{android_support.O3DE_SCRIPT_EXTENSION}"
+DEFAULT_ANDROID_BUILD_FOLDER = 'build/android'
+
+logging.basicConfig(format=utils.LOG_FORMAT)
+logger = logging.getLogger('o3de.android')
+
+O3DE_COMMAND_CONFIGURE = 'android-configure'
+O3DE_COMMAND_GENERATE = 'android-generate'
+SIGNCONFIG_ARG_STORE_FILE = '--signconfig-store-file'
+SIGNCONFIG_ARG_KEY_ALIAS = '--signconfig-key-alias'
+
+VALIDATION_WARNING_SIGNCONFIG_NOT_SET = "Signing configuration not set."
+VALIDATION_WARNING_SIGNCONFIG_INCOMPLETE = "Signing configuration settings is incomplete."
+VALIDATION_MISSING_PASSWORD = "Signing configuration password not set."
+
+ValidatedEnv = collections.namedtuple('ValidatedEnv', ['java_version',
+                                                       'gradle_home',
+                                                       'gradle_version',
+                                                       'cmake_path',
+                                                       'cmake_version',
+                                                       'ninja_path',
+                                                       'ninja_version',
+                                                       'android_gradle_plugin_ver',
+                                                       'sdk_build_tools_version',
+                                                       'sdk_manager'])
+
+def validate_android_config(android_config: command_utils.O3DEConfig) -> None:
+    """
+    Perform basic settings and environment validation to see if there are any missing dependencies or setup steps
+    needed to process android commands.
+
+    :param android_config:  The android configuration to look up any configured values needed for validation
+    """
+
+    # Validate Java is installed
+    java_version = android_support.validate_java_environment()
+
+    # Validate gradle
+    gradle_home, gradle_version = android_support.validate_gradle(android_config)
+
+    # Validate cmake
+    cmake_path, cmake_version = android_support.validate_cmake(android_config)
+
+    # Validate ninja
+    ninja_path, ninja_version = android_support.validate_ninja(android_config)
+
+    # Validate the versions of gradle and ninja against the configured version of the android gradle plugin
+    android_gradle_plugin_ver = android_config.get_value(android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key)
+    if not android_gradle_plugin_ver:
+        raise android_support.AndroidToolError(f"Missing '{android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key}' from the android settings")
+
+    android_gradle_requirements = android_support.get_android_gradle_plugin_requirements(android_gradle_plugin_ver)
+    logger.info(f"Validating settings for requested version {android_gradle_requirements.version} of the Android Gradle Plugin.")
+    android_gradle_requirements.validate_gradle_version(gradle_version)
+    android_gradle_requirements.validate_java_version(java_version)
+
+    sdk_build_tools_version = android_gradle_requirements.sdk_build_tools_version
+
+    # Verify the Android SDK setting
+    sdk_manager = android_support.AndroidSDKManager(java_version, android_config)
+
+    # Make sure that the licenses have been read and accepted
+    sdk_manager.check_licenses()
+
+    # Make sure that all the signing config is set (warning)
+    has_signing_config_store_file = len(android_config.get_value(android_support.SETTINGS_SIGNING_STORE_FILE.key, default='')) > 0
+    has_signing_config_store_password = len(android_config.get_value(android_support.SETTINGS_SIGNING_STORE_PASSWORD.key, default='')) > 0
+    has_signing_config_key_alias = len(android_config.get_value(android_support.SETTINGS_SIGNING_KEY_ALIAS.key, default='')) > 0
+    has_signing_config_key_password = len(android_config.get_value(android_support.SETTINGS_SIGNING_KEY_PASSWORD.key, default='')) > 0
+
+    signing_config_warned = False
+    if not has_signing_config_store_file and not has_signing_config_store_password and not has_signing_config_key_alias and not has_signing_config_key_password:
+        signing_config_warned = True
+        logger.warning(VALIDATION_WARNING_SIGNCONFIG_NOT_SET)
+        print(f"\nNone of the 'signconfig.*' was set. The android scripts will not support APK signing until a signing config is set. The "
+              f"\nsigning configuration key store and key alias can be set with the '{SIGNCONFIG_ARG_STORE_FILE}' and '{SIGNCONFIG_ARG_KEY_ALIAS}' "
+              f"\nrespectively with the '{O3DE_COMMAND_GENERATE}' command. The signing configuration values can also be stored in the settings.")
+    elif has_signing_config_store_file and not has_signing_config_key_alias:
+        signing_config_warned = True
+        logger.warning(VALIDATION_WARNING_SIGNCONFIG_INCOMPLETE)
+        print(f"{android_support.SETTINGS_SIGNING_STORE_FILE}' is set, but '{android_support.SETTINGS_SIGNING_KEY_ALIAS}' is not. "
+              f"\nYou will need to provide a '{SIGNCONFIG_ARG_KEY_ALIAS}' argument when calling '{O3DE_COMMAND_GENERATE}'. For example:\n"
+              f"\n{O3DE_SCRIPT_PATH} {O3DE_COMMAND_GENERATE} {SIGNCONFIG_ARG_KEY_ALIAS} SIGNCONFIG_KEY_ALIAS ...\n")
+    elif not has_signing_config_store_file and has_signing_config_key_alias:
+        signing_config_warned = True
+        logger.warning(VALIDATION_WARNING_SIGNCONFIG_INCOMPLETE)
+        print(f" The setting for '{android_support.SETTINGS_SIGNING_KEY_ALIAS}' is set, but '{android_support.SETTINGS_SIGNING_STORE_FILE}' "
+              f"\nis not. You will need to provide a '{SIGNCONFIG_ARG_STORE_FILE}' argument when calling '{O3DE_COMMAND_GENERATE}'. For example:\n"
+              f"\n{O3DE_SCRIPT_PATH} {O3DE_COMMAND_GENERATE} {SIGNCONFIG_ARG_STORE_FILE} SIGNCONFIG_STORE_FILE ...\n")
+
+    if has_signing_config_store_file:
+        signing_config_store_file = pathlib.Path(android_config.get_value(android_support.SETTINGS_SIGNING_STORE_FILE.key))
+        if not signing_config_store_file.is_file():
+            raise android_support.AndroidToolError(f"The signing config key store file '{signing_config_store_file}' is not a valid file.")
+
+    if has_signing_config_store_file and has_signing_config_key_alias:
+        # If the keystore and key alias is set, then runnnig the generate command will prompt for the password. Provide a warning anyways even though
+        # they will still have an opportunity to enter the password
+        if not has_signing_config_store_password or not has_signing_config_key_password:
+            signing_config_warned = True
+            logger.warning(VALIDATION_MISSING_PASSWORD)
+
+            missing_passwords = []
+            if not has_signing_config_store_password:
+                missing_passwords.append(f"store password ({android_support.SETTINGS_SIGNING_STORE_PASSWORD})")
+            if not has_signing_config_key_password:
+                missing_passwords.append(f"key password ({android_support.SETTINGS_SIGNING_KEY_PASSWORD})")
+
+            print(f"The signing configuration is set but missing the following settings:\n")
+            for missing_password in missing_passwords:
+                print(f" - {missing_password}")
+            print(f"\nYou will be prompted for these passwords during the call to {O3DE_COMMAND_GENERATE}.")
+
+    if signing_config_warned:
+        print(f"\nType in '{O3DE_SCRIPT_PATH} {O3DE_COMMAND_CONFIGURE} --help' for more information about setting the signing config value in the settings.")
+
+    validated_env = ValidatedEnv(java_version=java_version,
+                                 gradle_home=gradle_home,
+                                 gradle_version=gradle_version,
+                                 cmake_path=cmake_path,
+                                 cmake_version=cmake_version,
+                                 ninja_path=ninja_path,
+                                 ninja_version=ninja_version,
+                                 android_gradle_plugin_ver=android_gradle_plugin_ver,
+                                 sdk_build_tools_version=sdk_build_tools_version,
+                                 sdk_manager=sdk_manager)
+    return validated_env
+
+
+
+def list_android_config(android_config: command_utils.O3DEConfig) -> None:
+    """
+    Print to stdout the current settings for android that will be applied to android operations
+
+    :param android_config: The android configuration to look up the configured settings
+    """
+
+    all_settings = android_config.get_all_values()
+
+    print("\nO3DE Android settings:\n")
+
+    for item, value, source in all_settings:
+        if not value:
+            continue
+        print(f"{'* ' if source else '  '}{item} = {value}")
+
+    if not android_config.is_global:
+        print(f"\n* Settings that are specific to {android_config.project_name}. Use the --global argument to see only the global settings.")
+
+
+def get_android_config_from_args(args: argparse) -> (command_utils.O3DEConfig, str):
+    """
+    Resolve and create the appropriate O3DE config object based on whether operation is based on the global
+    settings or a project specific setting
+
+    :param args:    The args object to parse
+    :return:  Tuple of the appropriate config manager object and the project name if this is not a global settings, otherwise None
+    """
+
+    is_global = getattr(args, 'global', False)
+    project = getattr(args, 'project', None)
+
+    if is_global:
+        if project:
+            logger.warning(f"Both --global and --project ({project}) arguments were provided. The --project argument will "
+                            "be ignored and the execution of this command will be based on the global settings.")
+        android_config = android_support.get_android_config(project_path=None)
+        project_name = None
+    elif not project:
+        try:
+            project_name, project_path = command_utils.resolve_project_name_and_path()
+        except command_utils.O3DEConfigError:
+            project_name = None
+        if project_name:
+            logger.info(f"The execution of this command will be based on the currently detected project ({project_name}).")
+            android_config = android_support.get_android_config(project_path=project_path)
+        else:
+            logger.info("The execution of this command will be based on the global settings.")
+            android_config = android_support.get_android_config(project_path=None)
+    else:
+        project_path = pathlib.Path(project)
+        if project_path.is_dir():
+            # If '--project' was set to a project path, then resolve the project path and name
+            project_name, project_path = command_utils.resolve_project_name_and_path(project_path)
+
+        else:
+            project_name = project
+
+            # If '--project' was not a project path, check to see if its a registered project by its name
+            project_path = manifest.get_registered(project_name=project_name)
+            if not project_path:
+                raise command_utils.O3DEConfigError(f"Unable to resolve project named '{project_name}'. "
+                                                    f"Make sure it is registered with O3DE.")
+
+        logger.info(f"The execution of this command will be based on project ({project_name}).")
+        android_config = android_support.get_android_config(project_path=project_path)
+
+    return android_config, project_name
+
+
+def configure_android_options(args: argparse) -> int:
+    """
+    Configure the android platform settings for generating, building, and deploying android projects.
+
+    :param args:    The args from the arg parser to determine the actions
+    :return: The result code to return back
+    """
+    if args.debug:
+        logger.setLevel(logging.DEBUG)
+    else:
+        logger.setLevel(logging.INFO)
+
+    try:
+        android_config, _ = get_android_config_from_args(args)
+
+        if args.list:
+            list_android_config(android_config)
+        if args.validate:
+            validate_android_config(android_config)
+        if args.set_value:
+            android_config.set_config_value_from_expression(args.set_value)
+        if args.set_password:
+            android_config.set_password(args.set_password)
+        if args.clear_value:
+            android_config.set_config_value(key=args.clear_value,
+                                            value='',
+                                            validate_value=False)
+            logger.info(f"Setting '{args.clear_value}' cleared.")
+
+    except (command_utils.O3DEConfigError, android_support.AndroidToolError) as err:
+        logger.error(str(err))
+        return 1
+    else:
+        return 0
+
+
+def prompt_validated_password(name: str) -> str:
+    """
+    Request a password with validation prompt to the user. If the password validation fails (either empty or doesnt match), an exception is thrown
+
+    :param name:    The password name to display
+    :return: The validated password
+    """
+    enter_password = getpass(f"Please enter the password for {name} : ")
+    if not enter_password:
+        raise android_support.AndroidToolError(f"Password for {name} required.")
+    confirm_password = getpass(f"Please verify the password for {name} : ")
+    if confirm_password != enter_password:
+        raise android_support.AndroidToolError(f"Passwords for {name} do not match.")
+
+    return enter_password
+
+
+def generate_android_project(args: argparse) -> int:
+    """
+    Execute the android gradle project creation workflow from the input arguments
+
+    :param args:    The args from the arg parser to extract the necessary parameters for generating the project
+    :return: The result code to return back
+    """
+
+    if args.debug:
+        logger.setLevel(logging.DEBUG)
+    else:
+        logger.setLevel(logging.INFO)
+
+    try:
+        # Create the android configuration
+        android_config, project_name = get_android_config_from_args(args)
+
+        # Resolve the project path and get the project and android settings
+        resolved_project_path = manifest.get_registered(project_name=project_name)
+        if not resolved_project_path:
+            raise android_support.AndroidToolError(f"Project '{project_name}' is not registered with O3DE.")
+        project_settings, android_settings = android_support.read_android_settings_for_project(resolved_project_path)
+
+        # Perform validation on the config and the environment
+        android_env = validate_android_config(android_config)
+
+        # Make sure we have the extra android packages "market_apk_expansion" and "market_licensing" which is needed by the APK
+        android_env.sdk_manager.install_package(package_install_path='extras;google;market_apk_expansion',
+                                                package_description='Google APK Expansion Library')
+
+        android_env.sdk_manager.install_package(package_install_path='extras;google;market_licensing',
+                                                package_description='Google Play Licensing Library')
+
+        # Make sure the requested Platform SDK (defined by API Level) is installed
+        if args.platform_sdk_api_level:
+            android_platform_sdk_api_level = args.platform_sdk_api_level
+        else:
+            android_platform_sdk_api_level = android_config.get_value(android_support.SETTINGS_PLATFORM_SDK_API.key)
+            if not android_platform_sdk_api_level:
+                raise android_support.AndroidToolError(f"The Android Platform SDK API Level was not specified in either the command argument (--platform-sdk-api-level) "
+                                                       f"nor the android settings ({android_support.SETTINGS_PLATFORM_SDK_API.key}).")
+
+        platform_package_name = f"platforms;android-{android_platform_sdk_api_level}"
+        platform_sdk_package = android_env.sdk_manager.install_package(package_install_path=platform_package_name,
+                                                                       package_description=f'Android SDK Platform {android_platform_sdk_api_level}')
+        logger.info(f"Selected Android Platform API Level : {android_platform_sdk_api_level}")
+
+        # Make sure the NDK is specified and installed
+        if args.ndk_version:
+            android_ndk_version = args.ndk_version
+        else:
+            android_ndk_version = android_config.get_value(android_support.SETTINGS_NDK_VERSION.key)
+            if not android_ndk_version:
+                raise android_support.AndroidToolError(f"The Android NDK version was not specified in either the command argument (--ndk-version) "
+                                                       f"nor the android settings ({android_support.SETTINGS_NDK_VERSION.key}).")
+
+        ndk_package_name = f"ndk;{android_ndk_version}"
+        ndk_package = android_env.sdk_manager.install_package(package_install_path=ndk_package_name,
+                                                              package_description=f'Android NDK version {android_ndk_version}')
+        logger.info(f"Selected Android NDK Version : {ndk_package.version}")
+
+        # Make sure that the android build tools (based on the spec from the android gradle plugin) is installed
+        build_tools_package_name = f"build-tools;{android_env.sdk_build_tools_version}"
+        android_env.sdk_manager.install_package(package_install_path=build_tools_package_name,
+                                                package_description=f'Android Build Tools {android_env.sdk_build_tools_version}')
+
+        engine_path = ENGINE_PATH
+        logger.info(f'Engine Path : {engine_path}')
+
+        project_path = resolved_project_path
+        logger.info(f'Project Path : {project_path}')
+
+        # Debug stripping option
+        if getattr(args, 'strip_debug', False):
+            strip_debug = True
+        elif getattr(args, 'no_strip_debug', False):
+            strip_debug = False
+        else:
+            strip_debug = android_config.get_boolean_value(android_support.SETTINGS_STRIP_DEBUG.key)
+
+        logger.info(f"Debug symbol stripping {'enabled' if strip_debug else 'disabled'}.")
+
+        # Optional additional cmake args for the native project generation
+        extra_cmake_args = args.extra_cmake_args
+
+        # Optional custom gradle JVM arguments
+        custom_jvm_args = args.custom_jvm_args
+
+        # Oculus project options
+        if getattr(args, 'oculus_project', False):
+            is_oculus_project = True
+        elif getattr(args, 'no_oculus_project', False):
+            is_oculus_project = False
+        else:
+            is_oculus_project = android_config.get_boolean_value(android_support.SETTINGS_OCULUS_PROJECT.key)
+        if is_oculus_project:
+            logger.info(f"Oculus flags enabled.")
+
+        # Get the android build folder
+        build_folder = pathlib.Path(args.build_dir)
+        logger.info(f'Project Android Build Folder : {build_folder}')
+
+        # Get the bundled assets sub folder
+        src_bundle_pak_subfolder = android_config.get_value(android_support.SETTINGS_ASSET_BUNDLE_SUBPATH.key,
+                                                            default='AssetBundling/Bundles')
+
+        # Check if there is a signing config from the arguments
+        signconfig_store_file = getattr(args, 'signconfig_store_file', None)
+        signconfig_key_alias = getattr(args, 'signconfig_key_alias', None)
+        if signconfig_store_file and signconfig_key_alias:
+
+            signconfig_store_file_path = pathlib.Path(signconfig_store_file)
+            if not signconfig_store_file_path.is_file():
+                raise android_support.AndroidToolError(f"Invalid signing configuration store file '{signconfig_store_file}' specified. File does not exist.")
+
+            stored_signconfig_store_password = android_config.get_value(android_support.SETTINGS_SIGNING_STORE_PASSWORD.key)
+            if stored_signconfig_store_password:
+                signconfig_store_password = stored_signconfig_store_password
+            else:
+                signconfig_store_password = prompt_validated_password(f"key store for {signconfig_store_file}")
+
+            stored_signconfig_key_password = android_config.get_value(android_support.SETTINGS_SIGNING_KEY_PASSWORD.key)
+            if stored_signconfig_key_password:
+                signconfig_key_password = stored_signconfig_key_password
+            else:
+                signconfig_key_password = prompt_validated_password(f"key alias for {signconfig_key_alias}")
+
+            signing_config = android_support.AndroidSigningConfig(store_file=signconfig_store_file_path,
+                                                                  store_password=signconfig_store_password,
+                                                                  key_alias=signconfig_key_alias,
+                                                                  key_password=signconfig_key_password)
+        elif signconfig_store_file and not signconfig_key_alias:
+            raise android_support.AndroidToolError(f"Missing required '{SIGNCONFIG_ARG_KEY_ALIAS}' argument to accompany the provided '{SIGNCONFIG_ARG_STORE_FILE}' ({signconfig_store_file})")
+        elif not signconfig_store_file and signconfig_key_alias:
+            raise android_support.AndroidToolError(f"Missing required '{SIGNCONFIG_ARG_STORE_FILE}' argument to accompany the provided '{SIGNCONFIG_ARG_KEY_ALIAS}' ({signconfig_store_file})")
+        else:
+            signing_config = None
+
+        if signing_config is not None:
+            logger.info("Signing configuration enabled")
+        else:
+            logger.warning("No signing configuration enabled. This output APK will not be signed until a signing configuration is added.")
+
+        apg = android_support.AndroidProjectGenerator(engine_root=engine_path,
+                                                      android_build_dir=build_folder,
+                                                      android_sdk_path=android_env.sdk_manager.get_android_sdk_path(),
+                                                      android_build_tool_version=android_env.sdk_build_tools_version,
+                                                      android_platform_sdk_api_level=android_platform_sdk_api_level,
+                                                      android_ndk_package=ndk_package,
+                                                      project_name=project_name,
+                                                      project_path=project_path,
+                                                      project_general_settings=project_settings,
+                                                      project_android_settings=android_settings,
+                                                      cmake_version=android_env.cmake_version,
+                                                      cmake_path=android_env.cmake_path,
+                                                      gradle_path=android_env.gradle_home,
+                                                      gradle_version=android_env.gradle_version,
+                                                      gradle_custom_jvm_args=custom_jvm_args,
+                                                      android_gradle_plugin_version=android_env.android_gradle_plugin_ver,
+                                                      ninja_path=android_env.ninja_path,
+                                                      asset_mode=args.asset_mode,
+                                                      signing_config=signing_config,
+                                                      extra_cmake_configure_args=extra_cmake_args,
+                                                      overwrite_existing=True,
+                                                      strip_debug_symbols=strip_debug,
+                                                      src_pak_file_path=src_bundle_pak_subfolder,
+                                                      oculus_project=is_oculus_project)
+        apg.execute()
+
+    except (command_utils.O3DEConfigError, android_support.AndroidToolError) as err:
+        logger.error(str(err))
+        return 1
+    else:
+        return 0
+
+
+def add_args(subparsers) -> None:
+    """
+    add_args is called to add subparsers for the following commands to the central o3de.py command processor:
+
+    - android_configure
+    - android_generate
+
+    :param subparsers: the caller instantiates subparsers and passes it in here
+    """
+
+    # Read from the android config if possible to try to display default values
+    try:
+        project_name, project_path = command_utils.resolve_project_name_and_path()
+        android_config = android_support.get_android_config(project_path=project_path)
+    except (android_support.AndroidToolError, command_utils.O3DEConfigError):
+        logger.debug(f"No project detected at {os.getcwd()}, default settings from global config.")
+        project_name = None
+        android_config = android_support.get_android_config(project_path=None)
+
+    #
+    # Configure the subparser for 'android-configure'
+    #
+
+    # Generate the epilog string to describe the settings that can be configured
+    epilog_lines = ['Configure the default values that control the android helper scripts for O3DE.',
+                    'The list settings that can be set are:',
+                    '']
+    max_key_len = 0
+    for setting in android_config.setting_descriptions:
+        max_key_len = max(len(setting.key), max_key_len)
+    max_key_len += 4
+    for setting in android_config.setting_descriptions:
+        setting_line = f"{'* ' if setting.is_password else '  '}{setting.key: <{max_key_len}} {setting.description}"
+        epilog_lines.append(setting_line)
+    epilog_lines.extend([
+        '',
+        '* Denotes password settings that can only be set using the --set-password command'
+    ])
+
+    epilog = '\n'.join(epilog_lines)
+
+    android_configure_subparser = subparsers.add_parser(O3DE_COMMAND_CONFIGURE,
+                                                        help='Configure the Android platform settings for generating, building, and deploying Android projects.',
+                                                        epilog=epilog,
+                                                        formatter_class=argparse.RawTextHelpFormatter)
+
+    android_configure_subparser.add_argument('--global', default=False, action='store_true',
+                                             help='Used with the configure command, specify whether the settings are project local or global.')
+    android_configure_subparser.add_argument('-p', '--project', type=str, required=False,
+                                             help="The name of the registered project to configure the settings for. This value is ignored if '--global' was specified."
+                                                  "Note: If both '--global' and '-p/--project' is not specified, the script will attempt to deduce the project from the "
+                                                  "current working directory if possible")
+    android_configure_subparser.add_argument('-l', '--list', default=False, action='store_true',
+                                             help='Display the current Android settings. ')
+    android_configure_subparser.add_argument('--validate', default=False, action='store_true',
+                                             help='Validate the settings and values in the Android settings. ')
+    android_configure_subparser.add_argument('--set-value', type=str, required=False,
+                                             help='Set the value for an Android setting, using the format <argument>=<value>. For example: \'ndk_version=22.5*\'',
+                                             metavar='VALUE')
+    android_configure_subparser.add_argument('--clear-value', type=str, required=False,
+                                             help='Clear a previously configured setting.',
+                                             metavar='VALUE')
+    android_configure_subparser.add_argument('--set-password', type=str, required=False,
+                                             help='Set the password for a password setting. A password prompt will be presented.',
+                                             metavar='SETTING')
+    android_configure_subparser.add_argument('--debug',
+                                             help=f"Enable debug level logging for this script.",
+                                             action='store_true')
+    android_configure_subparser.set_defaults(func=configure_android_options)
+
+    #
+    # Configure the subparser for 'android_generate'
+    #
+    android_generate_subparser = subparsers.add_parser(O3DE_COMMAND_GENERATE,
+                                                       help='Generate an Android/Gradle project.')
+
+    # Project Name
+    android_generate_subparser.add_argument('-p', '--project', type=str,
+                                             help="The name of the registered project or the full path to the O3DE project to generate the Android build scripts for. If not supplied, this operation will attempt to "
+                                                  "resolve the project from the current directory.")
+
+    # Build Directory
+    android_generate_subparser.add_argument('-B', '--build-dir', type=str,
+                                             help=f"The location to write the android project scripts to. Default: '{DEFAULT_ANDROID_BUILD_FOLDER}'",
+                                             default=DEFAULT_ANDROID_BUILD_FOLDER)
+
+    # Platform SDK API Level (https://developer.android.com/tools/releases/platforms)
+    platform_sdk_api_level = android_config.get_value(android_support.SETTINGS_PLATFORM_SDK_API.key)
+    if platform_sdk_api_level:
+        android_generate_subparser.add_argument('--platform-sdk-api-level', type=str,
+                                                help=f"Specify the platform SDK API Level. Default: {platform_sdk_api_level}",
+                                                default=platform_sdk_api_level)
+    else:
+        android_generate_subparser.add_argument('--platform-sdk-api-level', type=str,
+                                                help=f"Specify the platform SDK API Level ({android_support.SETTINGS_PLATFORM_SDK_API.key})")
+
+    # Android NDK Version version (https://developer.android.com/ndk/downloads)
+    ndk_version = android_config.get_value(android_support.SETTINGS_NDK_VERSION.key)
+    if ndk_version:
+        android_generate_subparser.add_argument('--ndk-version', type=str,
+                                                help=f"Specify the android NDK version. Default: {ndk_version}.",
+                                                default=ndk_version)
+    else:
+        android_generate_subparser.add_argument('--ndk-version', type=str,
+                                                help=f"Specify the android NDK version ({android_support.SETTINGS_NDK_VERSION.key}).")
+
+    # Signing configuration key store file
+    signconfig_store_file = android_config.get_value(android_support.SETTINGS_SIGNING_STORE_FILE.key)
+    if signconfig_store_file:
+        android_generate_subparser.add_argument(SIGNCONFIG_ARG_STORE_FILE, type=str,
+                                                help=f"Specify the location of the jks keystore file to enable a signing configuration for this project automatically. "
+                                                     f"If set, then a store file password, key alias, and key password will be required as well. "
+                                                     f" Default : {signconfig_store_file}",
+                                                default=signconfig_store_file)
+    else:
+        android_generate_subparser.add_argument(SIGNCONFIG_ARG_STORE_FILE, type=str,
+                                                help=f"Specify the location of the jks keystore file to enable a signing configuration for this project automatically "
+                                                     f"({android_support.SETTINGS_SIGNING_STORE_FILE.key}). "
+                                                     f"If set, then a store file password, key alias, and key password will be required as well.")
+
+    # Signing configuration key store alias
+    signconfig_key_alias = android_config.get_value(android_support.SETTINGS_SIGNING_KEY_ALIAS.key)
+    if signconfig_key_alias:
+        android_generate_subparser.add_argument(SIGNCONFIG_ARG_KEY_ALIAS, type=str,
+                                                help=f"Specify the key alias for the configured jks keystore file if set.\n"
+                                                     f"Default: {signconfig_key_alias}.",
+                                                default=signconfig_key_alias)
+    else:
+        android_generate_subparser.add_argument(SIGNCONFIG_ARG_KEY_ALIAS, type=str,
+                                                help=f"Specify the key alias for the configured jks keystore file if set "
+                                                     f"({android_support.SETTINGS_SIGNING_KEY_ALIAS.key}.")
+    # Asset Mode
+    asset_mode = android_config.get_value(android_support.SETTINGS_ASSET_MODE.key, default=android_support.ASSET_MODE_LOOSE)
+    android_generate_subparser.add_argument('--asset-mode', type=str,
+                                            help=f"The mode of asset deployment to use. "
+                                                 f" Default: {asset_mode}",
+                                            choices=android_support.ASSET_MODES,
+                                            default=asset_mode)
+
+    # Extra CMake configure args
+    extra_cmake_args = android_config.get_value(android_support.SETTINGS_EXTRA_CMAKE_ARGS.key, default='')
+    android_generate_subparser.add_argument('--extra-cmake-args', type=str,
+                                            help=android_support.SETTINGS_EXTRA_CMAKE_ARGS.description,
+                                            default=extra_cmake_args)
+
+    # Custom JVM args    ANDROID_SETTINGS_GRADLE_JVM_ARGS
+    custom_jvm_args = android_config.get_value(android_support.SETTINGS_GRADLE_JVM_ARGS.key, default='')
+    android_generate_subparser.add_argument('--custom-jvm-args', type=str,
+                                            help=android_support.SETTINGS_GRADLE_JVM_ARGS.description,
+                                            default=custom_jvm_args)
+
+    # Flag to strip the debug symbols
+    strip_debug = android_config.get_value(android_support.SETTINGS_STRIP_DEBUG.key)
+    if strip_debug:
+        android_generate_subparser.add_argument('--no-strip-debug',
+                                                help=f"Don't strip the debug symbols from the built binaries.",
+                                                action='store_true')
+    else:
+        android_generate_subparser.add_argument('--strip-debug',
+                                                help=f"Strip the debug symbols from the built binaries.",
+                                                action='store_true')
+
+    # Flag indicating this is an oculus project
+    is_oculus_project = android_config.get_value(android_support.SETTINGS_OCULUS_PROJECT.key)
+    if is_oculus_project:
+        android_generate_subparser.add_argument('--no-oculus-project',
+                                                help=f"Turn off the flag that marks the project as an Oculus project.",
+                                                action='store_true')
+    else:
+        android_generate_subparser.add_argument('--oculus-project',
+                                                help="Marks the project as an Oculus project. This flag enables Oculus-specific settings "
+                                                     "that are required for the Android project generation.",
+                                                action='store_true')
+
+    android_generate_subparser.add_argument('--debug',
+                                            help=f"Enable debug level logging for this script.",
+                                            action='store_true')
+
+    android_generate_subparser.set_defaults(func=generate_android_project)

+ 2174 - 0
scripts/o3de/o3de/android_support.py

@@ -0,0 +1,2174 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+import argparse
+import configparser
+import datetime
+import fnmatch
+import imghdr
+import json
+import logging
+import os
+import re
+import platform
+import shutil
+import stat
+import string
+import subprocess
+
+from enum import Enum
+from o3de import command_utils, manifest, utils
+from packaging.version import Version
+from pathlib import Path
+from typing import Dict, List, Tuple
+
+logging.basicConfig(format=utils.LOG_FORMAT)
+logger = logging.getLogger('o3de.android')
+
+DEFAULT_READ_ENCODING = 'utf-8'    # The default encoding to use when reading from a text file or stream
+DEFAULT_WRITE_ENCODING = 'ascii'
+ENCODING_ERROR_HANDLINGS = 'ignore'     # What to do if we encounter any encoding errors
+
+# Set platform specific values and extensions
+if platform.system() == 'Windows':
+    EXE_EXTENSION = '.exe'
+    O3DE_SCRIPT_EXTENSION = '.bat'
+    SDKMANAGER_EXTENSION = '.bat'
+    GRADLE_EXTENSION = '.bat'
+    DEFAULT_ANDROID_SDK_PATH = f"{os.getenv('LOCALAPPDATA')}\\Android\\Sdk"
+    PYTHON_SCRIPT = 'python.cmd'
+elif platform.system() == 'Darwin':
+    EXE_EXTENSION = ''
+    O3DE_SCRIPT_EXTENSION = '.sh'
+    SDKMANAGER_EXTENSION = ''
+    GRADLE_EXTENSION = '.sh'
+    DEFAULT_ANDROID_SDK_PATH = f"{os.getenv('HOME')}/Library/Android/Sdk"
+    PYTHON_SCRIPT = 'python.sh'
+elif platform.system() == 'Linux':
+    EXE_EXTENSION = ''
+    O3DE_SCRIPT_EXTENSION = '.sh'
+    SDKMANAGER_EXTENSION = ''
+    GRADLE_EXTENSION = '.sh'
+    DEFAULT_ANDROID_SDK_PATH = f"{os.getenv('HOME')}/Android/Sdk"
+    PYTHON_SCRIPT = 'python.sh'
+else:
+    assert False, f"Unknown platform {platform.system()}"
+
+ASSET_MODE_LOOSE = 'LOOSE'
+ASSET_MODE_PAK = 'PAK'
+
+ASSET_MODES = [ASSET_MODE_LOOSE, ASSET_MODE_PAK]
+
+BUILD_CONFIGURATIONS = ['Debug', 'Profile', 'Release']
+ANDROID_ARCH = 'arm64-v8a'
+
+DEFAULT_ANDROID_GRADLE_PLUGIN_VERSION = '8.1.0'
+ANDROID_RESOLUTION_SETTINGS = ('mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi')
+
+# Values to set for the android configuration handler
+ANDROID_SETTINGS_FILE = '.command_settings'
+ANDROID_SETTINGS_SECTION_NAME = 'android'
+
+SUPPORTED_ANDROID_SETTINGS = []
+
+def register_setting(key: str, description: str, default: str= None,  is_password:bool = False, is_boolean = False, restricted_regex: str = None, restricted_regex_description: str = None) -> command_utils.SettingsDescription:
+    """
+    Register a new settings description for android
+
+    :param key:             The settings key used for configuration storage
+    :param description:     The description of this setting value
+    :param default:         The default value for the setting
+    :param is_password:     Flag indicating that this is a password setting
+    :param is_boolean:      Flag indicating that this is a boolean setting
+    :param restricted_regex:  (Optional) Validation regex to restrict the possible value
+    :param restricted_regex_description:  Description of the validation regex
+    :return: The new settings description
+    """
+
+    global SUPPORTED_ANDROID_SETTINGS
+
+    new_setting = command_utils.SettingsDescription(key=key,
+                                                    description=description,
+                                                    default=default,
+                                                    is_password=is_password,
+                                                    is_boolean=is_boolean,
+                                                    restricted_regex=restricted_regex,
+                                                    restricted_regex_description=restricted_regex_description)
+    SUPPORTED_ANDROID_SETTINGS.append(new_setting)
+
+    return new_setting
+
+
+#
+# Declarations of the android-specific settings
+#
+SETTINGS_SDK_ROOT              = register_setting(key='sdk.root',
+                                                  description='The root path of the android sdk on this system.')
+
+SETTINGS_PLATFORM_SDK_API      = register_setting(key='platform.sdk.api',
+                                                  description='The android platform API level (ref https://developer.android.com/tools/releases/platforms)',
+                                                  default='31')
+
+SETTINGS_NDK_VERSION           = register_setting(key='ndk.version',
+                                                  description='The version of the android NDK (ref https://developer.android.com/ndk/downloads). File matching patterns can be used (i.e. 25.* will search for the most update to date major version 25)',
+                                                  default='25.*')
+
+SETTINGS_GRADLE_PLUGIN_VERSION = register_setting(key='android.gradle.plugin',
+                                                  description='The version of the android gradle plugin to use for the build (ref https://developer.android.com/reference/tools/gradle-api)',
+                                                  default=DEFAULT_ANDROID_GRADLE_PLUGIN_VERSION)
+
+SETTINGS_GRADLE_HOME           = register_setting(key='gradle.home',
+                                                  description='The root path of the locally installed version of gradle. If not set, the gradle that is in the PATH environment will be used')
+
+SETTINGS_GRADLE_JVM_ARGS       = register_setting(key='gradle.jvmargs',
+                                                  description='Customized jvm arguments to set when invoking gradle. (ref https://docs.gradle.org/current/userguide/config_gradle.html#sec:configuring_jvm_memory)',
+                                                  default='')
+
+SETTINGS_CMAKE_HOME            = register_setting(key='cmake.home',
+                                                  description='The root path of the locally installed version of cmake. If not set, the cmake that is in the PATH environment will be used')
+
+# Settings related to the signing configuration (ref https://developer.android.com/studio/publish/app-signing)
+SETTINGS_SIGNING_STORE_FILE     = register_setting(key='signconfig.store.file',
+                                                   description='The key store file to use for creating a signing config. (ref https://developer.android.com/studio/publish/app-signing)')
+
+SETTINGS_SIGNING_STORE_PASSWORD = register_setting(key='signconfig.store.password',
+                                                   description='The password for the key store file',
+                                                   is_password=True)
+
+SETTINGS_SIGNING_KEY_ALIAS      = register_setting(key='signconfig.key.alias',
+                                                   description='The key alias withing the key store that idfentifies the signing key')
+
+SETTINGS_SIGNING_KEY_PASSWORD   = register_setting(key='signconfig.key.password',
+                                                   description='The password for the key inside the key store referenced by the key alias',
+                                                   is_password=True)
+
+# General O3DE build and deployment options
+SETTINGS_ASSET_MODE             = register_setting(key='asset.mode',
+                                                   description='The asset mode to determine how the assets are stored in the target APK. Valid values are LOOSE and PAK.',
+                                                   restricted_regex=f'({ASSET_MODE_LOOSE}|{ASSET_MODE_PAK})',
+                                                   restricted_regex_description=f"Valid values are {','.join(ASSET_MODES)}.")
+
+SETTINGS_STRIP_DEBUG            = register_setting(key='strip.debug',
+                                                   description='Option to strip the debug symbols of the built native libs before deployment to the APK',
+                                                   is_boolean=True)
+
+SETTINGS_OCULUS_PROJECT         = register_setting(key='oculus.project',
+                                                   description='Option to set Oculus-specific build options when building the APK',
+                                                   is_boolean=True)
+
+SETTINGS_ASSET_BUNDLE_SUBPATH   = register_setting(key='asset.bundle.subpath',
+                                                   description='The sub-path from the project root to specify where the bundle/pak files will be generated to. '
+                                                                                      '(ref https://www.docs.o3de.org/docs/user-guide/packaging/asset-bundler/)')
+
+SETTINGS_EXTRA_CMAKE_ARGS       = register_setting(key='extra.cmake.args',
+                                                   description='Optional string to set additional cmake arguments during the native project generation within the android gradle build process')
+
+
+def get_android_config(project_path: Path or None) -> command_utils.O3DEConfig:
+    """
+    Create an android configuration a project. If a project name is provided, then attempt to look for the project and use its
+    project-specific settings (if any) as an overlay to the global settings. If the project name is None, then return an
+    android configuration object that only represents the global setting
+
+    :param project_path:    The path to the registered O3DE project to look for its project-specific setting. If None, only use the global settings.
+    :return: The android configuration object
+    """
+    return command_utils.O3DEConfig(project_path=project_path,
+                                    settings_filename=ANDROID_SETTINGS_FILE,
+                                    settings_section_name=ANDROID_SETTINGS_SECTION_NAME,
+                                    settings_description_list=SUPPORTED_ANDROID_SETTINGS)
+
+
+"""
+Map to make troubleshooting java issues related to mismatched java versions when running the sdkmanager.
+"""
+JAVA_CLASS_FILE_MAP = {
+    "65": "Java SE 21",
+    "64": "Java SE 20",
+    "63": "Java SE 19",
+    "62": "Java SE 18",
+    "61": "Java SE 17",
+    "60": "Java SE 16",
+    "59": "Java SE 15",
+    "58": "Java SE 14",
+    "57": "Java SE 13",
+    "56": "Java SE 12",
+    "55": "Java SE 11",
+    "54": "Java SE 10",
+    "53": "Java SE 9",
+    "52": "Java SE 8",
+    "51": "Java SE 7",
+    "50": "Java SE 6",
+    "49": "Java SE 5",
+    "48": "JDK 1.4",
+    "47": "JDK 1.3",
+    "46": "JDK 1.2"
+}
+
+ANDROID_HELP_REGISTER_ANDROID_SDK_MESSAGE = f"""
+Building android projects requires the android sdk manager command-line (https://developer.android.com/tools/sdkmanager). 
+Please follow the instructions at https://developer.android.com/studio to download the sdk manager command line tool from
+either Android Studio or the command line tools only and set the path to the 'sdkmanager{SDKMANAGER_EXTENSION}' path.
+
+If installing from Android Studio (recommended) then the default location of the Android SDK will be %HOME%   
+
+For example:
+
+o3de{O3DE_SCRIPT_EXTENSION} android-register --global --set-value android_sdk_root=<path to android SDK>
+
+"""
+
+class AndroidToolError(Exception):
+    pass
+
+
+class AndroidGradlePluginRequirements(object):
+    """
+    This class manages the Android Gradle plugin requirements environment and represents each entry from the
+    ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.
+    """
+    def __init__(self,
+                 agp_version:str,
+                 gradle_version:str,
+                 sdk_build_tools_version:str,
+                 jdk_version:str,
+                 release_note_url:str):
+        self._agp_version = Version(agp_version)
+        self._gradle_ver = Version(gradle_version)
+        self._sdk_build_tools_version = Version(sdk_build_tools_version)
+        self._jdk_version = Version(jdk_version)
+        self._release_notes_url = release_note_url
+
+    @property
+    def gradle_ver(self) -> Version:
+        return self._gradle_ver
+
+    @property
+    def sdk_build_tools_version(self) -> Version:
+        return self._sdk_build_tools_version
+
+    @property
+    def version(self) -> Version:
+        return self._agp_version
+
+    def validate_java_version(self, java_version:str) -> None:
+        """
+        Validate a version of Java against the current Android Gradle Plugin. Raise a detailed exception if it doesn't meet the requirements.
+        @param java_version:    The version string reported by java (-version)
+        @return: None
+        """
+        java_version_check = Version(java_version)
+        if not java_version_check >= self._jdk_version:
+            raise AndroidToolError(f"The installed version of java ({java_version_check}) does not meet the minimum version of ({self._jdk_version}) "
+                                   f"which is required by the Android Gradle Plugin version ({self._agp_version}). Refer to the android gradle plugin "
+                                   f"release notes at {self._release_notes_url}")
+
+    def validate_gradle_version(self, gradle_version:str) -> None:
+        """
+        Validate a version of gradle against the current Android Gradle Plugin. Raise a a detailed exception if it doesnt meet the requirements.
+        @param java_version:    The version string reported by gradle. (--version)
+        @return: None
+        """
+        gradle_version_check = Version(gradle_version)
+        if not gradle_version_check >= self._gradle_ver:
+            raise AndroidToolError(f"The installed version of gradle ({gradle_version_check}) does not meet the minimum version of ({self._gradle_ver}) "
+                                   f"which is required by the Android Gradle Plugin version ({self._agp_version}). Refer to the android gradle plugin "
+                                   f"release notes at {self._release_notes_url}")
+
+# The ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP manages the known android plugin known to O3DE and its compatibility requirements
+# Note: This map needs to be updated in conjunction with newer versions of the Android Gradle plugins.
+
+ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP = {
+
+    '8.1': AndroidGradlePluginRequirements(agp_version='8.1',
+                                           gradle_version='8.0',
+                                           sdk_build_tools_version='33.0.1',
+                                           jdk_version='17',
+                                           release_note_url='https://developer.android.com/build/releases/gradle-plugin'),
+
+    '8.0': AndroidGradlePluginRequirements(agp_version='8.0',
+                                           gradle_version='8.0',
+                                           sdk_build_tools_version='30.0.3',
+                                           jdk_version='17',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-8-0-0-release-notes'),
+
+    '7.4': AndroidGradlePluginRequirements(agp_version='7.4',
+                                           gradle_version='7.5',
+                                           sdk_build_tools_version='30.0.3',
+                                           jdk_version='11',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-7-4-0-release-notes'),
+
+    '7.3': AndroidGradlePluginRequirements(agp_version='7.3',
+                                           gradle_version='7.4',
+                                           sdk_build_tools_version='30.0.3',
+                                           jdk_version='11',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-7-3-0-release-notes'),
+
+    '7.2': AndroidGradlePluginRequirements(agp_version='7.2',
+                                           gradle_version='7.3.3',
+                                           sdk_build_tools_version='30.0.3',
+                                           jdk_version='11',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-7-2-0-release-notes'),
+
+    '7.1': AndroidGradlePluginRequirements(agp_version='7.1',
+                                           gradle_version='7.2',
+                                           sdk_build_tools_version='30.0.3',
+                                           jdk_version='11',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-7-1-0-release-notes'),
+
+    '7.0': AndroidGradlePluginRequirements(agp_version='7.0',
+                                           gradle_version='7.0.2',
+                                           sdk_build_tools_version='30.0.2',
+                                           jdk_version='11',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-7-0-0-release-notes'),
+
+    '4.2': AndroidGradlePluginRequirements(agp_version='4.2',
+                                           gradle_version='6.7.1',
+                                           sdk_build_tools_version='30.0.2',
+                                           jdk_version='8',
+                                           release_note_url='https://developer.android.com/build/releases/past-releases/agp-4-2-0-release-notes'),
+}
+
+# Determine the latest version of the gradle plugin
+MAX_ANDROID_GRADLE_PLUGIN_VER = Version(sorted(list(ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.keys()), reverse=True)[0])
+
+
+def get_android_gradle_plugin_requirements(requested_agp_version:str) -> AndroidGradlePluginRequirements:
+    """
+    Lookup up a specific Android Gradle Plugin (AGP) requirements based on a specific version of the AGP
+    :param requested_agp_version:   The version of the AGP to look for the requirements
+    :return: The instance of AndroidGradlePluginRequirements for the specific version of AGP
+    """
+
+    global ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP
+
+    # The lookup map is only based on the major and minor version
+    lookup_version = Version(requested_agp_version)
+    lookup_version_str = f'{lookup_version.major}.{lookup_version.minor}'
+
+    if Version(lookup_version_str) > MAX_ANDROID_GRADLE_PLUGIN_VER:
+        raise AndroidToolError(f"Android gradle plugin version {requested_agp_version} is newer than the latest version supported ({MAX_ANDROID_GRADLE_PLUGIN_VER}).")
+
+    matched_agp_requirements = ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.get(lookup_version_str)
+    if not matched_agp_requirements:
+        raise AndroidToolError(f"Unrecognized/unsupported android gradle plugin version {requested_agp_version} specified. Supported Versions are "
+            f"{', '.join(f'{version_key}.0' for version_key in ANDROID_GRADLE_PLUGIN_COMPATIBILITY_MAP.keys())}")
+    return matched_agp_requirements
+
+
+class AndroidSDKManager(object):
+    """
+    Class to manage the android sdk manager command-line tool which is required to generate and process O3DE
+    android projects.
+    """
+
+    class BasePackage(object):
+        """
+        Base package with the common properties for all the android sdk packages
+        """
+        def __init__(self, components: List):
+            """
+            Initialize the base package
+
+            :param components:  Dictionary of the components to extract the common properties from
+            """
+
+            self._path = components[0]
+            self._version = Version(components[1].strip().replace(' ', '.'))  # Fix for versions that have spaces between the version number and potential non-numeric versioning (PEP-0440)
+            self._description = components[2]
+
+        @property
+        def path(self)->str:
+            return self._path
+
+        @property
+        def version(self)->Version:
+            return self._version
+
+        @property
+        def description(self) -> str:
+            return self._description
+
+    class InstalledPackage(BasePackage):
+        """
+        This class represents an installed package
+        """
+        def __init__(self, installed_package_components):
+            super().__init__(installed_package_components)
+            assert len(installed_package_components) == 4, '4 sections expected for installed package components (path, version, description, location)'
+            self._location = installed_package_components[3]
+
+        @property
+        def location(self) -> str:
+            return self._location
+
+
+    class AvailablePackage(BasePackage):
+        """
+        This class represents an available package (for install)
+        """
+        def __init__(self, available_package_components):
+            super().__init__(available_package_components)
+            assert len(available_package_components) == 3, '3 sections expected for installed package components (path, version, description)'
+
+    class AvailableUpdate(BasePackage):
+        """
+        This class represents an installed package that has an available update
+        """
+        def __init__(self, available_update_components):
+            super().__init__(available_update_components)
+            assert len(available_update_components) == 3, '3 sections expected for installed package components (path, version, available)'
+
+    # Enums that track the type of package information that we are tracking
+    class PackageCategory(Enum):
+        AVAILABLE = 1
+        INSTALLED = 2
+        UPDATABLE = 3
+
+    def __init__(self, current_java_version: str, android_settings: command_utils.O3DEConfig):
+        """
+        Initialize the Android SDK Manager
+
+        :param current_java_version:    The current version of java used by gradle
+        :param android_settings:        The current android settings
+        """
+
+        # Validate the android sdk path is set, contains the command line tools at the expected location, and that it is compatible with the
+        # input java version
+        self._installed_packages = {}
+        self._available_packages = {}
+        self._available_updates = {}
+        self._android_command_line_tools_sdkmanager_path, self.android_sdk_path = \
+            AndroidSDKManager.validate_android_sdk_environment(current_java_version=current_java_version,
+                                                               android_settings=android_settings)
+
+        self.refresh_sdk_installation()
+
+    @staticmethod
+    def validate_android_sdk_environment(current_java_version: str, android_settings: command_utils.O3DEConfig) -> Path:
+        """
+        From the android sdk value (android_sdk_root) from the settings, validate that it is set to a valid location,
+        contains the command line tools at the expected location, and that it is compatible with the input java version
+
+        :param current_java_version:    The version of java to validate the version of the command line tools (if any)
+        :param android_settings:        The android settings to read the android sdk path from
+        :return: The path to the android sdk command line tools (in its expected location)
+        """
+
+        # Validate the android sdk folder was set to a valid location
+        android_sdk_root = android_settings.get_value(SETTINGS_SDK_ROOT.key)
+        if not android_sdk_root:
+            raise AndroidToolError(f"The android sdk path was not set. Set the value of '{SETTINGS_SDK_ROOT}' to the path where the android sdk base is located.")
+        android_sdk_root_path = Path(android_sdk_root)
+        if not android_sdk_root_path.is_dir():
+            raise AndroidToolError(f"The android sdk path '{SETTINGS_SDK_ROOT}' is set to an invalid path '{android_sdk_root_path}'. Folder does not exist.")
+
+        # Make sure that the android command line tool is installed (unzipped) to the expected location under the specified android SDK
+        android_sdk_command_line_tools_root = android_sdk_root_path / 'cmdline-tools' / 'latest'
+        android_sdk_command_line_tool = android_sdk_command_line_tools_root / 'bin' / f'sdkmanager{SDKMANAGER_EXTENSION}'
+        if not android_sdk_command_line_tool.is_file():
+            raise AndroidToolError(f"Unable to locate the android sdk command line tool from the android sdk root set at '{android_sdk_root_path}'. "
+                                   f"Make sure that it is installed at {android_sdk_command_line_tools_root}.")
+
+        # Retrieve the version if possible to validate it can be used
+        command_arg = [android_sdk_command_line_tool.name, '--version']
+        logging.debug("Validating tool version exec: (%s)", subprocess.list2cmdline(command_arg))
+        result = subprocess.run(command_arg,
+                                shell=(platform.system() == 'Windows'),
+                                capture_output=True,
+                                encoding=DEFAULT_READ_ENCODING,
+                                errors=ENCODING_ERROR_HANDLINGS,
+                                cwd=android_sdk_command_line_tool.parent)
+
+        if result.returncode != 0:
+
+            # Check if the error is caused by a mismatched version of java that was used to run the tool
+            match = re.search(r'(class file version\s)([\d\.]+)', result.stdout or result.stderr, re.MULTILINE)
+            if match:
+                # The error is related to a mismatched version of java.
+                java_file_version = match.group(2)
+                cmdline_tool_java_version = JAVA_CLASS_FILE_MAP.get(java_file_version.split('.')[0], None)
+                if cmdline_tool_java_version:
+                    raise AndroidToolError(f"The android SDK command line tool requires java version {cmdline_tool_java_version} but the current version of java is {current_java_version}")
+                else:
+                    raise AndroidToolError(f"The android SDK command line tool requires java that supports class version {java_file_version}, but the current version of java is {current_java_version}")
+            elif re.search('Could not determine SDK root', result.stdout or result.stderr, re.MULTILINE) is not None:
+                # The error is related to the android SDK not being able to be resolved.
+                raise AndroidToolError(f"The android SDK command line tool at {android_sdk_command_line_tool} is not located under a valid Android SDK root.\n"
+                                       "It must exist under a path designated as the Android SDK root, such as: \n\n"
+                                       f"<android_sdk>/cmdline-tools/latest/sdkmanager{SDKMANAGER_EXTENSION}' \n\n"
+                                       "Refer to https://developer.android.com/tools/sdkmanager for more information.\n")
+            else:
+                raise AndroidToolError(f"An error occurred attempt to run the android command line tool:\n{result.stdout or result.stderr}")
+        else:
+            logger.info(f'Verified Android SDK Manager version {result.stdout.strip()}')
+            logger.info(f'Verified Android SDK path at {android_sdk_root_path}')
+            return android_sdk_command_line_tool, android_sdk_root_path
+
+    def call_sdk_manager(self, arguments):
+        """
+        Perform the command line call to the sdk manager
+
+        :param arguments:   The arguments to pass to the SDK manager
+        :return: The subprocess.Result of the call
+        """
+        assert isinstance(arguments, list)
+
+        command_args = [self._android_command_line_tools_sdkmanager_path]
+        command_args.extend(arguments)
+
+        logger.debug(f"Calling sdkmanager:  {subprocess.list2cmdline(arguments)}")
+
+        result = subprocess.run(command_args,
+                                shell=(platform.system() == 'Windows'),
+                                capture_output=True,
+                                encoding=DEFAULT_READ_ENCODING,
+                                errors=ENCODING_ERROR_HANDLINGS)
+        return result
+
+    def get_package_list(self, search_package_path: str, packageCategory: PackageCategory) -> List:
+        """
+        Query for the package information that this manager class maintains based a search query (that uses file patterns)
+        on the category of packages (INSTALLED, AVAILABLE, UPDATEABLE)
+
+        :param search_package_path: The path search pattern (file pattern) to search for the package
+        :param packageCategory:     The category of packages to search on
+        :return:    The list of packages that meet the search path and category inputs
+        """
+
+        def _package_sort(package):
+            # Sort by version number of the package
+            return package.version
+
+        package_detail_result_list = []
+
+        match packageCategory:
+            case AndroidSDKManager.PackageCategory.INSTALLED:
+                package_dict = self._installed_packages
+            case AndroidSDKManager.PackageCategory.AVAILABLE:
+                package_dict = self._available_packages
+            case AndroidSDKManager.PackageCategory.UPDATABLE:
+                package_dict = self._available_updates
+            case _:
+                assert False, "Unsupported package category"
+
+        for installed_package_path, installed_package_details in package_dict.items():
+            if fnmatch.fnmatch(installed_package_path, search_package_path):
+                package_detail_result_list.append(installed_package_details)
+        package_detail_result_list.sort(reverse=True, key=_package_sort)
+        return package_detail_result_list
+
+    def refresh_sdk_installation(self):
+        """
+        Utilize the sdk_manager command line tool from the Android SDK to collect / refresh the list of
+        installed, available, and updateable packages that are managed by the android SDK.
+        """
+        self._installed_packages = {}
+        self._available_packages = {}
+        self._available_updates = {}
+
+        def _factory_installed_package(package_map, item_components):
+            package_map[item_components[0]] = AndroidSDKManager.InstalledPackage(item_components)
+
+        def _factory_available_package(package_map, item_components):
+            package_map[item_components[0]] = AndroidSDKManager.AvailablePackage(item_components)
+
+        def _factory_available_update(package_map, item_components):
+            package_map[item_components[0]] = AndroidSDKManager.AvailableUpdate(item_components)
+
+        # Use the SDK manager to collect the available and installed packages
+        logger.info("Refreshing installed and available packages...")
+        result = self.call_sdk_manager(['--list'])
+        if result.returncode != 0:
+            raise AndroidToolError(f"Error calling sdkmanager (arguments: '--list' error : {result.stderr or result.stdout}")
+        result_lines = result.stdout.split('\n')
+
+        current_append_map = None
+        current_item_factory = None
+        for package_item in result_lines:
+            package_item_stripped = package_item.strip()
+            if not package_item_stripped:
+                continue
+            if '|' not in package_item_stripped:
+                if package_item_stripped.upper() == 'INSTALLED PACKAGES:':
+                    current_append_map = self._installed_packages
+                    current_item_factory = _factory_installed_package
+                elif package_item_stripped.upper() == 'AVAILABLE PACKAGES:':
+                    current_append_map = self._available_packages
+                    current_item_factory = _factory_available_package
+                elif package_item_stripped.upper() == 'AVAILABLE UPDATES:':
+                    current_append_map = self._available_updates
+                    current_item_factory = _factory_available_update
+                else:
+                    current_append_map = None
+                    current_item_factory = None
+                continue
+            item_parts = [split.strip() for split in package_item_stripped.split('|')]
+            if len(item_parts) < 3:
+                continue
+            elif item_parts[1].upper() in ('VERSION', 'INSTALLED', '-------'):
+                continue
+            elif current_append_map is None:
+                continue
+            if current_append_map is not None and current_item_factory is not None:
+                current_item_factory(current_append_map, item_parts)
+
+        logger.info(f"Installed packages: {len(self._installed_packages)}")
+        logger.info(f"Available packages: {len(self._available_packages)}")
+        logger.info(f"Available updates: {len(self._available_updates)}")
+
+    def install_package(self, package_install_path: str, package_description: str):
+        """
+        Install a package based on the path of an available android sdk package
+
+        :param package_install_path:    The path (as reported by the sdk manager) of the package to install locally
+        :param package_description:     A human-readable description to report back status and/or errors from the install operation
+        """
+
+        # Skip installation if the package is already installed
+        package_result_list = self.get_package_list(package_install_path, AndroidSDKManager.PackageCategory.INSTALLED)
+        if package_result_list:
+            installed_package_detail = package_result_list[0]
+            logger.info(f"{installed_package_detail.description} (version {installed_package_detail.version}) Detected")
+            return installed_package_detail
+
+        # Make sure the package name is available
+        package_result_list = self.get_package_list(package_install_path, AndroidSDKManager.PackageCategory.AVAILABLE)
+        if not package_result_list:
+            raise AndroidToolError(f"Invalid Android SDK Package {package_description}: Bad package path {package_install_path}")
+
+        # Reverse sort and pick the first item, which should be the latest (if the install path contains wildcards)
+        def _available_sort(item):
+            return item.path
+
+        package_result_list.sort(reverse=True, key=_available_sort)
+
+        available_package_to_install = package_result_list[0]  # For multiple hits, resolve to the first item which will be the latest version
+
+        # Perform the package installation
+        logger.info(f"Installing {available_package_to_install.description} ...")
+        self.call_sdk_manager(['--install', available_package_to_install.path])
+
+        # Refresh the tracked SDK Contents
+        self.refresh_sdk_installation()
+
+        # Get the package details to verify
+        package_result_list = self.get_package_list(package_install_path, AndroidSDKManager.PackageCategory.INSTALLED)
+        if package_result_list:
+            installed_package_detail = package_result_list[0]
+            logger.info(f"{installed_package_detail.description} (version {installed_package_detail.version}) Installed")
+            return installed_package_detail
+        else:
+            raise AndroidToolError(f"Unable to verify package at {available_package_to_install.path}")
+
+    LICENSE_NOT_ACCEPTED_REGEX = re.compile(r'(^\d of \d.*$)', re.MULTILINE)
+
+    LICENSE_ACCEPTED_REGEX = re.compile(r'(^All SDK package licenses accepted\.$)', re.MULTILINE)
+
+    def check_licenses(self):
+        """
+        Make sure that all the android SDK licenses have been reviewed and accepted first, otherwise raise an exception
+        that provides information on how to accept them.
+        """
+        logger.info("Checking Android SDK Package licenses state..")
+        result = subprocess.run(['echo' , 'Y' , '|', self._android_command_line_tools_sdkmanager_path, '--licenses'],
+                                shell=(platform.system() == 'Windows'),
+                                capture_output=True,
+                                encoding=DEFAULT_READ_ENCODING,
+                                errors=ENCODING_ERROR_HANDLINGS,
+                                timeout=2)
+        license_not_accepted_match = AndroidSDKManager.LICENSE_NOT_ACCEPTED_REGEX.search(result.stdout or result.stderr)
+        if license_not_accepted_match:
+            raise AndroidToolError(f"{license_not_accepted_match.group(1)}\n"
+                                   f"Please run '{self._android_command_line_tools_sdkmanager_path} --licenses' and follow the instructions.\n")
+        license_accepted_match = AndroidSDKManager.LICENSE_ACCEPTED_REGEX.search(result.stdout or result.stderr)
+        if license_accepted_match:
+            logger.info(license_accepted_match.group(1))
+        else:
+            raise AndroidToolError("Unable to determine the Android SDK Package license state. \n"
+                                   f"Please run '{self._android_command_line_tools_sdkmanager_path} --licenses' to troubleshoot the issue.\n")
+
+    def get_android_sdk_path(self) -> str:
+        return self.android_sdk_path
+
+
+def read_android_settings_for_project(project_path:Path) -> Tuple[dict, dict]:
+    """
+    Read the general project and android specific settings for the project, and return the general project settings
+    from project.json, and the android specific settings from 'Platform/Android/android_project.json'
+
+    :param project_path:
+    :return: Tuple of the project settings, and the android-specific settings
+    """
+    if not project_path.is_dir():
+        raise AndroidToolError(f"Invalid project path {project_path}. Path does not exist or is a file.")
+
+    # Read the project.json first
+    project_json_path = project_path / 'project.json'
+    if not project_json_path.is_file():
+        raise AndroidToolError(f"Invalid project path {project_path}. Path does not contain 'project.json.")
+    project_json_content = project_json_path.read_text(encoding=DEFAULT_READ_ENCODING,
+                                                       errors=ENCODING_ERROR_HANDLINGS)
+    project_settings = json.loads(project_json_content)
+    # Read the android_project.json next. If it does not exist, check if this is a legacy project where the android
+    # settings are in the root project.json
+    android_project_json_file = project_path / 'Platform' / 'Android' / 'android_project.json'
+    if android_project_json_file.is_file():
+        android_project_json_content = android_project_json_file.read_text(encoding=DEFAULT_READ_ENCODING,
+                                                                           errors=ENCODING_ERROR_HANDLINGS)
+        android_json = json.loads(android_project_json_content)
+        android_settings = android_json.get('android_settings')
+        if android_settings is None:
+            raise AndroidToolError(f"Missing android settings in file {android_project_json_file}")
+        android_settings = android_json['android_settings']
+    else:
+        android_settings = project_settings.get('android_settings')
+        if android_settings is None:
+            raise AndroidToolError(f"Missing android settings file {android_project_json_file} and cannot located legacy "
+                                   f"'android_settings' from {project_json_path}")
+
+    return project_settings, android_settings
+
+
+class AndroidSigningConfig(object):
+    """
+    Class to manage android signing configs section in a gradle build script
+    """
+    def __init__(self, store_file:Path, store_password:str, key_alias:str, key_password:str):
+        """
+        Initialize this android signing config object
+
+        :param store_file:      The path to the key store file used for the signing configuration
+        :param store_password:  The password to the key store file
+        :param key_alias:       The alias of the private signing key in the key store
+        :param key_password:    The password to the private signing key in the key store referenced by the key alias
+        """
+
+        self._store_file = Path(store_file)
+        self._store_password = store_password
+        self._key_alias = key_alias
+        self._key_password = key_password
+
+    def to_template_string(self, tabs) -> str:
+        """
+        Generate a string that represents a signing config section that can be inserted into a gradle script.
+
+        :param tabs:    The number of leading tabs for each line to insert for better readability of the gradle script
+        :return:    The signing config string section to insert
+        """
+        tab_prefix = ' '* 4 * tabs  # 4 spaces per tab
+        return f"{tab_prefix}storeFile file('{self._store_file.as_posix()}')\n" \
+               f"{tab_prefix}storePassword '{self._store_password}'\n" \
+               f"{tab_prefix}keyPassword '{self._key_password}'\n" \
+               f"{tab_prefix}keyAlias '{self._key_alias}'"
+
+
+JAVA_VERSION_REGEX = re.compile(r'.*(\w)\s(version)\s*\"?(?P<version>[\d\_\.]+)', re.MULTILINE)
+
+
+def validate_java_environment() -> str:
+    """
+    Java is required in order to build android projects with gradle. Check if java is either on the system PATH
+    environment, or set by the JAVA_HOME environment variable and return the version string. This is the same
+    java search approach used by the sdkmanager.
+    """
+
+    java_home = os.getenv('JAVA_HOME', None)
+    if java_home:
+        java_exe_working_dir = os.path.join(java_home,'bin')
+        java_exe_path = os.path.join(java_exe_working_dir, f'java{EXE_EXTENSION}')
+        if not os.path.isfile(java_exe_path):
+            raise AndroidToolError(f"JAVA_HOME is set to an invalid location ({java_home}). There is no java executable located in that path.")
+    else:
+        java_exe_working_dir = None
+
+    result = subprocess.run([f'java{EXE_EXTENSION}', '-version'],
+                            shell=(platform.system() == 'Windows'),
+                            capture_output=True,
+                            encoding=DEFAULT_READ_ENCODING,
+                            errors=ENCODING_ERROR_HANDLINGS,
+                            cwd=java_exe_working_dir)
+
+    if result.returncode != 0:
+        if java_exe_working_dir:
+            raise AndroidToolError(f"Unable to determine java version from {java_exe_working_dir} ({result.stderr or result.stdout})")
+        else:
+            raise AndroidToolError(f"Unable to locate java. Either set it in the PATH environment or set JAVA_HOME to a valid java installation. ({result.stderr or result.stdout})")
+
+    java_version_match = JAVA_VERSION_REGEX.search(result.stdout or result.stderr)
+    if java_version_match is None:
+        raise AndroidToolError(f"Unable to determine java version")
+
+    java_version = java_version_match.group('version')
+    if java_home:
+        logger.info(f"Detected java version {java_version} (from JAVA_HOME)")
+    else:
+        logger.info(f"Detected java version {java_version}")
+
+    return java_version
+
+
+def validate_build_tool(tool_name: str, tool_command: str, tool_config_key: str or None, tool_environment_var: str, tool_config_sub_path: str or None, tool_version_arg: str,
+                        version_regex: str, android_config: command_utils.O3DEConfig) -> (Path, str):
+    """
+    Perform a tool validation by checking on its version number if possible
+
+    :param tool_name:               The name of the tool to display for status purposes
+    :param tool_command:            The name of the tool command that is executed
+    :param tool_config_key:         Optional. If provided, check against the path represented by this key for the tool, otherwise the tool must exist in the PATH environment
+    :param tool_environment_var:    Optional. If provided, check if there is an environment variable that matches this value and use to check the tool
+    :param tool_config_sub_path:    Optional. The sub path that leads to the binary from the value of the tool home folder
+    :param tool_version_arg:        The argument to pass to the <tool_command> to query for its version
+    :param version_regex:           The regex to search for the raw version string from the result of the version query command
+    :param android_config:          The configuration to look up the <tool_config_key> if necessary
+    :return: Tuple of the full tool path, and its raw version string
+    """
+
+    # Locate the tool command. The order of precedence for the search is: 'tool_config_key', 'tool_environment_var', PATH (None)
+    tool_home_and_src_list = []
+    if tool_config_key:
+        config_home = android_config.get_value(tool_config_key)
+        if config_home:
+            tool_home_and_src_list.append( (config_home, f'configration {tool_config_key}') )
+    if tool_environment_var:
+        env_home = os.getenv(tool_environment_var)
+        if env_home:
+            tool_home_and_src_list.append( (env_home, f'environment variable {tool_environment_var}') )
+    tool_home_and_src_list.append( (None, None) )
+
+    for tool_home, tool_home_src in tool_home_and_src_list:
+        if tool_home is not None:
+            tool_test_command = os.path.join(tool_home, tool_config_sub_path, tool_command)
+        else:
+            tool_test_command = tool_command
+
+        # Run the command to get the tool version
+        result = subprocess.run([tool_test_command, tool_version_arg],
+                                shell=(platform.system() == 'Windows'),
+                                capture_output=True,
+                                encoding=DEFAULT_READ_ENCODING,
+                                errors=ENCODING_ERROR_HANDLINGS)
+        if result.returncode == 0:
+            if tool_home:
+                tool_full_path = Path(tool_home) / tool_config_sub_path / tool_command
+            else:
+                tool_full_path = Path(shutil.which(tool_command))
+            break
+        else:
+            logger.warning(f"Unable to resolve tool {tool_name} from {tool_home_src}")
+
+    if result.returncode != 0:
+        error_msgs = [f"Unable to resolve {tool_name}. Make sure its installed and in the PATH environment"]
+        if tool_config_key:
+            error_msgs.append(f', or the {tool_config_key} settings')
+        if tool_environment_var:
+            error_msgs.append(f', or the {tool_environment_var} environment variable')
+        error_msgs.append('.')
+        raise AndroidToolError(''.join(error_msgs))
+
+    # Extract the version number from the results of running the command with the version argument
+    tool_version_match = re.search(version_regex, result.stdout, re.MULTILINE)
+    if tool_version_match is None:
+        raise AndroidToolError(f"Unable to determine {tool_name} version: {result.stdout or result.stderr}")
+    tool_version = tool_version_match.group(2)
+
+    # Report the status
+    logger.info(f"Detected {tool_name} version {tool_version} (From {tool_full_path})")
+    return tool_full_path, tool_version
+
+
+def validate_gradle(android_config) -> (Path, str):
+    """
+    Specialization of validate_build_tool for Gradle
+    :param android_config:      The android configuration to get the tool home if needed
+    :return:    Tuple of : The full path of the tool and the tool version
+    """
+    return validate_build_tool(tool_name='Gradle',
+                               tool_command=f'gradle{GRADLE_EXTENSION}',
+                               tool_config_key=SETTINGS_GRADLE_HOME.key,
+                               tool_environment_var='GRADLE_HOME',
+                               tool_config_sub_path='bin',
+                               tool_version_arg='--version',
+                               version_regex=r'.*(Gradle)\s*\"?([\d\_\.]+)',
+                               android_config=android_config)
+
+
+def validate_cmake(android_config) -> (Path, str):
+    """
+    Specialization of validate_build_tool for CMake
+    :param android_config:      The android configuration to get the tool home if needed
+    :return:    Tuple of : The full path of the tool and the tool version
+    """
+    return validate_build_tool(tool_name='Cmake',
+                               tool_command=f'cmake{EXE_EXTENSION}',
+                               tool_config_key=SETTINGS_CMAKE_HOME.key,
+                               tool_environment_var='CMAKE_HOME',
+                               tool_config_sub_path='bin',
+                               tool_version_arg='--version',
+                               version_regex=r'.*(cmake version)\s*\"?([\d\_\.]+)',
+                               android_config=android_config)
+
+
+def validate_ninja(android_config) -> (Path, str):
+    """
+    Specialization of validate_build_tool for Ninja Build
+    :param android_config:      The android configuration to get the tool home if needed
+    :return:    Tuple of : The full path of the tool and the tool version
+    """
+    return validate_build_tool(tool_name='Ninja',
+                               tool_command=f'ninja{EXE_EXTENSION}',
+                               tool_config_key=None,
+                               tool_environment_var=None,
+                               tool_config_sub_path='',
+                               tool_version_arg='--version',
+                               version_regex=r'(\s*)([\d\_\.]+)',
+                               android_config=android_config)
+
+
+PLATFORM_SETTINGS_FORMAT = """
+# Auto Generated from last cmake project generation ({generation_timestamp})
+
+[settings]
+platform={platform}
+game_projects={project_path}
+asset_deploy_mode={asset_mode}
+
+[android]
+android_sdk_path={android_sdk_path}
+android_gradle_plugin={android_gradle_plugin_version}
+"""
+
+PROJECT_DEPENDENCIES_VALUE_FORMAT = """
+dependencies {{
+{dependencies}
+    api 'androidx.core:core:1.1.0'
+}}
+"""
+
+NATIVE_CMAKE_SECTION_ANDROID_FORMAT = """
+    externalNativeBuild {{
+        cmake {{
+            buildStagingDirectory "{native_build_path}"
+            version "{cmake_version}"
+            path "{absolute_cmakelist_path}"
+        }}
+    }}
+"""
+
+NATIVE_CMAKE_SECTION_DEFAULT_CONFIG_NDK_FORMAT_STR = """
+        ndk {{
+            abiFilters '{abi}'
+        }}
+"""
+
+OVERRIDE_JAVA_SOURCESET_STR = """
+            java {{
+                srcDirs = ['{absolute_azandroid_path}', 'src/main/java']
+            }}
+"""
+
+NATIVE_CMAKE_SECTION_BUILD_TYPE_CONFIG_FORMAT_STR = """
+            externalNativeBuild {{
+                cmake {{
+                    {targets_section}
+                    arguments {arguments}
+                }}
+            }}
+"""
+
+CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR = """
+    task syncLYLayoutMode{config}(type:Exec) {{
+        workingDir '{working_dir}'
+        commandLine {full_command_line}
+    }}
+
+    process{config}MainManifest.dependsOn syncLYLayoutMode{config}
+
+    syncLYLayoutMode{config}.mustRunAfter {{
+        tasks.findAll {{ task->task.name.contains('strip{config}DebugSymbols') }}
+    }}
+    
+    mergeProfileAssets.dependsOn syncLYLayoutMode{config}
+"""
+
+DEFAULT_CONFIG_CHANGES = [
+    'keyboard',
+    'keyboardHidden',
+    'orientation',
+    'screenSize',
+    'smallestScreenSize',
+    'screenLayout',
+    'uiMode',
+]
+
+# 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'
+
+
+APP_NAME = 'app'
+ANDROID_MANIFEST_FILE = 'AndroidManifest.xml'
+ANDROID_LIBRARIES_JSON_FILE = 'android_libraries.json'
+
+ANDROID_LAUNCHER_NAME_PATTERN = "{project_name}.GameLauncher"
+
+class AndroidProjectManifestEnvironment(object):
+    """
+    This class manages the environment for the AndroidManifest.xml template file, based on project settings and environments
+    that were passed in or calculated from the command line arguments.
+    """
+
+    def __init__(self, project_path: Path, project_settings: dict, android_settings: dict, android_gradle_plugin_version: Version, android_platform_sdk_api_level: str, oculus_project: bool):
+        """
+        Initialize the object with the project specific parameters and values for the game project
+
+        :param project_path:                    The path were the project is located
+        :param android_settings:                The android settings to key of custom values
+        :param android_gradle_plugin_version:   The version of the android gradle plugin
+        :param android_platform_sdk_api_level:  The android SDK platform version
+        :param oculus_project:                  Indicates if it's an oculus project
+        """
+
+        # The project name is required
+        project_name = project_settings.get('project_name')
+        if not project_name:
+            raise AndroidToolError(f"Invalid project settings for project at '{project_path}'. Missing required 'project_name' key")
+
+        # The product name is optional. If 'product_name' is missing for the project, fallback to the project name
+        product_name = project_settings.get('product_name', project_name)
+
+        # The 'package_name' setting for android settings is required
+        package_name = android_settings.get('package_name')
+        if not package_name:
+            raise AndroidToolError(f"Invalid android settings for project at '{project_path}'. Missing required 'package_name' key in the android settings.")
+        package_path = package_name.replace('.', '/')
+
+        project_activity = f'{project_name}Activity'
+
+        # Multiview options require special processing
+        multi_window_options = AndroidProjectManifestEnvironment.process_android_multi_window_options(android_settings)
+
+        oculus_intent_filter_category = '<category android:name="com.oculus.intent.category.VR" />' if oculus_project else ''
+
+        self.internal_dict = {
+            'ANDROID_PACKAGE':                  package_name,
+            'ANDROID_PACKAGE_PATH':             package_path,
+            'ANDROID_VERSION_NUMBER':           android_settings["version_number"],
+            "ANDROID_VERSION_NAME":             android_settings["version_name"],
+            "ANDROID_SCREEN_ORIENTATION":       android_settings["orientation"],
+            'ANDROID_APP_NAME':                 product_name,       # external facing name
+            'ANDROID_PROJECT_NAME':             project_name,     # internal facing name
+            'ANDROID_PROJECT_ACTIVITY':         project_activity,
+            'ANDROID_LAUNCHER_NAME':            ANDROID_LAUNCHER_NAME_PATTERN.format(project_name=project_name),
+            'ANDROID_CONFIG_CHANGES':           multi_window_options['ANDROID_CONFIG_CHANGES'],
+            'ANDROID_APP_PUBLIC_KEY':           android_settings.get('app_public_key', 'NoKey'),
+            'ANDROID_APP_OBFUSCATOR_SALT':      android_settings.get('app_obfuscator_salt', ''),
+            'ANDROID_USE_MAIN_OBB':             android_settings.get('use_main_obb', 'false'),
+            'ANDROID_USE_PATCH_OBB':            android_settings.get('use_patch_obb', 'false'),
+            'ANDROID_ENABLE_KEEP_SCREEN_ON':    android_settings.get('enable_keep_screen_on', 'false'),
+            'ANDROID_DISABLE_IMMERSIVE_MODE':   android_settings.get('disable_immersive_mode', 'false'),
+            'ANDROID_TARGET_SDK_VERSION':       android_platform_sdk_api_level,
+            'ICONS':                            android_settings.get('icons', None),
+            'SPLASH_SCREEN':                    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'],
+
+            'OCULUS_INTENT_FILTER_CATEGORY':    oculus_intent_filter_category,
+            'ANDROID_MANIFEST_PACKAGE_OPTION':  '' if android_gradle_plugin_version >= Version('7.0') else f'package="{package_name}"'
+        }
+
+    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
+
+
+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: Path, android_build_dir: Path, android_sdk_path: Path, android_build_tool_version: str, android_platform_sdk_api_level: str,
+                 android_ndk_package: str, project_name: str, project_path: Path, project_general_settings: dict, project_android_settings: dict,
+                 cmake_path: Path, cmake_version: str, gradle_path: Path, gradle_version: str, gradle_custom_jvm_args: str, android_gradle_plugin_version: str,
+                 ninja_path: Path, asset_mode:str, signing_config: AndroidSigningConfig or None, extra_cmake_configure_args: str, src_pak_file_path: str,
+                 strip_debug_symbols: bool = False, overwrite_existing: bool = True, oculus_project: bool = False):
+        """
+        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 Path location of the Engine
+        :param android_build_dir:               The Path of the target folder where the android project will be created 
+        :param android_sdk_path:                The Path to the Android SDK Root used to generate and process the android build script.
+        :param android_build_tool_version:      The Android SDK build-tool version.
+        :param android_platform_sdk_api_level:  The Android Platform SDK API Level to use for the android build
+        :param android_ndk_package:             The Android NDK package version to use
+        :param project_name:                    The name of the project the android build script is being generated for.
+        :param project_path:                    The Path to the root of the project that the android build script is being generated for.
+        :param project_general_settings:        The general project settings (from <project_path>/project.json) for the project (to get the legacy android settings if specified)
+        :param project_android_settings:        The android project settings (from <project_path>/Platform/Android/android_project.json) to get the android settings
+        :param cmake_path:                      The path to cmake to use for the native android build
+        :param cmake_version:                   The version of cmake to use for the native android build
+        :param gradle_path:                     The path to gradle to use for the android gradle build script
+        :param gradle_version:                  The path to gradle to use for the android gradle build script
+        :param android_gradle_plugin_version:   The version of the android gradle plugin to specify for the android build script
+        :param ninja_path:                      The path to the ninja-build tool needed for the native android build step
+        :param asset_mode:                      The asset mode to use when applying the asset layout (see ASSET_MODES)
+        :param signing_config:                  The optional signing config to embed in the android build script. (Required for APK signing)
+        :param extra_cmake_configure_args:      The optional additional cmake arguments to pass down to the cmake project generation command for the native code
+        :param src_pak_file_path:               The sub-path from the project root to where the bundled pak files are expected
+        :param strip_debug_symbols:             Option to strip the debug symbols from the native built libraries
+        :param overwrite_existing:              Option to overwrite the any existing build script
+        :param oculus_project:                  Option to indicate that we are building the android script for oculus devices.
+        """
+
+        # General properties
+        self._engine_root = engine_root
+        self._build_dir = android_build_dir
+
+        # Android SDK Properties
+        self._android_sdk_path = android_sdk_path
+        self._android_sdk_build_tool_version = android_build_tool_version
+        self._android_ndk = android_ndk_package
+        self._android_platform_sdk_api_level = android_platform_sdk_api_level
+
+        # Target project properties
+        self._project_name = project_name
+        self._project_path = project_path
+        self._project_general_settings = project_general_settings
+        self._project_android_settings = project_android_settings
+
+        # Build tool related properties
+        self._cmake_version = cmake_version
+        self._cmake_path = cmake_path
+        self._extra_cmake_configure_args = extra_cmake_configure_args
+        self._gradle_path = gradle_path
+        self._gradle_version = gradle_version
+        self._gradle_plugin_version = Version(android_gradle_plugin_version)
+        self._gradle_custom_jvm_args = gradle_custom_jvm_args
+        self._ninja_path = ninja_path
+        self._strip_debug_symbols = strip_debug_symbols
+
+        # Asset type and location properties
+        self._src_pak_file_path = src_pak_file_path
+        self._asset_mode = asset_mode
+
+        # Gradle script related properties
+        self._android_project_builder_path = self._engine_root / 'Code/Tools/Android/ProjectBuilder'
+        self._native_build_path = 'o3de'
+        self._signing_config = signing_config
+        self._is_oculus_project = oculus_project
+
+        self._overwrite_existing = overwrite_existing
+        self._android_replacement_map_env = {}
+
+    def execute(self):
+        """
+        Execute the android project creation workflow
+        """
+        # If we are using asset PAK mode, then make sure we have pak files, and warn if they are missing
+        if self._asset_mode == ASSET_MODE_PAK:
+            src_pak_file_full_path = self._project_path / self._src_pak_file_path
+            if not src_pak_file_full_path.is_dir():
+                logger.warning(f"Android PAK files are expected at location {src_pak_file_full_path}, but the folder doesnt exist. Make sure "
+                                "to create release bundles (PAK files) before building and deploying to an android device. Refer to "
+                                "https://www.docs.o3de.org/docs/user-guide/packaging/asset-bundler/bundle-assets-for-release/ for more "
+                                "information.")
+            else:
+                pak_count = 0
+                for pak_dir_item in src_pak_file_full_path.iterdir():
+                    if pak_dir_item.name.lower().endswith('_android.pak'):
+                        pak_count += 1
+                if pak_count == 0:
+                    logger.warning(f"Android PAK files are expected at location {src_pak_file_full_path}, but none was detected. Make sure "
+                                    "to create release bundles (PAK files) before building and deploying to an android device. Refer to "
+                                    "https://www.docs.o3de.org/docs/user-guide/packaging/asset-bundler/bundle-assets-for-release/ for more "
+                                    "information.")
+
+        # 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 = {
+            'ANDROID_GRADLE_PLUGIN_VERSION': str(self._gradle_plugin_version),
+            'SDK_VER': self._android_platform_sdk_api_level,
+            'MIN_SDK_VER': self._android_platform_sdk_api_level,
+            'NDK_VERSION': self._android_ndk.version,
+            'SDK_BUILD_TOOL_VER': self._android_sdk_build_tool_version,
+            'LY_ENGINE_ROOT': self._engine_root.as_posix()
+        }
+        # 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()
+
+        logger.info(f"Android project scripts written to '{self._build_dir.absolute()}'.")
+
+    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 = utils.load_template_file(template_file_path=src_template_file_path,
+                                                                        template_env=template_env)
+
+            dst_file.write_text(default_local_properties_content,
+                                encoding=DEFAULT_WRITE_ENCODING,
+                                errors=ENCODING_ERROR_HANDLINGS)
+
+            logger.info('Generated default {}'.format(dst_file.name))
+        else:
+            logger.info('Skipped {} (file exists)'.format(dst_file.name))
+
+    def prepare_gradle_wrapper(self):
+        """
+        Generate the gradle wrapper by calling the validated version of gradle.
+        """
+        logger.info('Preparing Gradle Wrapper')
+
+        if self._gradle_path:
+            gradle_wrapper_cmd = [self._gradle_path]
+        else:
+            gradle_wrapper_cmd = ['gradle']
+
+        gradle_wrapper_cmd.extend(['wrapper', '-p', str(self._build_dir.resolve())])
+
+        proc_result = subprocess.run(gradle_wrapper_cmd,
+                                     shell=(platform.system() == 'Windows'))
+        if proc_result.returncode != 0:
+            raise AndroidToolError(f"Gradle was unable to generate a gradle wrapper for this project (code {proc_result.returncode}): {proc_result.stderr or ''}")
+
+
+    def create_platform_settings(self):
+        """
+        Create the 'platform.settings' file for the deployment script to use
+        """
+        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,
+                                                                    android_sdk_path=str(self._android_sdk_path),
+                                                                    android_gradle_plugin_version=self._gradle_plugin_version)
+
+        platform_settings_file = self._build_dir / 'platform.settings'
+
+        # Check if there already exists the build folder and a 'platform.settings' file. If there is an android gradle
+        # plugin version set, and it is different from the one configured here, we will always overwrite it since
+        # there could be significant differences from one plug-in to the next
+        if platform_settings_file.is_file():
+            config = configparser.ConfigParser()
+            config.read([str(platform_settings_file.resolve(strict=True))])
+            if config.has_option('android', 'android_gradle_plugin'):
+                exist_agp_version = config.get('android', 'android_gradle_plugin')
+                if exist_agp_version != str(self._gradle_plugin_version):
+                    self._overwrite_existing = True
+
+        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
+        """
+        if self._cmake_path:
+            # The cmake dir references the base cmake folder, not the executable path itself, so resolve to the base folder
+            template_cmake_path = Path(self._cmake_path).parents[1].as_posix()
+        else:
+            template_cmake_path = None
+
+        local_properties_env = {
+            "GENERATION_TIMESTAMP": str(datetime.datetime.now().strftime("%c")),
+            "ANDROID_SDK_PATH": self._android_sdk_path.resolve().as_posix(),
+            "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": self._android_sdk_path.as_posix(),
+            "ANDROID_SDK_VERSION": f"android-{self._android_platform_sdk_api_level}"
+        }
+        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=DEFAULT_READ_ENCODING,
+                                                                                                 errors=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._android_replacement_map_env)
+                if os.path.exists(path):
+                    src_dir = path
+                    break
+
+            if not src_dir:
+                failed_paths = ", ".join(string.Template(path).substitute(self._android_replacement_map_env) for path in value['srcDir'])
+                raise AndroidToolError(f'Failed to find library - {libName} - in path(s) [{failed_paths}]. Please download the '
+                                          'library from the Android SDK Manager and run this command again.')
+
+            if 'patches' in value:
+                lib_to_patch = self._Library(name=libName,
+                                             path=src_dir,
+                                             overwrite_existing=self._overwrite_existing,
+                                             gradle_plugin_version=self._gradle_plugin_version,
+                                             signing_config=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():
+            # special case the 'assets' directory before cleaning the whole directory tree
+            utils.remove_link(lumberyard_app_src / 'main' / 'assets')
+            utils.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 = self._engine_root.as_posix()
+        template_project_path = self._project_path.as_posix()
+        template_ndk_path = (self._android_sdk_path / self._android_ndk.location).resolve().as_posix()
+
+        native_build_path = self._native_build_path
+
+        gradle_build_env = dict()
+
+        absolute_cmakelist_path = (self._engine_root / 'CMakeLists.txt').resolve().as_posix()
+        absolute_azandroid_path = (self._engine_root / '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), native_build_path=native_build_path, 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_project_path if self._gradle_plugin_version >= Version("7.0") else str(self._engine_root.as_posix())}"',
+                f'"-DCMAKE_BUILD_TYPE={native_config_lower}"',
+                f'"-DCMAKE_TOOLCHAIN_FILE={template_engine_root}/cmake/Platform/Android/Toolchain_android.cmake"',
+                '"-DLY_DISABLE_TEST_MODULES=ON"',
+
+            ]
+
+            if self._strip_debug_symbols:
+                cmake_argument_list.append('"-DLY_STRIP_DEBUG_SYMBOLS=ON"')
+
+            cmake_argument_list.extend([
+                f'"-DANDROID_NATIVE_API_LEVEL={self._android_platform_sdk_api_level}"',
+                f'"-DLY_NDK_DIR={template_ndk_path}"',
+                '"-DANDROID_STL=c++_shared"',
+                '"-Wno-deprecated"',
+                '"-DLY_MONOLITHIC_GAME=ON"'
+            ])
+            if self._ninja_path:
+                cmake_argument_list.append(f'"-DCMAKE_MAKE_PROGRAM={self._ninja_path.as_posix()}"')
+
+            if self._is_oculus_project:
+                cmake_argument_list.append('"-DANDROID_USE_OCULUS_OPENXR=ON"')
+
+            if self._gradle_plugin_version < Version('7.0'):
+                cmake_argument_list.append(f'"-DLY_PROJECTS={template_project_path}"')
+
+            if self._extra_cmake_configure_args:
+                extra_cmake_configure_arg_list = [f'"{arg}"' for arg in self._extra_cmake_configure_args.split()]
+                cmake_argument_list.extend(extra_cmake_configure_arg_list)
+
+            # 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(arguments=','.join(cmake_argument_list),
+                                                                         targets_section=f'targets "{self._project_name}.GameLauncher"')
+
+            # 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'] = ''
+
+            # If assets must be included inside the APK do the assets layout under
+            # 'main' folder so they will be included into the APK. Otherwise
+            # do the layout under a different folder so it's created, but not
+            # copied into the APK.
+            python_full_path = self._engine_root / 'python' / PYTHON_SCRIPT
+            sync_layout_command_line_source = [f'{python_full_path.resolve().as_posix()}',
+                                            'android_post_build.py', az_android_dst_path.resolve().as_posix(),  # android_app_root
+                                            '--project-root', self._project_path.as_posix(),
+                                            '--gradle-version', self._gradle_version,
+                                            '--asset-mode', self._asset_mode,
+                                            '--asset-bundle-folder', self._src_pak_file_path]
+
+            sync_layout_command_line = ','.join([f"'{arg}'" for arg in sync_layout_command_line_source])
+
+            gradle_build_env[f'CUSTOM_APPLY_ASSET_LAYOUT_{native_config_upper}_TASK'] = \
+                CUSTOM_APPLY_ASSET_LAYOUT_TASK_FORMAT_STR.format(working_dir=(self._engine_root / 'cmake/Tools/Platform/Android').resolve().as_posix(),
+                                                                 full_command_line=sync_layout_command_line,
+                                                                 config=native_config)
+
+            gradle_build_env[f'SIGNING_{native_config_upper}_CONFIG'] = f'signingConfig signingConfigs.{native_config_lower}' if self._signing_config else ''
+
+        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'] = ""
+
+        if self._gradle_plugin_version >= Version('7.0'):
+            package_namespace = self._project_android_settings['package_name']
+            gradle_build_env['PROJECT_NAMESPACE_OPTION'] = f'namespace "{package_namespace}"'
+        else:
+            gradle_build_env['PROJECT_NAMESPACE_OPTION'] = ''
+
+        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_gradle_file)
+
+        # 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(project_path=self._project_path,
+                                                                   project_settings=self._project_general_settings,
+                                                                   android_settings=self._project_android_settings,
+                                                                   android_gradle_plugin_version=self._gradle_plugin_version,
+                                                                   android_platform_sdk_api_level=self._android_platform_sdk_api_level,
+                                                                   oculus_project=self._is_oculus_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=DEFAULT_READ_ENCODING,
+                                        errors=ENCODING_ERROR_HANDLINGS)
+        logger.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_JVM_ARGS': self._gradle_custom_jvm_args
+        }
+        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)
+        logger.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 = utils.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 = utils.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 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 AndroidToolError(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)
+                    utils.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}')
+                        utils.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)
+                utils.remove_dir_path(target_directory)
+
+    class _Library:
+        """
+        Library class to manage the library node in android_libraries.json
+        """
+        def __init__(self, name: str, path: str, overwrite_existing: bool, gradle_plugin_version: str, signing_config=None):
+            self.name = name
+            self.path = Path(path)
+            self.signing_config = signing_config
+            self.overwrite_existing = overwrite_existing
+            self._gradle_plugin_version = gradle_plugin_version
+            self.patch_files = []
+            self.dependencies = []
+            self.build_dependencies = []
+
+        def add_file_to_patch(self, file):
+            self.patch_files.append(file)
+
+        def read_package_namespace(self):
+            # Determine the library namespace (required for Android Gradle Plugin 8+)
+            library_android_manifest_file = self.path / 'AndroidManifest.xml'
+            if not library_android_manifest_file.is_file():
+                raise AndroidToolError(f"Missing expected 'AndroidManifest.xml' file from the anroid library package {self.name}")
+
+            library_android_manifest = library_android_manifest_file.read_text(encoding=DEFAULT_READ_ENCODING, errors=ENCODING_ERROR_HANDLINGS)
+            match_package_name = re.search(r'[\s]*package="([a-zA-Z\.]+)"', library_android_manifest, re.MULTILINE)
+            if not match_package_name:
+                raise AndroidToolError(f"Error reading 'AndroidManifest.xml' file from the anroid library package {self.name}. Unable to locate 'package' attribute.")
+            return match_package_name.group(1)
+
+        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():
+                utils.remove_dir_path(dst_path_src)
+            dst_path.mkdir(parents=True, exist_ok=True)
+
+            logging.debug("Copying library '{}' to '{}'".format(self.name, dst_path))
+
+            # Determine the library namespace (required for Android Gradle Plugin 8+)
+            name_space = self.read_package_namespace()
+
+            # 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,
+                'PROJECT_NAMESPACE': name_space,
+                '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': '',
+                'PROJECT_NAMESPACE_OPTION': ''
+            }
+
+            if self._gradle_plugin_version >= Version('7.0'):
+                build_gradle_env['PROJECT_NAMESPACE_OPTION'] = f'namespace "{name_space}"' if self._gradle_plugin_version >= Version('7.0') else ''
+
+            build_gradle_content = utils.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=DEFAULT_WRITE_ENCODING,
+                                                   errors=ENCODING_ERROR_HANDLINGS)
+
+            src_path = 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

+ 439 - 0
scripts/o3de/o3de/command_utils.py

@@ -0,0 +1,439 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import configparser
+import logging
+import json
+import re
+import os
+
+from getpass import getpass
+from o3de import manifest
+from typing import List, Tuple
+from pathlib import Path
+
+logger = logging.getLogger('o3de')
+
+
+class O3DEConfigError(Exception):
+    pass
+
+GENERAL_BOOLEAN_REGEX = '(t|f|true|false|0|1|on|off|yes|no)'
+
+def evaluate_boolean_from_setting(input: str, default: bool = None) -> bool:
+
+    if input is None:
+        return default
+
+    if not re.match(GENERAL_BOOLEAN_REGEX, input, re.IGNORECASE):
+        raise O3DEConfigError(f"Invalid boolean value {input}. Must match '{GENERAL_BOOLEAN_REGEX}'")
+
+    lower_input = input.lower()
+    match lower_input:
+        case 't' | 'true' | '1' | 'on' | 'yes':
+            return True
+        case 'f' | 'false' | '0' | 'off' | 'no':
+            return False
+        case _:
+            raise O3DEConfigError(f"Invalid boolean value {input}. Must match '{GENERAL_BOOLEAN_REGEX}'")
+
+class SettingsDescription(object):
+
+    def __init__(self, key: str, description: str, default: str= None,  is_password:bool = False, is_boolean = False, restricted_regex: str = None, restricted_regex_description: str = None):
+
+        self._key = key
+        self._description = description
+        self._default = default
+        self._is_password = is_password
+        self._is_boolean = is_boolean
+        self._restricted_regex = re.compile(restricted_regex, re.IGNORECASE) if restricted_regex is not None else None
+        self._restricted_regex_description = restricted_regex_description
+
+        assert not (is_boolean and is_password), 'Only is_boolean or is_password is allowed'
+        assert (restricted_regex and not (is_boolean and is_password)) or (not restricted_regex), 'restricted_regex cannot be set with either is_boolean or is_password'
+        assert (restricted_regex and restricted_regex_description) or not (restricted_regex or restricted_regex_description), 'If restricted_regex is set, then restricted_regex_description must be set as well'
+
+
+    def __str__(self):
+        return self._key
+
+    @property
+    def key(self) -> str:
+        return self._key
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @property
+    def default(self):
+        return self._default
+
+    @property
+    def is_password(self):
+        return self._is_password
+
+    @property
+    def is_boolean(self):
+        return self._is_boolean
+
+    def validate_value(self, input):
+        if self._is_password:
+            raise O3DEConfigError(f"Input value for '{self._key}' must be set through the password setting argument.")
+        if self._is_boolean:
+            evaluate_boolean_from_setting(input)
+        if self._restricted_regex and not self._restricted_regex.match(input):
+            raise O3DEConfigError(f"Input value '{input}' not valid. {self._restricted_regex_description}")
+
+
+def resolve_project_name_and_path(starting_path: Path or None = None) -> (str, Path):
+    """
+    Attempt to resolve the project name and path attempting to find the first 'project.json' that can be discovered based on the 'starting_path'
+
+    :param starting_path:   The starting path to start to search for project.json project marker. If `None`, then the starting path will be the current working directory
+    :return: The tuple of project name and its full path
+    """
+    def _get_project_name(input_project_json_path: Path):
+        # Make sure that the project defined with project.json is a valid o3de project and that it is registered properly
+        with project_json_path.open(mode='r') as json_data_file:
+            try:
+                json_data = json.load(json_data_file)
+            except json.JSONDecodeError as e:
+                raise O3DEConfigError(f"Invalid O3DE project at {project_path}: {e}")
+            project_name = json_data.get('project_name', None)
+            if not project_name:
+                raise O3DEConfigError(f"Invalid O3DE project at {project_path}: Invalid O3DE project json file")
+            return project_name
+
+    # Walk up the path util we find a valid 'project.json'
+    current_working_dir = Path(starting_path) if starting_path is not None else Path(os.getcwd())
+    project_json_path = current_working_dir / 'project.json'
+    while current_working_dir != current_working_dir.parent and not project_json_path.is_file():
+        project_json_path = current_working_dir / 'project.json'
+        current_working_dir = current_working_dir.parent
+    if not project_json_path.is_file():
+        raise O3DEConfigError(f"Unable to locate a 'project.json' file based on directory {current_working_dir}")
+
+    # Extract the project name from resolved project.json file and use it to look up a registered project by its name
+    project_path = project_json_path.parent
+    resolved_project_name = _get_project_name(project_json_path)
+    resolved_project_path = manifest.get_registered(project_name=resolved_project_name)
+    if not resolved_project_path:
+        raise O3DEConfigError(f"Project '{resolved_project_name}' found in {project_json_path} is not registered with O3DE.")
+
+    return resolved_project_name, resolved_project_path
+
+
+class O3DEConfig(object):
+    """
+    This class manages settings for o3de command line tools which are serialized globally, but can be overlayed with
+    values for specified registered projects.
+    """
+    def __init__(self, project_path: Path or None, settings_filename: str, settings_section_name: str,
+                 settings_description_list: List[SettingsDescription]):
+        """
+        Initialize the configuration object
+
+        :param project_path:                Optional. Path to the project root where the project-specific overlay settings will reside. If `None`, then this is a global only configuration with no project-specific override
+        :param settings_filename:           The filename for the setting
+        :param settings_section_name:       The section name that this settings object manages for the settings file
+        :param settings_description_list:   The list of supported setting descriptions
+        """
+
+        self._settings_filename = settings_filename
+        self._settings_section_name = settings_section_name
+
+        # Construct a map to the settings by its key
+        self._settings_description_map = {}
+        for setting in settings_description_list:
+            assert setting.key not in self._settings_description_map, f"Duplicate settings key '{setting.key}' detected"
+            self._settings_description_map[setting.key] = setting
+
+        # Always apply and read the global configuration
+        self._global_settings_file = O3DEConfig.apply_default_global_settings(settings_filename=settings_filename,
+                                                                              settings_section_name=settings_section_name,
+                                                                              settings_descriptions=settings_description_list)
+        global_config_reader = configparser.ConfigParser()
+        global_config_reader.read(self._global_settings_file.absolute())
+        if not global_config_reader.has_section(self._settings_section_name):
+            global_config_reader.add_section(self._settings_section_name)
+        self._global_settings = global_config_reader[self._settings_section_name]
+
+        if project_path is None:
+            # No project name, set to handle only the global configuration
+            self._project_settings_file = None
+            self._project_settings = None
+            self._project_name = None
+        else:
+            self._project_name, project_path = resolve_project_name_and_path(project_path)
+            self._project_settings_file = project_path / settings_filename
+            if self._project_settings_file and not self._project_settings_file.is_file():
+                self._project_settings_file.write_text(f"[{settings_section_name}]\n")
+
+            project_config_reader = configparser.ConfigParser()
+            project_config_reader.read(self._project_settings_file.absolute())
+            if not project_config_reader.has_section(self._settings_section_name):
+                project_config_reader.add_section(self._settings_section_name)
+            self._project_settings = project_config_reader[self._settings_section_name]
+
+    @property
+    def is_global(self) -> bool:
+        """
+        Determine if this object represents only global settings or both project and global settings
+        :return: bool: True if only the global settings are managed, false if not
+        """
+        return self._project_settings_file is None
+
+    @property
+    def project_name(self) -> str or None:
+        return self._project_name
+
+    @property
+    def setting_descriptions(self) -> list:
+        return [setting for _, setting in self._settings_description_map.items()]
+
+    @staticmethod
+    def apply_default_global_settings(settings_filename: str,
+                                      settings_section_name: str,
+                                      settings_descriptions: List[SettingsDescription]) -> Path:
+        """
+        Make sure that the global settings file exists and is populated with the default settings if they are missing
+
+        :param settings_filename:       The name of the settings file (file name only) to use to locate/create/update the settings file.
+        :param settings_section_name:   The settings file section name to create (if needed) for the default settings
+        :return: The path to the global settings file
+        """
+
+        # Make sure that we have a global .o3de folder
+        o3de_folder = manifest.get_o3de_folder()
+        if not o3de_folder.is_dir():
+            raise O3DEConfigError('The .o3de is not registered yet. Make sure to register the engine first.')
+
+        # Make sure a global settings file exists
+        global_settings = manifest.get_o3de_folder() / settings_filename
+        if not global_settings.is_file():
+            # If not create a new one with a single section
+            global_settings.write_text(f"[{settings_section_name}]")
+
+        # Read the global settings file and apply the defaults
+        global_config = configparser.ConfigParser()
+        global_config.read(global_settings.absolute())
+        if not global_config.has_section(settings_section_name):
+            global_config.add_section(settings_section_name)
+        global_section = global_config[settings_section_name]
+        modified = False
+
+        for setting in settings_descriptions:
+
+            config_key = setting.key
+            default_value = setting.default
+
+            # Only apply default values if it is not None
+            if default_value is None:
+                continue
+
+            # Only add default values to keys that don't exist. We don't want to overwrite any existing value
+            if config_key not in global_section:
+                global_section[config_key] = default_value
+                modified = True
+
+        # Write back to the settings file only if there was a modification
+        if modified:
+            with global_settings.open('w') as global_settings_file:
+                global_config.write(global_settings_file)
+            logger.debug(f"Missing default values applied to {global_settings}")
+
+        return global_settings
+
+    def set_config_value(self, key: str, value: str, validate_value: bool = True, show_log: bool = True) -> str:
+        """
+        Apply a settings value to the configuration. If there is a project overlay configured, then only apply the value
+        to the project override. Only apply the value globally if this object is not managing an overlay setting
+
+        :param key:             The key of the entry to set or add.
+        :param value:           The value of the entry to set or add.
+        :param validate_value:  Option to validate the value validity against the key
+        :param show_log:        Option to show logging information
+        :return: The previous value if the key if it is being overwritten, or None if this is a new key+value
+        """
+
+        # Validate the setting key and its value
+        if not key:
+            raise O3DEConfigError("Missing 'key' argument to set a config value")
+        settings_description = self._settings_description_map.get(key, None)
+        if not settings_description:
+            raise O3DEConfigError(f"Unrecognized setting '{key}'")
+        if validate_value:
+            settings_description.validate_value(value)
+
+        is_clear_operation = len(value) == 0
+
+        if self.is_global:
+            # Only apply the setting to the global map
+            current_settings = self._global_settings
+            current_settings_file = self._global_settings_file
+        else:
+            # Apply the setting locally
+            current_settings = self._project_settings
+            current_settings_file = self._project_settings_file
+
+        # Read the settings and apply the change if necessary
+        project_config = configparser.ConfigParser()
+        project_config.read(current_settings_file.absolute())
+        if not project_config.has_section(self._settings_section_name):
+            project_config.add_section(self._settings_section_name)
+        project_config_section = project_config[self._settings_section_name]
+        current_value = project_config_section.get(key, None)
+        if current_value != value:
+            try:
+                current_settings[key] = value
+                project_config_section[key] = value
+            except ValueError as e:
+                raise O3DEConfigError(f"Invalid settings value for setting '{key}': {e}")
+            with current_settings_file.open('w') as current_settings_file:
+                project_config.write(current_settings_file)
+            if show_log:
+                logger.info(f"Setting for {key} cleared.")
+        elif is_clear_operation and not self.is_global and len(self._global_settings.get(key)) > 0:
+            # If the was a clear value request, but the key is only set globally and the global flag was not applied,
+            # then present a warning
+            if show_log:
+                logger.warning(f"Operation skipped. The settings value for {key} was requested to be cleared locally, but is only "
+                               "set globally. Run this request again but with the global flag specified.")
+
+        return current_value
+
+    REGEX_NAME_AND_VALUE_MATCH_UNQUOTED = re.compile(r'(\w[\d\w\.]+)[\s]*=[\s]*((.*))')
+    REGEX_NAME_AND_VALUE_MATCH_SINGLE_QUOTED = re.compile(r"(\w[\d\w\.]+)[\s]*=[\s]*('(.*)')")
+    REGEX_NAME_AND_VALUE_MATCH_DOUBLE_QUOTED = re.compile(r'(\w[\d\w\.]+)[\s]*=[\s]*("(.*)")')
+
+    def set_config_value_from_expression(self, key_and_value: str) -> str:
+        """
+        Apply a settings value to the configuration based on a '<key>=<value>' expression.
+
+        The follow formats are recognized:
+             key=value
+             key='value'
+             key="value"
+
+        If there is a project overlay configured, then only apply the value to the project override. Only apply the
+        value globally if this object is not managing an overlay setting.
+
+        :param key_and_value:     The '<key>=<value>' expression to apply to the settings
+        :return: The previous value if the key if it is being overwritten, or None if this is a new key+value
+        """
+        match = O3DEConfig.REGEX_NAME_AND_VALUE_MATCH_DOUBLE_QUOTED.match(key_and_value)
+        if not match:
+            match = O3DEConfig.REGEX_NAME_AND_VALUE_MATCH_SINGLE_QUOTED.match(key_and_value)
+        if not match:
+            match = O3DEConfig.REGEX_NAME_AND_VALUE_MATCH_UNQUOTED.match(key_and_value)
+        if not match:
+            raise O3DEConfigError(f"Invalid setting key argument: {key_and_value}")
+
+        key = match.group(1)
+        value = match.group(3)
+
+        return self.set_config_value(key, value)
+
+    def get_value_and_source(self, key: str) -> (str, str):
+        """
+        Get a tuple of a particular settings value and its project source based on a key. If the project source is empty, then
+        it represents a global setting.
+
+        :param key: The key to look up the value
+        :return: Tuple (str,str) that represents (value, project_name). If project_name is empty, then the value is global for all projects
+        """
+        settings_description = self._settings_description_map.get(key, None)
+        if not settings_description:
+            raise O3DEConfigError(f"Unrecognized setting '{key}'")
+
+        if self._project_settings:
+            value = self._project_settings.get(key, None)
+            if value:
+                return value, self._project_name
+
+        value = self._global_settings.get(key, None)
+        return value, ""
+
+    def get_value(self, key: str, default: str = None) -> str:
+        """
+        Get the value of a particular setting based on a key.
+        :param key: The key to look up the value
+        :param default: The default value to return if the key is not set
+        :return: The value of the settings based on the key. If the key is not found, return the default
+        """
+        settings_description = self._settings_description_map.get(key, None)
+        if not settings_description:
+            raise O3DEConfigError(f"Unrecognized setting '{key}'")
+        result = self.get_value_and_source(key)[0]
+        return result or default
+
+    def get_boolean_value(self, key: str, default: bool=False) -> bool:
+
+        settings_description = self._settings_description_map.get(key, None)
+        if not settings_description:
+            raise O3DEConfigError(f"Unrecognized setting '{key}'")
+        if not settings_description.is_boolean:
+            raise O3DEConfigError(f"Setting '{key}' is not a boolean value.")
+
+        str_value = self.get_value(key)
+        return evaluate_boolean_from_setting(str_value)
+
+    def get_all_values(self) -> List[Tuple]:
+        """
+        Get all the values that this configuration object represents. The values are stored as a list of tuples of
+        the key, value, and project source (if any, otherwise it represents a global setting)
+
+        :return: List of tuples of the key, value, and project source
+        """
+
+        all_settings_map = {}
+        for key, value in self._global_settings.items():
+            all_settings_map[key] = value
+        if not self.is_global:
+            for key, value in self._project_settings.items():
+                if value:
+                    all_settings_map[key] = value
+
+        all_settings_list = []
+        for key, value in all_settings_map.items():
+            if not self.is_global:
+                all_settings_list.append((key, value, self._project_name if self._project_settings.get(key) else ''))
+            else:
+                all_settings_list.append((key, value, ''))
+
+        return all_settings_list
+
+    def set_password(self, key: str) -> None:
+        """
+        Set a password for a password-specified key 
+        :param key:     The key to the password setting to set
+        """
+        
+        settings_description = self._settings_description_map.get(key, None)
+        if not settings_description:
+            raise O3DEConfigError(f"Unrecognized setting '{key}'")
+        if not settings_description.is_password:
+            raise O3DEConfigError(f"Setting '{key}' is not a password setting.")
+
+        input_password = getpass(f'Please enter the password for {key}: ')
+        if not input_password:
+            raise O3DEConfigError(f"Invalid empty password")
+
+        verify_password = getpass(f'Please verify the password for {key}: ')
+        if input_password != verify_password:
+            raise O3DEConfigError(f"Passwords do not match.")
+    
+        # Set the password bypassing the validity check
+        self.set_config_value(key=key,
+                              value=input_password,
+                              validate_value=False,
+                              show_log=False)
+
+        logger.info(f"Password set for {key}.")
+

+ 45 - 2
scripts/o3de/o3de/utils.py

@@ -16,13 +16,12 @@ import stat
 import pathlib
 import re
 import shutil
-import subprocess
+import string
 import sys
 import urllib.request
 from urllib.parse import ParseResult
 import uuid
 import zipfile
-from packaging.version import Version
 from packaging.specifiers import SpecifierSet
 
 from o3de import github_utils, git_utils, validation as valid
@@ -781,3 +780,47 @@ def safe_kill_processes(*processes: List[Popen], process_logger: logging.Logger
     except Exception:  # purposefully broad
         process_logger.error("Unexpected exception while waiting for processes to terminate, with stacktrace:", exc_info=True)
 
+
+def load_template_file(template_file_path: pathlib.Path, template_env: dict, read_encoding:str = 'UTF-8', encoding_error_action:str='ignore') -> str:
+    """
+    Helper method to load in a template file and return the processed template based on the input template environment
+    This will also handle '###' tokens to strip out of the final output completely to support things like adding
+    copyrights to the template that is not intended for the output text
+
+    :param template_file_path:  The path to the template file to load
+    :param template_env:        The template environment dictionary for the template file to process
+    :param read_encoding:       The text encoding to use to read from the file
+    :param  encoding_error_action:  The action to take on encoding errors
+    :return:    The processed content from the template file
+    :raises:    FileNotFoundError: If the template file path cannot be found
+    """
+    try:
+        template_file_content = template_file_path.resolve(strict=True).read_text(encoding=read_encoding,
+                                                                                  errors=encoding_error_action)
+        # Filter out all lines that start with '###' before replacement
+        filtered_template_file_content = (str(re.sub('###.*', '', template_file_content)).strip())
+
+        return string.Template(filtered_template_file_content).substitute(template_env)
+    except FileNotFoundError:
+        raise FileNotFoundError(f"Invalid file path. Cannot find template file located at {str(template_file_path)}")
+
+
+def remove_link(link:pathlib.PurePath):
+    """
+    Helper function to either remove a symlink, or remove a folder
+    """
+    link = pathlib.PurePath(link)
+    if os.path.isdir(link):
+        try:
+            os.unlink(link)
+        except OSError:
+            # If unlink fails use shutil.rmtree
+            def remove_readonly(func, path, _):
+                # Clear the readonly bit and reattempt the removal
+                os.chmod(path, stat.S_IWRITE)
+                func(path)
+
+            try:
+                shutil.rmtree(link, onerror=remove_readonly)
+            except shutil.Error as shutil_error:
+                raise RuntimeError(f'Error trying remove directory {link}: {shutil_error}', shutil_error.errno)

+ 852 - 0
scripts/o3de/tests/test_android.py

@@ -0,0 +1,852 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import pathlib
+import os
+import pytest
+import re
+
+from o3de import android, android_support, command_utils
+from unittest.mock import patch, MagicMock, Mock, PropertyMock
+
+
+def test_validate_android_config_happy_path(tmpdir):
+
+    tmpdir.ensure('test.keystore', dir=False)
+    test_key_store_path = tmpdir.join('test.keystore').realpath()
+
+    # Test Data
+    test_gradle_version = '8.4'
+    test_validate_java_environment_result = '1.17'
+    test_android_support_validate_gradle_result = ('/home/gradle-8.4/', test_gradle_version)
+
+    test_cmake_path = "/path/cmake"
+    test_cmake_version = "3.22"
+    test_ninja_path = "/path/ninja"
+    test_ninja_version = "1.10.1"
+
+    # Mocks
+    with patch('o3de.android_support.validate_java_environment') as mock_validate_java_environment, \
+         patch('o3de.android_support.validate_gradle') as mock_validate_gradle, \
+         patch('o3de.android_support.validate_cmake') as mock_validate_cmake, \
+         patch('o3de.android_support.validate_ninja') as mock_validate_ninja, \
+         patch('o3de.android_support.get_android_gradle_plugin_requirements') as mock_get_android_gradle_plugin_requirements, \
+         patch('o3de.android_support.AndroidSDKManager') as mock_get_AndroidSDKManager, \
+         patch('o3de.android.logger.warn') as mock_warn:
+
+        mock_validate_java_environment.return_value = test_validate_java_environment_result
+        mock_validate_gradle.return_value = test_android_support_validate_gradle_result
+        mock_validate_cmake.return_value = (test_cmake_path, test_cmake_version)
+        mock_validate_ninja.return_value = (test_ninja_path, test_ninja_version)
+
+        mock_gradle_requirements = Mock()
+        mock_get_android_gradle_plugin_requirements.return_value = mock_gradle_requirements
+
+        mock_sdk_manager = Mock()
+        mock_get_AndroidSDKManager.return_value = mock_sdk_manager
+
+        def _android_config_get_value_(input, default=None):
+            if input == android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key:
+                return "8.4"
+            elif input == android_support.SETTINGS_SIGNING_STORE_FILE.key:
+                return str(test_key_store_path)
+            elif input == android_support.SETTINGS_SIGNING_STORE_PASSWORD.key:
+                return "test_store_file_password"
+            elif input == android_support.SETTINGS_SIGNING_KEY_ALIAS.key:
+                return "test_key_alias"
+            elif input == android_support.SETTINGS_SIGNING_KEY_PASSWORD.key:
+                return "test_key_password"
+            else:
+                return default
+
+        mock_android_config = Mock(spec=command_utils.O3DEConfig)
+        mock_android_config.get_value.side_effect = _android_config_get_value_
+
+        # Call the method
+        result = android.validate_android_config(mock_android_config)
+
+        # Validation
+        mock_gradle_requirements.validate_gradle_version.assert_called_with(test_gradle_version)
+        mock_gradle_requirements.validate_java_version.assert_called_with(test_validate_java_environment_result)
+        mock_sdk_manager.check_licenses.assert_called()
+        mock_validate_java_environment.assert_called_once()
+        mock_validate_gradle.assert_called_once_with(mock_android_config)
+        mock_validate_cmake.assert_called_once_with(mock_android_config)
+        mock_validate_ninja.assert_called_once_with(mock_android_config)
+
+        assert result.cmake_path == test_cmake_path
+        assert result.cmake_version == test_cmake_version
+        assert result.ninja_path == test_ninja_path
+        assert result.ninja_version == test_ninja_version
+        assert result.sdk_manager == mock_sdk_manager
+
+        assert mock_warn.call_count == 0
+
+
[email protected](raises=android_support.AndroidToolError)
+def test_validate_android_config_bad_keystore_path(tmpdir):
+
+    # Test Data
+    mock_validate_java_environment_result = '1.17'
+    mock_android_support_validate_gradle_result = ('/home/gradle-8.4/', '8.4')
+
+    test_cmake_path = "/path/cmake"
+    test_cmake_version = "3.22"
+    test_ninja_path = "/path/ninja"
+    test_ninja_version = "1.10.1"
+
+    # Mocks
+    with patch('o3de.android_support.validate_java_environment') as mock_validate_java_environment, \
+         patch('o3de.android_support.validate_gradle') as mock_validate_gradle, \
+         patch('o3de.android_support.validate_cmake') as mock_validate_cmake, \
+         patch('o3de.android_support.validate_ninja') as mock_validate_ninja, \
+         patch('o3de.android_support.get_android_gradle_plugin_requirements') as mock_get_android_gradle_plugin_requirements, \
+         patch('o3de.android_support.AndroidSDKManager') as mock_get_AndroidSDKManager:
+
+        mock_validate_java_environment.return_value = mock_validate_java_environment_result
+        mock_validate_gradle.return_value = mock_android_support_validate_gradle_result
+        mock_validate_cmake.return_value = (test_cmake_path, test_cmake_version)
+        mock_validate_ninja.return_value = (test_ninja_path, test_ninja_version)
+
+        mock_gradle_requirements = Mock()
+        mock_get_android_gradle_plugin_requirements.return_value = mock_gradle_requirements
+
+        mock_sdk_manager = Mock()
+        mock_get_AndroidSDKManager.return_value = mock_sdk_manager
+
+        mock_android_config = Mock(spec=command_utils.O3DEConfig)
+        def _android_config_get_value_(input, default=None):
+            if input == android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key:
+                return "8.4"
+            elif input == android_support.SETTINGS_SIGNING_STORE_FILE.key:
+                return "test_key_store_path"
+            elif input == android_support.SETTINGS_SIGNING_STORE_PASSWORD.key:
+                return "test_store_file_password"
+            elif input == android_support.SETTINGS_SIGNING_KEY_ALIAS.key:
+                return "test_key_alias"
+            elif input == android_support.SETTINGS_SIGNING_KEY_PASSWORD.key:
+                return "test_key_password"
+            else:
+                return default
+
+        mock_android_config.get_value.side_effect = _android_config_get_value_
+
+        # Test the method
+        android.validate_android_config(mock_android_config)
+        mock_validate_java_environment.assert_called_once()
+        mock_validate_gradle.assert_called_once_with(mock_android_config)
+        mock_validate_cmake.assert_called_once_with(mock_android_config)
+        mock_validate_ninja.assert_called_once_with(mock_android_config)
+
+
[email protected](
+    "test_sc_store_file, test_store_pw, test_key_alias, test_key_password, expected_warning", [
+        pytest.param('', '', '', '', android.VALIDATION_WARNING_SIGNCONFIG_NOT_SET, id='no_signing_configs'),
+        pytest.param('test.keystore', '', '', '', android.VALIDATION_WARNING_SIGNCONFIG_INCOMPLETE, id='only_keystore'),
+        pytest.param('', '', 'test_key_alias', '', android.VALIDATION_WARNING_SIGNCONFIG_INCOMPLETE, id='only_keyalias'),
+        pytest.param('test.keystore', '', 'test_key_alias', '', android.VALIDATION_MISSING_PASSWORD, id='no_passwords'),
+        pytest.param('test.keystore', '1234', 'test_key_alias', '', android.VALIDATION_MISSING_PASSWORD, id='store_password_only'),
+        pytest.param('test.keystore', '', 'test_key_alias', '1234', android.VALIDATION_MISSING_PASSWORD, id='key_passwords_only')
+    ]
+)
+def test_validate_android_signing_config_warnings(tmpdir, test_sc_store_file, test_store_pw, test_key_alias,
+                                                  test_key_password, expected_warning):
+    tmpdir.ensure('test.keystore', dir=False)
+
+    # Test Data
+    test_gradle_version = '8.4'
+    test_validate_java_environment_result = '1.17'
+    test_android_support_validate_gradle_result = ('/home/gradle-8.4/', test_gradle_version)
+
+    test_cmake_path = "/path/cmake"
+    test_cmake_version = "3.22"
+    test_ninja_path = "/path/ninja"
+    test_ninja_version = "1.10.1"
+
+    # Mocks
+    with patch('o3de.android_support.validate_java_environment') as mock_validate_java_environment, \
+         patch('o3de.android_support.validate_gradle') as mock_validate_gradle, \
+         patch('o3de.android_support.validate_cmake') as mock_validate_cmake, \
+         patch('o3de.android_support.validate_ninja') as mock_validate_ninja, \
+         patch('o3de.android_support.get_android_gradle_plugin_requirements') as mock_get_android_gradle_plugin_requirements, \
+         patch('o3de.android_support.AndroidSDKManager') as mock_get_AndroidSDKManager, \
+         patch('o3de.android.logger.warning') as mock_warn:
+
+        mock_validate_java_environment.return_value = test_validate_java_environment_result
+        mock_validate_gradle.return_value = test_android_support_validate_gradle_result
+        mock_validate_cmake.return_value = (test_cmake_path, test_cmake_version)
+        mock_validate_ninja.return_value = (test_ninja_path, test_ninja_version)
+
+        mock_gradle_requirements = Mock()
+        mock_get_android_gradle_plugin_requirements.return_value = mock_gradle_requirements
+
+        mock_sdk_manager = Mock()
+        mock_get_AndroidSDKManager.return_value = mock_sdk_manager
+
+        mock_android_config = Mock(spec=command_utils.O3DEConfig)
+
+        def _mock_android_config_get_value_(input, default=None):
+
+            if input == android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key:
+                return "8.4"
+            elif input == android_support.SETTINGS_SIGNING_STORE_FILE.key:
+
+                if test_sc_store_file:
+                    tmpdir.ensure(test_sc_store_file, dir=False)
+                    test_key_store_path = tmpdir.join(test_sc_store_file).realpath()
+                    return str(test_key_store_path)
+                else:
+                    return ""
+            elif input == android_support.SETTINGS_SIGNING_STORE_PASSWORD.key:
+                return test_store_pw
+            elif input == android_support.SETTINGS_SIGNING_KEY_ALIAS.key:
+                return test_key_alias
+            elif input == android_support.SETTINGS_SIGNING_KEY_PASSWORD.key:
+                return test_key_password
+            else:
+                return default
+
+        mock_android_config.get_value.side_effect = _mock_android_config_get_value_
+
+        # Test the method
+        android.validate_android_config(mock_android_config)
+
+        # Validation
+        mock_gradle_requirements.validate_gradle_version.assert_called_with(test_gradle_version)
+        mock_gradle_requirements.validate_java_version.assert_called_with(test_validate_java_environment_result)
+        mock_sdk_manager.check_licenses.assert_called()
+        mock_validate_java_environment.assert_called_once()
+        mock_validate_gradle.assert_called_once_with(mock_android_config)
+        mock_validate_cmake.assert_called_once_with(mock_android_config)
+        mock_validate_ninja.assert_called_once_with(mock_android_config)
+
+        assert mock_warn.call_count == 1
+        mock_call_args = mock_warn.mock_calls[0].args
+        assert mock_call_args[0] == expected_warning
+
+
+def test_list_android_config_local():
+
+    # Test Data
+    test_project_name = 'foo'
+    all_settings = [
+        ('one', 'uno', None),
+        ('two', 'dos', None),
+        ('three', 'tres', None),
+        ('four', 'quattro', test_project_name)
+    ]
+
+    # Mocks
+    with patch('builtins.print') as mock_print:
+
+        mock_config = PropertyMock(spec=command_utils.O3DEConfig)
+        mock_config.get_all_values.return_value = all_settings
+        mock_config.is_global = False
+        mock_config.project_name = test_project_name
+
+        # Test the method
+        android.list_android_config(mock_config)
+
+        # Validation
+        assert mock_print.call_count == len(all_settings) + 2  # the extra two are for the header and the non-global addendum
+
+
+def test_list_android_config_global():
+
+    # Test Data
+    all_settings = [
+        ('one', 'uno', None),
+        ('two', 'dos', None),
+        ('three', 'tres', None)
+    ]
+
+    # Mocks
+    with patch('builtins.print') as mock_print:
+
+        mock_config = PropertyMock(spec=command_utils.O3DEConfig)
+        mock_config.get_all_values.return_value = all_settings
+        mock_config.is_global = True
+        mock_config.project_name = ""
+
+        # Test the method
+        android.list_android_config(mock_config)
+
+        # Validation
+        assert mock_print.call_count == len(all_settings) + 1  # the extra one is for the header
+
+
+def test_get_android_config_from_args_global():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'global', True)
+
+    # Mocks
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.android.logger.warn') as mock_warning:
+
+        mock_get_android_config.return_value = Mock()
+
+        # Test the method
+        result, _ = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert result == mock_get_android_config.return_value
+        assert mock_warning.call_count == 0
+        assert mock_get_android_config.call_count == 1
+        mock_call_args = mock_get_android_config.call_args_list[0]
+        assert 'project_path' in mock_call_args.kwargs
+        assert mock_call_args.kwargs['project_path'] is None
+
+
+def test_get_android_config_from_args_global_with_warning_project_name():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'global', True)
+    setattr(test_args, 'project', "Foo")
+
+    # Mocjs
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.android.logger.warning') as mock_warning:
+
+        mock_get_android_config.return_value = Mock()
+
+        # Test the method
+        result = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert mock_warning.call_count == 1
+        assert mock_get_android_config.call_count == 1
+        mock_call_args = mock_get_android_config.call_args_list[0]
+        assert 'project_path' in mock_call_args.kwargs
+        assert mock_call_args.kwargs['project_path'] is None
+
+
+def test_get_android_config_no_project_name_no_project_detected():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'global', False)
+    setattr(test_args, 'project', "")
+
+    # Mocks
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.android.logger.info') as mock_info, \
+         patch('o3de.command_utils.resolve_project_name_and_path', new_callable=MagicMock) as mock_resolve:
+
+        mock_resolve.side_effect = command_utils.O3DEConfigError
+
+        mock_get_android_config.return_value = Mock()
+
+        # Test the method
+        result, project_name = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert result == mock_get_android_config.return_value
+        assert project_name is None
+        assert mock_info.call_count == 1
+        assert re.search(r'(based on the global settings)', mock_info.call_args.args[0]) is not None
+        assert mock_get_android_config.call_count == 1
+        assert 'project_path' in mock_get_android_config.call_args_list[0].kwargs
+        assert mock_get_android_config.call_args_list[0].kwargs['project_path'] is None
+
+
+def test_get_android_config_no_project_name_project_detected():
+
+    # Test Data
+    detected_project = "Foo"
+    detected_path = '/foo'
+    test_args = PropertyMock
+    setattr(test_args, 'global', False)
+    setattr(test_args, 'project', "")
+
+    # Mocks
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.android.logger.info') as mock_info, \
+         patch('o3de.command_utils.resolve_project_name_and_path', new_callable=MagicMock) as mock_resolve:
+
+        mock_resolve.return_value = (detected_project, detected_path)
+
+        mock_get_android_config.return_value = Mock()
+
+        # Test the method
+        result, project_name = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert result == mock_get_android_config.return_value
+        assert project_name == detected_project
+        assert mock_info.call_count == 1
+        assert re.search(f'(based on the currently detected project \\({detected_project}\\))', mock_info.call_args.args[0]) is not None
+        assert mock_get_android_config.call_count == 1
+        assert 'project_path' in mock_get_android_config.call_args_list[0].kwargs
+        assert mock_get_android_config.call_args_list[0].kwargs['project_path'] == detected_path
+
+
+def test_get_android_config_project_path(tmpdir):
+
+    tmpdir.ensure("project", dir=True)
+
+
+    # Test Data
+    test_path = pathlib.Path(tmpdir.join('project').realpath())
+    test_project = 'project'
+
+
+    test_args = PropertyMock
+    setattr(test_args, 'global', False)
+    setattr(test_args, 'project', str(test_path))
+
+    # Mocks
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.android.logger.info') as mock_info, \
+         patch('o3de.command_utils.resolve_project_name_and_path', new_callable=MagicMock) as mock_resolve:
+
+        mock_resolve.return_value = (test_project, test_path)
+
+        mock_get_android_config.return_value = Mock()
+
+        # Test the method
+        result, project_name = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert result == mock_get_android_config.return_value
+        assert project_name == test_project
+        assert mock_info.call_count == 1
+        assert re.search(f'(will be based on project \\({test_project}\\))', mock_info.call_args.args[0]) is not None
+        assert mock_get_android_config.call_count == 1
+        assert 'project_path' in mock_get_android_config.call_args_list[0].kwargs
+        assert mock_get_android_config.call_args_list[0].kwargs['project_path'] == test_path
+
+
+def test_get_android_config_no_global_project_name_provided():
+
+    # Test Data
+    test_project = "Foo"
+    test_project_path = pathlib.Path('/foo')
+    test_args = PropertyMock
+    setattr(test_args, 'global', False)
+    setattr(test_args, 'project', test_project)
+
+    # Mocks
+    with patch('o3de.android_support.get_android_config') as mock_get_android_config, \
+         patch('o3de.manifest.get_registered') as mock_get_registered, \
+         patch('o3de.android.logger.info') as mock_info:
+
+        mock_get_android_config.return_value = Mock()
+
+        mock_get_registered.return_value = test_project_path
+
+        # Test the method
+        result, project_name = android.get_android_config_from_args(test_args)
+
+        # Validation
+        assert result == mock_get_android_config.return_value
+        assert project_name == test_project
+        assert mock_info.call_count == 1
+        assert re.search(f'(will be based on project \\({test_project}\\))', mock_info.call_args.args[0]) is not None
+        assert mock_get_android_config.call_count == 1
+        assert 'project_path' in mock_get_android_config.call_args_list[0].kwargs
+        assert mock_get_android_config.call_args_list[0].kwargs['project_path'] == test_project_path
+
+
+def test_configure_android_options_list():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', True)
+    setattr(test_args, 'validate', False)
+    setattr(test_args, 'set_value', False)
+    setattr(test_args, 'set_password', False)
+    setattr(test_args, 'clear_value', False)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.list_android_config') as mock_list_android, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.info') as mock_logger_info:
+
+        mock_android_config = Mock()
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+
+        # Test the method
+        android.configure_android_options(test_args)
+
+        # Validation
+        mock_get_android_config_from_args.assert_called_once_with(test_args)
+        mock_list_android.assert_called_once_with(mock_android_config)
+
+
+def test_configure_android_options_validate():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', False)
+    setattr(test_args, 'validate', True)
+    setattr(test_args, 'set_value', False)
+    setattr(test_args, 'set_password', False)
+    setattr(test_args, 'clear_value', False)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.validate_android_config') as mock_validate_android_config, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.info') as mock_logger_info:
+
+        mock_android_config = Mock()
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+
+        # Test the method
+        android.configure_android_options(test_args)
+
+        # Validation
+        mock_get_android_config_from_args.assert_called_once_with(test_args)
+        mock_validate_android_config.assert_called_once_with(mock_android_config)
+
+
+def test_configure_android_options_set_value():
+
+    # Test Data
+    test_value = 'test.key=FooValue'
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', False)
+    setattr(test_args, 'validate', False)
+    setattr(test_args, 'set_value', test_value)
+    setattr(test_args, 'set_password', False)
+    setattr(test_args, 'clear_value', False)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.info') as mock_logger_info:
+
+        mock_android_config = Mock()
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+
+        # Test the method
+        android.configure_android_options(test_args)
+
+        # Validation
+        assert mock_get_android_config_from_args.call_count == 1
+        mock_android_config.set_config_value_from_expression.assert_called_once_with(test_value)
+
+
+def test_configure_android_options_set_password():
+
+    # Test Data
+    test_value = 'test.key'
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', False)
+    setattr(test_args, 'validate', False)
+    setattr(test_args, 'set_value', False)
+    setattr(test_args, 'set_password', test_value)
+    setattr(test_args, 'clear_value', False)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.command_utils.logger.info') as mock_logger_info:
+
+        mock_android_config = Mock(spec=command_utils.O3DEConfig)
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+
+        # Test the method
+        android.configure_android_options(test_args)
+
+        # Validation
+        assert mock_get_android_config_from_args.call_count == 1
+        mock_android_config.set_password.assert_called_once_with(test_value)
+
+
+def test_configure_android_options_clear_value():
+
+    # Test Data
+    test_value = 'test.key'
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', False)
+    setattr(test_args, 'validate', False)
+    setattr(test_args, 'set_value', None)
+    setattr(test_args, 'set_password', None)
+    setattr(test_args, 'clear_value', test_value)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.info') as mock_logger_info:
+
+        mock_android_config = Mock()
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+
+        # Test the method
+        android.configure_android_options(test_args)
+
+        # Validation
+        assert mock_get_android_config_from_args.call_count == 1
+        mock_android_config.set_config_value.assert_called_once_with(key=test_value, value='', validate_value=False)
+        assert mock_logger_info.call_count == 1
+
+
+def test_configure_android_options_validate_error():
+
+    # Test Data
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'list', False)
+    setattr(test_args, 'validate', True)
+    setattr(test_args, 'set_value', False)
+    setattr(test_args, 'set_password', False)
+    setattr(test_args, 'clear_value', False)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.android.validate_android_config') as mock_validate_android_config, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.error') as mock_logger_error:
+
+        mock_android_config = Mock()
+        mock_get_android_config_from_args.return_value = (mock_android_config, 'foo')
+        mock_validate_android_config.side_effect = android_support.AndroidToolError("BAD")
+
+        result = android.configure_android_options(test_args)
+
+        # Validation
+        assert result == 1
+        assert mock_logger_error.call_count == 1
+
+
+def test_prompt_password_empty_error():
+
+    # Test Data
+    test_key_name = 'Foo'
+
+    # Mocks
+    with patch('o3de.android.getpass') as mock_get_pass:
+
+        mock_get_pass.return_value = ""
+
+        try:
+            # Test the method
+            android.prompt_validated_password(test_key_name)
+
+        except android_support.AndroidToolError as err:
+            # Validate the error
+            assert f"Password for {test_key_name} required." == str(err)
+        else:
+            assert False, "Error expected"
+
+
+def test_prompt_password_mismatch_error():
+
+    # Test Data
+    test_key_name = 'Foo'
+
+    # Mocks
+    with patch('o3de.android.getpass') as mock_get_pass:
+
+        get_pass_call_count = 0
+        def _mock_get_pass_(prompt='Password: ', stream=None):
+            nonlocal get_pass_call_count
+            get_pass_call_count += 1
+            return f"foo{get_pass_call_count}"
+
+        mock_get_pass.side_effect = _mock_get_pass_
+
+        # Test the method
+        try:
+            android.prompt_validated_password(test_key_name)
+        except android_support.AndroidToolError as err:
+            # Validate the error
+            assert f"Passwords for {test_key_name} do not match." == str(err)
+        else:
+            assert False, "Error expected"
+
+
+def test_prompt_password_success():
+
+    # Test Data
+    test_key_name = 'Foo'
+    test_password = '1111'
+
+    # Mocks
+    with patch('o3de.android.getpass') as mock_get_pass:
+
+        mock_get_pass.return_value = test_password
+
+        # Test the method
+        result = android.prompt_validated_password(test_key_name)
+
+        # Validation
+        assert result == test_password
+        assert mock_get_pass.call_count == 2
+
+
+def test_generate_android_project_success(tmpdir):
+
+    # Test Data
+    test_project_name = "Foo"
+    test_project_root = f"{os.sep}foo{os.sep}bar{os.sep}Foo"
+    test_java_version = '17.1'
+    test_gradle_path = f"{os.sep}usr{os.sep}bin{os.sep}gradle"
+    test_gradle_version = '8.4'
+    test_android_grade_plugin_version = '8.1'
+    test_cmake_path = f"{os.sep}usr{os.sep}bin{os.sep}cmake"
+    test_cmake_version = '3.25'
+    test_ninja_path = f"{os.sep}usr{os.sep}bin{os.sep}ninja"
+    test_ninja_version = '1.10'
+    test_sdk_build_tools_version = "1.10.1"
+    test_extra_cmake_args = '-DLY_FOO=ON'
+    test_custom_jvm_args = '-Xm1024m'
+    test_build_dir = f'{os.sep}home{os.sep}project{os.sep}android'
+    test_sc_store_file = 'o3de.keystore'
+    test_sc_key_alias = 'test_key_alias'
+    test_store_pw = '1111'
+    test_key_password = '2222'
+    test_android_sdk_path = f'{os.sep}usr{os.sep}android{os.sep}sdk'
+    test_platform_api_level = '31'
+    test_asset_mode = 'LOOSE'
+    test_bundle_subpath = f'AssetBundling{os.sep}Bundles'
+    test_ndk_version = '25.*'
+
+    tmpdir.ensure(test_sc_store_file, dir=False)
+    test_key_store_path = tmpdir.join(test_sc_store_file).realpath()
+
+    test_args = PropertyMock
+    setattr(test_args, 'debug', False)
+    setattr(test_args, 'extra_cmake_args', test_extra_cmake_args)
+    setattr(test_args, 'custom_jvm_args', test_custom_jvm_args)
+    setattr(test_args, 'build_dir', test_build_dir)
+    setattr(test_args, 'signconfig_store_file', str(test_key_store_path))
+    setattr(test_args, 'signconfig_key_alias', test_sc_key_alias)
+    setattr(test_args, 'platform_sdk_api_level', test_platform_api_level)
+    setattr(test_args, 'asset_mode', test_asset_mode)
+    setattr(test_args, 'ndk_version', test_ndk_version)
+
+    # Mocks
+    with patch('o3de.android.get_android_config_from_args') as mock_get_android_config_from_args, \
+         patch('o3de.manifest.get_registered') as mock_manifest_get_registered, \
+         patch('o3de.android_support.read_android_settings_for_project') as mock_read_android_settings_for_project, \
+         patch('o3de.android.validate_android_config') as mock_validate_android_config, \
+         patch('o3de.android_support.AndroidProjectGenerator') as mock_get_android_project_generator, \
+         patch('o3de.android_support.AndroidSigningConfig') as mock_get_signing_config, \
+         patch('o3de.android.logger.setLevel') as mock_logger_set_level, \
+         patch('o3de.android.logger.error') as mock_logger_error:
+
+        def _mock_android_config_get_value_(key, default=None):
+            if key == android_support.SETTINGS_GRADLE_PLUGIN_VERSION.key:
+                return test_android_grade_plugin_version
+            elif key == android_support.SETTINGS_ASSET_BUNDLE_SUBPATH.key:
+                return test_bundle_subpath
+            elif key == android_support.SETTINGS_SIGNING_STORE_PASSWORD.key:
+                return test_store_pw
+            elif key == android_support.SETTINGS_SIGNING_KEY_PASSWORD.key:
+                return test_key_password
+            else:
+                assert False
+
+        def _mock_android_config_get_boolean_value_(key: str, default: bool = False):
+            if key == android_support.SETTINGS_STRIP_DEBUG.key:
+                return True
+            elif key == android_support.SETTINGS_OCULUS_PROJECT.key:
+                return False
+            else:
+                assert False
+
+        mock_android_config = Mock(spec=command_utils.O3DEConfig)
+        mock_android_config.get_value.side_effect = _mock_android_config_get_value_
+        mock_android_config.get_boolean_value = _mock_android_config_get_boolean_value_
+
+        mock_get_android_config_from_args.return_value = (mock_android_config, test_project_name)
+
+        mock_manifest_get_registered.return_value = test_project_root
+
+        mock_project_settings = Mock()
+        mock_android_settings = Mock()
+
+        mock_read_android_settings_for_project.return_value = (mock_project_settings, mock_android_settings)
+
+        mock_android_gradle_plugin_requirements = Mock()
+        setattr(mock_android_gradle_plugin_requirements,'sdk_build_tools_version', test_sdk_build_tools_version)
+
+        mock_sdk_manager = Mock()
+        mock_sdk_manager.get_android_sdk_path.return_value = test_android_sdk_path
+
+        mock_ndk_package = Mock()
+        mock_sdk_manager.install_package.return_value = mock_ndk_package
+
+        test_android_env = android.ValidatedEnv(java_version=test_java_version,
+                                                gradle_home=test_gradle_path,
+                                                gradle_version=test_gradle_version,
+                                                cmake_path=test_cmake_path,
+                                                cmake_version=test_cmake_version,
+                                                ninja_path=test_ninja_path,
+                                                ninja_version=test_ninja_version,
+                                                android_gradle_plugin_ver=test_android_grade_plugin_version,
+                                                sdk_build_tools_version=test_sdk_build_tools_version,
+                                                sdk_manager=mock_sdk_manager)
+        mock_validate_android_config.return_value = test_android_env
+
+
+        mock_android_project_generator = Mock()
+        mock_get_android_project_generator.return_value = mock_android_project_generator
+
+        mock_signing_config = Mock()
+        mock_get_signing_config.return_value = mock_signing_config
+
+        # Test the method
+        result = android.generate_android_project(test_args)
+
+        # Validation
+        mock_manifest_get_registered.assert_called_once_with(project_name=test_project_name)
+
+        mock_read_android_settings_for_project.assert_called_once_with(test_project_root)
+
+        mock_validate_android_config.assert_called_once()
+
+        assert mock_sdk_manager.install_package.call_count == 5
+
+        mock_get_signing_config.assert_called_once_with(store_file=pathlib.Path(test_key_store_path),
+                                                        store_password=test_store_pw,
+                                                        key_alias=test_sc_key_alias,
+                                                        key_password=test_key_password)
+
+        mock_get_android_project_generator.assert_called_once_with(engine_root=android.ENGINE_PATH,
+                                                                   android_build_dir=pathlib.Path(test_build_dir),
+                                                                   android_sdk_path=test_android_sdk_path,
+                                                                   android_build_tool_version=test_sdk_build_tools_version,
+                                                                   android_platform_sdk_api_level=test_platform_api_level,
+                                                                   android_ndk_package=mock_ndk_package,
+                                                                   project_name=test_project_name,
+                                                                   project_path=test_project_root,
+                                                                   project_general_settings=mock_project_settings,
+                                                                   project_android_settings=mock_android_settings,
+                                                                   cmake_version=test_cmake_version,
+                                                                   cmake_path=test_cmake_path,
+                                                                   gradle_path=test_gradle_path,
+                                                                   gradle_version=test_gradle_version,
+                                                                   gradle_custom_jvm_args=test_custom_jvm_args,
+                                                                   android_gradle_plugin_version=test_android_grade_plugin_version,
+                                                                   ninja_path=test_ninja_path,
+                                                                   asset_mode=test_asset_mode,
+                                                                   signing_config=mock_signing_config,
+                                                                   extra_cmake_configure_args=test_extra_cmake_args,
+                                                                   overwrite_existing=True,
+                                                                   strip_debug_symbols=True,
+                                                                   src_pak_file_path=test_bundle_subpath,
+                                                                   oculus_project=False)
+
+        mock_android_project_generator.execute.assert_called_once()

+ 884 - 0
scripts/o3de/tests/test_android_support.py

@@ -0,0 +1,884 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import os
+import subprocess
+
+import pytest
+from unittest.mock import patch, Mock, PropertyMock
+
+from o3de import android_support, command_utils
+from packaging.version import Version
+
+
[email protected](
+    "agp_version, expected_gradle_version", [
+        pytest.param("8.1.0", "8.0"),
+        pytest.param("8.0.0", "8.0"),
+    ]
+)
+def test_get_android_gradle_plugin_requirements(agp_version, expected_gradle_version):
+
+    result = android_support.get_android_gradle_plugin_requirements(requested_agp_version=agp_version)
+    assert result.gradle_ver == Version(expected_gradle_version)
+
+
+def test_validate_java_environment_from_java_home(tmpdir):
+
+    # Test Data
+    test_java_version = '17.0.9'
+    test_result_java_version = f"""
+        Picked up JAVA_TOOL_OPTIONS: -Dlog4j2.formatMsgNoLookups=true
+        java version "{test_java_version}" 2023-10-17 LTS
+        Java(TM) SE Runtime Environment (build 17.0.9+11-LTS-201)
+        Java HotSpot(TM) 64-Bit Server VM (build 17.0.9+11-LTS-201, mixed mode, sharing)
+    """
+
+    tmpdir.ensure(f'java_home/bin/java{android_support.EXE_EXTENSION}')
+    tmpdir.join(f'java_home/bin/java{android_support.EXE_EXTENSION}').realpath()
+
+    # Mocks
+    with patch('os.getenv') as mock_os_getenv, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_os_getenv.return_value = tmpdir.join(f'java_home').realpath()
+
+        mock_completed_process = PropertyMock()
+        mock_completed_process.returncode = 0
+        mock_completed_process.stdout = test_result_java_version
+
+        mock_subprocess_run.return_value = mock_completed_process
+
+        # Test the method
+        version = android_support.validate_java_environment()
+
+        # Validation
+        mock_os_getenv.assert_called_once()
+        assert version == test_java_version
+
+
[email protected](raises=android_support.AndroidToolError)
+def test_validate_java_environment_from_invalid_java_home(tmpdir):
+
+    # Mocks
+    with patch('os.getenv') as mock_os_getenv:
+
+        mock_os_getenv.return_value = tmpdir.join(f'empty')
+
+        # Test the method
+        android_support.validate_java_environment()
+
+
+def test_validate_java_environment_from_java_home_error(tmpdir):
+
+    tmpdir.ensure(f'java_home/bin/java{android_support.EXE_EXTENSION}')
+    tmpdir.join(f'java_home/bin/java{android_support.EXE_EXTENSION}').realpath()
+
+    # Test Data
+    test_error = "An error Occured"
+    test_java_home_val = tmpdir.join(f'java_home').realpath()
+
+    # Mocks
+    with patch('os.getenv') as mock_os_getenv, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_os_getenv.return_value = test_java_home_val
+
+        mock_completed_process = PropertyMock()
+        mock_completed_process.returncode = 1
+        mock_completed_process.stderr = test_error
+
+        mock_subprocess_run.return_value = mock_completed_process
+
+        try:
+            # Test the method
+            android_support.validate_java_environment()
+        except android_support.AndroidToolError as err:
+            assert test_error in str(err)
+        else:
+            assert False, "Expected AndroidToolError(JAVA_HOME invalid)"
+
+
+def test_validate_java_environment_from_path(tmpdir):
+
+    # Test Data
+    test_java_version = '17.0.9'
+    test_result_java_version = f"""
+        Picked up JAVA_TOOL_OPTIONS: -Dlog4j2.formatMsgNoLookups=true
+        java version "{test_java_version}" 2023-10-17 LTS
+        Java(TM) SE Runtime Environment (build 17.0.9+11-LTS-201)
+        Java HotSpot(TM) 64-Bit Server VM (build 17.0.9+11-LTS-201, mixed mode, sharing)
+    """
+
+    # Mocks
+    with patch('os.getenv') as mock_os_getenv, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_os_getenv.return_value = None
+
+        mock_subprocess_result = PropertyMock
+        mock_subprocess_result.returncode = 0
+        mock_subprocess_result.stdout = test_result_java_version
+        mock_subprocess_run.return_value = mock_subprocess_result
+
+        # Test the method
+        version = android_support.validate_java_environment()
+
+        # Validation
+        assert version == test_java_version
+        mock_os_getenv.assert_called_once()
+        mock_subprocess_run.assert_called_once()
+
+
[email protected](
+    "agp_version, java_version", [
+        pytest.param("8.0.0", "17"),
+        pytest.param("8.0.1", "17.0"),
+        pytest.param("8.0.2", "17.0.2"),
+        pytest.param("8.1.0", "17.21.43"),
+    ]
+)
+def test_android_gradle_plugin_requirements_java_pass(agp_version, java_version):
+
+    agp_pass = android_support.get_android_gradle_plugin_requirements(agp_version)
+    agp_pass.validate_java_version(java_version)
+
+
[email protected](
+    "agp_version, java_version", [
+        pytest.param("8.0.0", "11"),
+        pytest.param("8.0.1", "11.0"),
+        pytest.param("8.0.2", "8.0.2"),
+        pytest.param("8.1.0", "11.21.43"),
+        pytest.param("7.1.0", "17"),
+    ]
+)
[email protected](raises=android_support.AndroidToolError)
+def test_android_gradle_plugin_requirements_java_fail(agp_version, java_version):
+
+    agp_fail = android_support.get_android_gradle_plugin_requirements(agp_version)
+    agp_fail.validate_java_version(java_version)
+
+
[email protected](
+    "agp_version, gradle_version", [
+        pytest.param("8.0.0", "8.0"),
+        pytest.param("8.0.1", "8.1"),
+        pytest.param("8.0.2", "8.1.2"),
+        pytest.param("8.1.0", "8.3"),
+    ]
+)
+def test_android_gradle_plugin_requirements_gradle_pass(agp_version, gradle_version):
+
+    agp_pass = android_support.get_android_gradle_plugin_requirements(agp_version)
+    agp_pass.validate_gradle_version(gradle_version)
+
+
[email protected](
+    "agp_version, gradle_version", [
+        pytest.param("8.0.0", "7.0"),
+        pytest.param("8.0.1", "7.1"),
+        pytest.param("8.1.0", "7.2.1"),
+        pytest.param("7.1.1", "11.21.43"),
+    ]
+)
[email protected](raises=android_support.AndroidToolError)
+def test_android_gradle_plugin_requirements_gradle_fail(agp_version, gradle_version):
+
+    agp_fail = android_support.get_android_gradle_plugin_requirements(agp_version)
+    agp_fail.validate_gradle_version(gradle_version)
+
+
[email protected](
+    "test_path", [
+        pytest.param(None),
+        pytest.param("android_foo"),
+        pytest.param("android_sdk_empty"),
+        pytest.param("android_sdk")
+    ]
+)
[email protected](raises=android_support.AndroidToolError)
+def test_android_sdk_environment_failed_settings(tmpdir, test_path):
+
+    tmpdir.ensure(f'android_sdk_empty', dir=True)
+    tmpdir.ensure(f'android_sdk/cmdline-tools/bin/sdkmanager{android_support.SDKMANAGER_EXTENSION}')
+
+    # Test Data
+    test_java_version = "17"
+
+    # Mocks
+    mock_settings = Mock(spec=command_utils.O3DEConfig)
+    mock_settings.get_value.return_value = tmpdir.join(test_path).realpath() if test_path is not None else None
+
+    # Test the method
+    android_support.AndroidSDKManager.validate_android_sdk_environment(test_java_version, mock_settings)
+
+
+def test_android_sdk_environment_failed_validation_test_bad_java_version(tmpdir):
+
+    tmpdir.ensure(f'android_sdk/cmdline-tools/latest/bin/sdkmanager{android_support.SDKMANAGER_EXTENSION}')
+
+    # Test Data
+    test_java_version = "17"
+    test_path = tmpdir.join('android_sdk').realpath()
+    test_err_msg = """
+Exception in thread \"main\" java.lang.UnsupportedClassVersionError: com/android/sdklib/tool/sdkmanager/SdkManagerCli has been compiled by a more recent version of the Java Runtime (class file version 61.0),  this version of the Java Runtime only recognizes class file versions up to 52.0
+"""
+    # Mocks
+    with patch('subprocess.run') as mock_subprocess_run:
+        mock_settings = Mock(spec=command_utils.O3DEConfig)
+        mock_settings.get_value.return_value = test_path
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 1
+        mock_completed_process.stdout = ""
+        mock_completed_process.stderr = test_err_msg
+        mock_subprocess_run.return_value = mock_completed_process
+        try:
+            # Test the method
+            android_support.AndroidSDKManager.validate_android_sdk_environment(test_java_version, mock_settings)
+        except android_support.AndroidToolError as err:
+            # Validate the specific error
+            assert 'command line tool requires java version' in str(err), "Expected specific java error message"
+        else:
+            assert False, "Error expected"
+
+
+def test_android_sdk_environment_failed_validation_test_bad_android_sdk_cmd_tools_location(tmpdir):
+
+    tmpdir.ensure(f'android_sdk/cmdline-tools/latest/bin/sdkmanager{android_support.SDKMANAGER_EXTENSION}')
+
+    # Test Data
+    test_path = tmpdir.join('android_sdk').realpath()
+    test_err_msg = 'Could not determine SDK root'
+    test_java_version = "17"
+
+    # Mocks
+    with patch('subprocess.run') as mock_subprocess_run:
+
+        mock_settings = Mock(spec=command_utils.O3DEConfig)
+        mock_settings.get_value.return_value = test_path
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 1
+        mock_completed_process.stdout = ""
+        mock_completed_process.stderr = test_err_msg
+        mock_subprocess_run.return_value = mock_completed_process
+
+        try:
+            # Test the method
+            android_support.AndroidSDKManager.validate_android_sdk_environment(test_java_version, mock_settings)
+
+        except android_support.AndroidToolError as err:
+            # Validate the error
+            assert 'not located under a valid Android SDK root' in str(err), "Expected specific sdk command line error message"
+        else:
+            assert False, "Expected failure"
+
+
+def test_android_sdk_environment_failed_validation_test_general_error(tmpdir):
+
+    tmpdir.ensure(f'android_sdk/cmdline-tools/latest/bin/sdkmanager{android_support.SDKMANAGER_EXTENSION}')
+
+    # Test Data
+    test_path = tmpdir.join('android_sdk').realpath()
+    test_err_msg = 'Something went wrong.'
+    test_java_version = "17"
+
+    # Mocks
+    with patch('subprocess.run') as mock_subprocess_run:
+
+        mock_settings = Mock(spec=command_utils.O3DEConfig)
+        mock_settings.get_value.return_value = test_path
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 1
+        mock_completed_process.stdout = ""
+        mock_completed_process.stderr = test_err_msg
+        mock_subprocess_run.return_value = mock_completed_process
+
+        try:
+            # Test the method
+            android_support.AndroidSDKManager.validate_android_sdk_environment(test_java_version, mock_settings)
+        except android_support.AndroidToolError as err:
+            # Validate the error
+            assert 'An error occurred attempt to run the android command line tool' in str(err), "Expected specific sdk command line error message"
+        else:
+            assert False, "Expected failure"
+
+
+def test_android_sdk_manager_get_installed_packages():
+
+    # Test Data
+    installed_packages_cmd_result = """
+    Installed packages:
+  Path                               | Version      | Description                       | Location
+  -------                            | -------      | -------                           | -------
+  build-tools;30.0.2                 | 30.0.2       | Android SDK Build-Tools 30.0.2    | build-tools\\30.0.2
+  platforms;android-33               | 3            | Android SDK Platform 33           | platforms\\android-33
+
+Available Packages:
+  add-ons;addon-google_apis-google-15                                                      | 3             | Google APIs
+  add-ons;addon-google_apis-google-23                                                      | 1             | Google APIs
+  
+Available Updates:
+  platforms;android-33                                                                     | 4             | Google APIs
+    :return: 
+    :rtype: 
+    """
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 0
+        mock_completed_process.stdout = installed_packages_cmd_result
+        mock_subprocess_run.return_value = mock_completed_process
+
+        test = android_support.AndroidSDKManager(None, None)
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.INSTALLED)
+        result_installed = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.INSTALLED)
+
+        # Validation
+        assert len(result_installed) == 2
+        assert result_installed[0].path == 'build-tools;30.0.2'
+        assert result_installed[1].path == 'platforms;android-33'
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.AVAILABLE)
+        result_available = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.AVAILABLE)
+
+        # Validation
+        assert len(result_available) == 2
+        assert result_available[0].path == 'add-ons;addon-google_apis-google-15'
+        assert result_available[1].path == 'add-ons;addon-google_apis-google-23'
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.UPDATABLE)
+        result_updateable = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.UPDATABLE)
+
+        # Validation
+        assert len(result_updateable) == 1
+        assert result_updateable[0].path == 'platforms;android-33'
+
+
+def test_android_sdk_manager_install_package_already_installed():
+
+    # Test Data
+    installed_packages_cmd_result = """
+    
+Installed packages:
+  Path                               | Version      | Description                       | Location
+  -------                            | -------      | -------                           | -------
+  build-tools;30.0.2                 | 30.0.2       | Android SDK Build-Tools 30.0.2    | build-tools\\30.0.2
+  platforms;android-33               | 3            | Android SDK Platform 33           | platforms\\android-33
+
+Available Packages:
+  add-ons;addon-google_apis-google-15                                                      | 3             | Google APIs
+  add-ons;addon-google_apis-google-23                                                      | 1             | Google APIs
+
+Available Updates:
+  platforms;android-33                                                                     | 4             | Google APIs
+    :return: 
+    :rtype: 
+    """
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 0
+        mock_completed_process.stdout = installed_packages_cmd_result
+        mock_subprocess_run.return_value = mock_completed_process
+
+        test = android_support.AndroidSDKManager(None, None)
+
+        # Test the method (Missing)
+        result = test.install_package('platforms;android-33', "Android SDK Platform 33")
+        # Validation
+        assert result is not None
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.INSTALLED)
+        result_installed = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.INSTALLED)
+        # Validation
+        assert len(result_installed) == 2
+        assert result_installed[0].path == 'build-tools;30.0.2'
+        assert result_installed[1].path == 'platforms;android-33'
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.AVAILABLE)
+        result_available = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.AVAILABLE)
+        # Validation
+        assert len(result_available) == 2
+        assert result_available[0].path == 'add-ons;addon-google_apis-google-15'
+        assert result_available[1].path == 'add-ons;addon-google_apis-google-23'
+
+        # Test the method (android_support.AndroidSDKManager.PackageCategory.UPDATABLE)
+        result_updateable = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.UPDATABLE)
+        # Validation
+        assert len(result_updateable) == 1
+        assert result_updateable[0].path == 'platforms;android-33'
+
+
+def test_android_sdk_manager_install_package_new():
+
+    # Test Data
+    cmd_result_calls = ["""
+Installed packages:
+  Path                               | Version      | Description                       | Location
+  -------                            | -------      | -------                           | -------
+  build-tools;30.0.2                 | 30.0.2       | Android SDK Build-Tools 30.0.2    | build-tools\30.0.2
+  platforms;android-33               | 3            | Android SDK Platform 33           | platforms\android-33
+
+Available Packages:
+  add-ons;addon-google_apis-google-15                                                      | 3             | Google APIs
+  add-ons;addon-google_apis-google-23                                                      | 1             | Google APIs
+
+Available Updates:
+  platforms;android-33                                                                     | 4             | Google APIs
+    :return: 
+    :rtype: 
+    """,
+    "",
+    """
+        Installed packages:
+      Path                                  | Version      | Description                       | Location
+      -------                               | -------      | -------                           | -------
+      build-tools;30.0.2                    | 30.0.2       | Android SDK Build-Tools 30.0.2    | build-tools\30.0.2
+      platforms;android-33                  | 3            | Android SDK Platform 33           | platforms\android-33
+      add-ons;addon-google_apis-google-15   | 1            | Platform API 15                   | platforms\android-15
+
+    Available Packages:
+      add-ons;addon-google_apis-google-23                                                      | 1             | Google APIs
+
+    Available Updates:
+      platforms;android-33                                                                     | 4             | Google APIs
+        :return: 
+        :rtype: 
+        """]
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_index = 0  # Track a counter to simulate multiple calls to the same function, producing multiple results
+
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+            nonlocal mock_index
+            assert mock_index < 3
+            message = cmd_result_calls[mock_index]
+            mock_index += 1
+
+            mock_completed_process = Mock(spec=subprocess.CompletedProcess)
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = message
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        test = android_support.AndroidSDKManager(None, None)
+        # Test the install_package
+        result = test.install_package('add-ons;addon-google_apis-google-15', "API 25")
+        # Validation (that it was already installed)
+        assert result is not None
+
+        # Test the package list
+        result_installed = test.get_package_list('*', android_support.AndroidSDKManager.PackageCategory.INSTALLED)
+
+        # Validation
+        assert result_installed[0].path == 'build-tools;30.0.2'
+        assert result_installed[1].path == 'platforms;android-33'
+        assert result_installed[2].path == 'add-ons;addon-google_apis-google-15'
+
+
[email protected](raises=android_support.AndroidToolError)
+def test_android_sdk_manager_install_bad_package_name():
+    # Test Data
+    installed_packages_cmd_result = """
+Installed packages:
+  Path                               | Version      | Description                       | Location
+  -------                            | -------      | -------                           | -------
+  build-tools;30.0.2                 | 30.0.2       | Android SDK Build-Tools 30.0.2    | build-tools\30.0.2
+  platforms;android-33               | 3            | Android SDK Platform 33           | platforms\android-33
+
+Available Packages:
+  add-ons;addon-google_apis-google-15                                                      | 3             | Google APIs
+  add-ons;addon-google_apis-google-23                                                      | 1             | Google APIs
+
+Available Updates:
+  platforms;android-33                                                                     | 4             | Google APIs
+    :return: 
+    :rtype: 
+    """
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 0
+        mock_completed_process.stdout = installed_packages_cmd_result
+        mock_subprocess_run.return_value = mock_completed_process
+
+        # Test installing a bad package
+        test = android_support.AndroidSDKManager(None, None)
+        test.install_package('foo', 'bar')
+
+
+def test_android_sdk_manager_check_licenses_not_accepted():
+
+    # Test Data
+    cmd_call_results = ["""
+Installed packages:
+
+Available Packages:
+
+Available Updates:
+        """,
+        "7 of 7 SDK package licenses not accepted"]
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_index = 0
+
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+            nonlocal mock_index
+            assert mock_index < 2
+            message = cmd_call_results[mock_index]
+            mock_index += 1
+
+            mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = message
+            mock_completed_process.stderr = ""
+
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        test = android_support.AndroidSDKManager(None, None)
+        try:
+            test.check_licenses()
+        except android_support.AndroidToolError as e:
+            assert 'licenses not accepted' in str(e)
+        else:
+            assert False, "Error expected"
+
+
+def test_android_sdk_manager_check_licenses_error():
+
+    # Test Data
+    cmd_call_results = ["""
+Installed packages:
+
+Available Packages:
+
+Available Updates:
+                        """,
+                        "Invalid java"
+                        ]
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_index = 0
+
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+            nonlocal mock_index
+            assert mock_index < 2
+            message = cmd_call_results[mock_index]
+            mock_index += 1
+
+            mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = message
+            mock_completed_process.stderr = ""
+
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        test = android_support.AndroidSDKManager(None, None)
+        try:
+            # Test the method
+            test.check_licenses()
+        except android_support.AndroidToolError as e:
+            # Validate the error
+            assert 'Unable to determine the Android SDK Package license state' in str(e)
+        else:
+            assert False, "Error expected"
+
+
+def test_android_sdk_manager_check_licenses_accepted():
+    cmd_call_results = ["""
+Installed packages:
+
+Available Packages:
+
+Available Updates:
+                        """,
+                        "All SDK package licenses accepted."
+                        ]
+
+    iter = 0
+
+    def _return_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+        nonlocal iter
+        assert iter < 2
+        message = cmd_call_results[iter]
+        iter += 1
+
+        mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+        mock_completed_process.returncode = 0
+        mock_completed_process.stdout = message
+        mock_completed_process.stderr = ""
+
+        return mock_completed_process
+
+    # Mocks
+    with patch('o3de.android_support.AndroidSDKManager.validate_android_sdk_environment') as mock_validate_android_sdk_environment, \
+         patch('subprocess.run') as mock_subprocess_run:
+
+        mock_validate_android_sdk_environment.return_value = (None, None)
+
+        mock_subprocess_run.side_effect = _return_
+
+        # Test the method
+        test = android_support.AndroidSDKManager(None, None)
+        test.check_licenses()
+
+
[email protected](
+    "test_version_query, test_version_result, test_version_regex, test_expected_version", [
+        pytest.param("-version", "Version 1.4", r'(Version)\s*([\d\.]*)', '1.4'),
+        pytest.param("--version", "Unknown", r'(Version)\s*([\d\.]*)', None),
+        pytest.param("/version", "Version 1.6", r'(Version)\s*([\d\.]*)', '1.6')
+    ]
+)
+def test_validate_build_tool_from_config_key(test_version_query, test_version_result, test_version_regex, test_expected_version):
+
+    # Test Data
+    tool_name = 'test'
+    tool_cmd = 'validate.exe'
+    tool_config_subpath = 'bin'
+    tool_config_key = 'validate.home'
+    tool_config_value = f'{os.sep}home{os.sep}path{os.sep}validator'
+    tool_config_full_path = os.path.join(tool_config_value, tool_config_subpath, tool_cmd)
+
+    tool_version_arg = test_version_query
+    tool_version_regex = test_version_regex
+
+    # Mocks
+    with patch('o3de.command_utils.O3DEConfig') as mock_android_config, \
+         patch('o3de.android_support.subprocess.run') as mock_subprocess_run:
+
+        mock_android_config.get_value.return_value = tool_config_value
+
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+
+            full_cmd_line_args = popenargs[0]
+            full_cmd_line = ' '.join(full_cmd_line_args)
+
+            expected_cmd_line = f'{tool_config_full_path} {tool_version_arg}'
+            assert full_cmd_line == expected_cmd_line
+
+            mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = test_version_result
+            mock_completed_process.stderr = ""
+
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        try:
+            # Test the method
+            result_location, result_version = android_support.validate_build_tool(tool_name=tool_name,
+                                                                                  tool_command=tool_cmd,
+                                                                                  tool_config_key=tool_config_key,
+                                                                                  tool_environment_var='',
+                                                                                  tool_config_sub_path=tool_config_subpath,
+                                                                                  tool_version_arg=tool_version_arg,
+                                                                                  version_regex=tool_version_regex,
+                                                                                  android_config=mock_android_config)
+            # Validate success results
+            assert str(result_location) == tool_config_full_path
+            assert result_version == test_expected_version
+
+        except android_support.AndroidToolError:
+            # Validate error scenarios
+            assert test_expected_version is None
+
+
[email protected](
+    "test_version_query, test_version_result, test_version_regex, test_expected_version", [
+        pytest.param("-version", "Version 1.4", r'(Version)\s*([\d\.]*)', '1.4'),
+        pytest.param("--version", "Unknown", r'(Version)\s*([\d\.]*)', None),
+        pytest.param("/version", "Version 1.6", r'(Version)\s*([\d\.]*)', '1.6')
+    ]
+)
+def test_validate_build_tool_from_env(test_version_query, test_version_result, test_version_regex, test_expected_version):
+
+    # Test Data
+    tool_name = 'test'
+    tool_config_subpath = 'bin'
+
+    tool_cmd = 'validate.exe'
+    tool_config_key = 'validate.home'
+    tool_config_value = f'{os.sep}home{os.sep}path{os.sep}invalid_validator'
+    tool_config_full_path = os.path.join(tool_config_value, tool_config_subpath, tool_cmd)
+
+    tool_env_key = 'VALIDATE_HOME'
+    tool_env_value = f'{os.sep}home{os.sep}path{os.sep}validator'
+    tool_env_full_path = os.path.join(tool_env_value, tool_config_subpath, tool_cmd)
+
+    tool_version_arg = test_version_query
+    tool_version_regex = test_version_regex
+
+    # Mocks
+    with patch('o3de.command_utils.O3DEConfig') as mock_android_config, \
+         patch('os.getenv') as mock_os_getenv, \
+         patch('o3de.android_support.subprocess.run') as mock_subprocess_run:
+
+        mock_android_config.get_value.return_value = tool_config_value
+
+        mock_os_getenv.return_value = tool_env_value
+
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+            full_cmd_line_args = popenargs[0]
+            if full_cmd_line_args[0] == tool_config_full_path:
+                return subprocess.CompletedProcess(args=full_cmd_line_args, returncode=1, stdout="")
+
+            full_cmd_line = ' '.join(full_cmd_line_args)
+
+            # nonlocal tool_config_value, tool_config_subpath, tool_cmd
+            expected_cmd_line = f'{tool_env_full_path} {tool_version_arg}'
+            assert full_cmd_line == expected_cmd_line
+
+            mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = test_version_result
+            mock_completed_process.stderr = ""
+
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        try:
+            result_location, result_version = android_support.validate_build_tool(tool_name=tool_name,
+                                                                                  tool_command=tool_cmd,
+                                                                                  tool_config_key=tool_config_key,
+                                                                                  tool_environment_var=tool_env_key,
+                                                                                  tool_config_sub_path=tool_config_subpath,
+                                                                                  tool_version_arg=tool_version_arg,
+                                                                                  version_regex=tool_version_regex,
+                                                                                  android_config=mock_android_config)
+            assert str(result_location) == tool_env_full_path
+            assert result_version == test_expected_version
+        except android_support.AndroidToolError:
+            assert test_expected_version is None
+        finally:
+            mock_os_getenv.assert_called_once_with(tool_env_key)
+
+
[email protected](
+    "test_version_query, test_version_result, test_version_regex, test_expected_version", [
+        pytest.param("-version", "Version 1.4", r'(Version)\s*([\d\.]*)', '1.4'),
+        pytest.param("--version", "Unknown", r'(Version)\s*([\d\.]*)', None),
+        pytest.param("/version", "Version 1.6", r'(Version)\s*([\d\.]*)', '1.6')
+    ]
+)
+def test_validate_build_tool_from_path(test_version_query, test_version_result, test_version_regex, test_expected_version):
+
+    # Test Data
+    tool_name = 'test'
+    tool_config_subpath = 'bin'
+    tool_cmd = 'validate.exe'
+    tool_config_key = 'validate.home'
+    tool_config_value = f'{os.sep}home{os.sep}path{os.sep}invalid_validator'
+    tool_config_full_path = os.path.join(tool_config_value, tool_config_subpath, tool_cmd)
+    tool_env_key = 'VALIDATE_HOME'
+    tool_env_value = f'{os.sep}home{os.sep}path{os.sep}validator'
+    tool_env_full_path = os.path.join(tool_env_value, tool_config_subpath, tool_cmd)
+    path_value = f'{os.sep}system{os.sep}bin'
+    full_path = os.path.join(path_value, tool_cmd)
+    tool_version_arg = test_version_query
+    tool_version_regex = test_version_regex
+
+    # Mocks
+    with patch('o3de.command_utils.O3DEConfig') as mock_android_config, \
+         patch('os.getenv') as mock_os_getenv, \
+         patch('shutil.which') as mock_shutil_which, \
+         patch('o3de.android_support.subprocess.run') as mock_subprocess_run:
+
+        mock_android_config.get_value.return_value = tool_config_value
+
+        mock_os_getenv.return_value = tool_env_value
+        def _mock_subprocess_run_(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs):
+
+            full_cmd_line_args = popenargs[0]
+
+            if full_cmd_line_args[0] in (tool_config_full_path, tool_env_full_path):
+
+                mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+                mock_completed_process.args = full_cmd_line_args
+                mock_completed_process.returncode = 1
+                mock_completed_process.stdout = ""
+                mock_completed_process.stderr = ""
+                return mock_completed_process
+
+            full_cmd_line = ' '.join(full_cmd_line_args)
+
+            # nonlocal tool_config_value, tool_config_subpath, tool_cmd
+            expected_cmd_line = f'{tool_cmd} {tool_version_arg}'
+            assert full_cmd_line == expected_cmd_line
+
+            mock_completed_process = PropertyMock(spec=subprocess.CompletedProcess)
+            mock_completed_process.args = full_cmd_line_args
+            mock_completed_process.returncode = 0
+            mock_completed_process.stdout = test_version_result
+            mock_completed_process.stderr = ""
+            return mock_completed_process
+
+        mock_subprocess_run.side_effect = _mock_subprocess_run_
+
+        mock_shutil_which.return_value = os.path.join(path_value, tool_cmd)
+
+        try:
+            # Test the method call
+            result_location, result_version = android_support.validate_build_tool(tool_name=tool_name,
+                                                                                  tool_command=tool_cmd,
+                                                                                  tool_config_key=tool_config_key,
+                                                                                  tool_environment_var=tool_env_key,
+                                                                                  tool_config_sub_path=tool_config_subpath,
+                                                                                  tool_version_arg=tool_version_arg,
+                                                                                  version_regex=tool_version_regex,
+                                                                                  android_config=mock_android_config)
+            # Validate the successful calls
+            assert str(result_location) == full_path
+            assert result_version == test_expected_version
+        except android_support.AndroidToolError:
+            # Validate the error condition
+            assert test_expected_version is None
+        finally:
+            mock_os_getenv.assert_called_once_with(tool_env_key)
+            mock_shutil_which.assert_called_once_with(tool_cmd)

+ 623 - 0
scripts/o3de/tests/test_command_utils.py

@@ -0,0 +1,623 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+
+import configparser
+import pytest
+import pathlib
+
+from unittest.mock import patch
+
+from o3de import command_utils
+
+
+def test_apply_default_values_create_new_settings_file(tmpdir):
+
+    # Test Data
+    tmpdir.ensure('.o3de', dir=True)
+
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+    settings = [command_utils.SettingsDescription("foo", "foo desc", "1"),
+                command_utils.SettingsDescription("bar", "bar desc", "2")]
+
+    # Mocks
+    with patch('o3de.manifest.get_o3de_folder') as mock_get_o3de_folder:
+
+        mock_get_o3de_folder.return_value = pathlib.Path(tmpdir.join('.o3de').realpath())
+
+        # Test the method
+        result_settings_file = command_utils.O3DEConfig.apply_default_global_settings(settings_filename=settings_filename,
+                                                                                      settings_section_name=settings_section_name,
+                                                                                      settings_descriptions=settings)
+        # Validation
+        mock_get_o3de_folder.assert_called()
+        expected_settings_file_path = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+        assert result_settings_file == expected_settings_file_path
+        assert pathlib.Path(expected_settings_file_path).is_file()
+        config_reader = configparser.ConfigParser()
+        config_reader.read(result_settings_file.absolute())
+        settings = config_reader[settings_section_name]
+        assert settings['foo'] == '1'
+        assert settings['bar'] == '2'
+
+
+def test_apply_default_values_open_existing_settings_file_new_entries(tmpdir):
+
+    tmpdir.ensure('.o3de', dir=True)
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+    settings = [command_utils.SettingsDescription("foo", "foo desc","1"),
+                command_utils.SettingsDescription("bar", "bar desc","2")]
+
+    existing_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    existing_settings_file.write(f"""
+[{settings_section_name}]
+foo = 3
+crew = 7
+""")
+
+    # Mocks
+    with patch('o3de.manifest.get_o3de_folder') as mock_get_o3de_folder:
+
+        mock_get_o3de_folder.return_value = pathlib.Path(tmpdir.join('.o3de').realpath())
+
+        # Test the methods
+        result_settings_file = command_utils.O3DEConfig.apply_default_global_settings(settings_filename=settings_filename,
+                                                                                      settings_section_name=settings_section_name,
+                                                                                      settings_descriptions=settings)
+        # Validation
+        expected_settings_file_path = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+        mock_get_o3de_folder.assert_called()
+
+        assert expected_settings_file_path == expected_settings_file_path
+        assert pathlib.Path(expected_settings_file_path).is_file()
+
+        config_reader = configparser.ConfigParser()
+        config_reader.read(result_settings_file.absolute())
+        settings = config_reader[settings_section_name]
+
+        assert settings['foo'] == '3'   # The default setting of 1 should not be applied since 'foo' already exists
+        assert settings['bar'] == '2'   # bar will be added
+        assert settings['crew'] == '7'  # crew already existed
+
+
[email protected](
+    "test_path, expect_error", [
+        pytest.param("my_project", False, id="From same level: success"),
+        pytest.param("my_project/Gem", False, id="From one level up: Success"),
+        pytest.param("my_project/Gem/Source", False, id="From two levels up: Success"),
+        pytest.param("not_a_project/foo", True, id="No project.json found: Error"),
+    ]
+)
+def test_resolve_project_path_from_cwd(tmpdir, test_path, expect_error):
+
+    # Test Data
+    tmpdir.ensure('not_a_project/foo', dir=True)
+    tmpdir.ensure('my_project/Cache/windows', dir=True)
+    tmpdir.ensure('my_project/Gem/Source/foo.cpp')
+
+    dummy_project_file = tmpdir.join('my_project/project.json')
+    dummy_project_file.write("""
+{
+    "project_name": "MyProject",
+    "project_id": "{11111111-1111-AAAA-AA11-111111111111}"
+}
+""")
+    # Mocks
+    with patch('o3de.manifest.get_all_projects') as mock_get_all_projects:
+
+        mock_get_all_projects.return_value = [tmpdir.join('my_project').realpath()]
+        try:
+            # Test the method
+            command_utils.resolve_project_name_and_path(tmpdir.join(test_path).realpath())
+
+        except command_utils.O3DEConfigError:
+            # Validate error conditions
+            assert expect_error
+        else:
+            # Validate success conditions
+            assert not expect_error
+
+
+
+
+def test_read_config_global_only(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de', dir=True)
+
+    settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    global_settings_file = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = global_settings_file
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=None,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        # Validation
+        mock_apply_default_global_settings.assert_called()
+        result_sdk_api_level = config.get_value('sdk_api_level')
+        result_extra1 = config.get_value('extra1')
+
+        assert result_sdk_api_level == 'ZZZZ'
+        assert result_extra1 == 'XXXX'
+
+
+def test_read_config_value_with_override(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+    project_name = 'foo'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+
+    tmpdir.ensure('project/project.json')
+    project_settings_file = tmpdir.join(f'project/{settings_filename}')
+    project_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = FFFF
+""")
+    test_project_path = pathlib.Path(tmpdir.join('project').realpath())
+    test_project_name = "project"
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+    global_settings_file = pathlib.Path(global_settings_file.realpath())
+    project_settings_file = pathlib.Path(project_settings_file.realpath())
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings, \
+         patch("o3de.command_utils.resolve_project_name_and_path") as mock_resolve_project_name_and_path:
+
+        mock_apply_default_global_settings.return_value = global_settings_file
+
+        mock_resolve_project_name_and_path.return_value = (test_project_name, test_project_path)
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=test_project_path,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        # Validation
+        result_sdk_api_level = config.get_value('sdk_api_level')
+        assert result_sdk_api_level == 'FFFF'
+
+        result_extra1 = config.get_value('extra1')
+        assert result_extra1 == 'XXXX'
+
+        mock_apply_default_global_settings.assert_called_once()
+        mock_resolve_project_name_and_path.assert_called_once()
+
+
+def test_read_config_value_and_source_with_override(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+
+    tmpdir.ensure('project/project.json')
+    project_settings_file = tmpdir.join(f'project/{settings_filename}')
+    project_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = FFFF
+""")
+    test_project_path = pathlib.Path(tmpdir.join('project').realpath())
+    test_project_name = "project"
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings, \
+         patch("o3de.command_utils.resolve_project_name_and_path") as mock_resolve_project_name_and_path:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(global_settings_file.realpath())
+
+        mock_resolve_project_name_and_path.return_value = (test_project_name, test_project_path)
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=test_project_path,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        # Validation
+        result_sdk_api_level, result_sdk_api_level_project = config.get_value_and_source('sdk_api_level')
+        assert result_sdk_api_level == 'FFFF'
+        assert result_sdk_api_level_project == test_project_name
+
+        result_extra1, result_extra1_project = config.get_value_and_source('extra1')
+        assert result_extra1 == 'XXXX'
+        assert result_extra1_project == ''
+
+        mock_apply_default_global_settings.assert_called_once()
+        mock_resolve_project_name_and_path.assert_called_once()
+
+
+def test_set_config_global(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=None,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        config.set_config_value('extra1', 'ZZZZ')
+
+        # Validation
+        result_extra1 = config.get_value('extra1')
+        assert result_extra1 == 'ZZZZ'
+        mock_apply_default_global_settings.assert_called_once()
+
+
+def test_set_config_project(tmpdir):
+
+    tmpdir.ensure('.o3de', dir=True)
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    project_name = "foo"
+
+    test_global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    test_global_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+
+    tmpdir.ensure('project/project.json')
+    test_project_settings_file = tmpdir.join(f'project/{settings_filename}')
+    test_project_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = FFFF
+""")
+    test_project_path = pathlib.Path(tmpdir.join('project').realpath())
+    test_project_name = "project"
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings, \
+         patch("o3de.command_utils.resolve_project_name_and_path") as mock_resolve_project_name_and_path:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(test_global_settings_file.realpath())
+        mock_resolve_project_name_and_path.return_value = (test_project_name, test_project_path)
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=test_project_path,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+        config.set_config_value('extra1','ZZZZ')
+
+        # Validation
+        project_config = configparser.ConfigParser()
+        project_config.read(test_project_settings_file.realpath())
+        project_config_section = project_config[settings_section_name]
+        assert project_config_section.get('extra1') == 'ZZZZ'
+
+        global_config = configparser.ConfigParser()
+        global_config.read(test_global_settings_file.realpath())
+        global_config_section = global_config[settings_section_name]
+        assert global_config_section.get('extra1') == 'XXXX'
+
+        mock_apply_default_global_settings.assert_called_once()
+        mock_resolve_project_name_and_path.assert_called_once()
+
+
[email protected](
+    "key_and_value, expected_key, expected_value", [
+        pytest.param("argument=foo", "argument", "foo", id="Simple"),
+        pytest.param("argument =foo", "argument", "foo", id="Simple with space 1"),
+        pytest.param("argument= foo", "argument", "foo", id="Simple with space 2"),
+        pytest.param("argument = foo", "argument", "foo", id="Simple with space 3"),
+        pytest.param("argument='foo* and three.'", "argument", "foo* and three.", id="Double Quotes"),
+        pytest.param("argument.one = foo", "argument.one", "foo", id="Simple alpha with dot"),
+        pytest.param("argument.1.foo = foo", "argument.1.foo", "foo", id="Simple alpha with number and dot")
+    ]
+)
+def test_set_config_key_value(tmpdir, key_and_value, expected_key, expected_value):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription(expected_key, "argument desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+    global_settings_file = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = global_settings_file
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=None,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+        config.set_config_value_from_expression(key_and_value)
+
+        # Validation
+        global_config = configparser.ConfigParser()
+        global_config.read(global_settings_file)
+        global_config_section = global_config[settings_section_name]
+        assert global_config_section.get(expected_key) == expected_value
+        mock_apply_default_global_settings.assert_called_once()
+
+
[email protected](raises=command_utils.O3DEConfigError)
+def test_set_config_key_invalid_config_value(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+extra1 = XXXX
+""")
+    bad_config_value = "foo? and two%"
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=None,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        config.set_config_value_from_expression(f"argument=\"{bad_config_value}\"")
+
+
+def test_get_all_config_values(tmpdir):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = ZZZZ
+ndk = 22.*
+extra1 = XXXX
+""")
+
+    tmpdir.ensure('project/project.json')
+    project_settings_file = tmpdir.join(f'project/{settings_filename}')
+    project_settings_file.write(f"""
+[{settings_section_name}]
+sdk_api_level = FFFF
+""")
+    test_project_path = pathlib.Path(tmpdir.join('project').realpath())
+    test_project_name = "project"
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("ndk", "ndk desc"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings, \
+         patch("o3de.command_utils.resolve_project_name_and_path") as mock_resolve_project_name_and_path:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(global_settings_file.realpath())
+
+        mock_resolve_project_name_and_path.return_value = (test_project_name, test_project_path)
+
+        # Test the method
+        config = command_utils.O3DEConfig(project_path=test_project_path,
+                                          settings_filename=settings_filename,
+                                          settings_section_name=settings_section_name,
+                                          settings_description_list=settings)
+
+        results = config.get_all_values()
+
+        mock_apply_default_global_settings.assert_called()
+        mock_resolve_project_name_and_path.assert_called()
+
+        # Validation
+        project_to_value_list = {}
+        for key, value, source in results:
+            project_to_value_list.setdefault(source or "global", []).append( (key, value) )
+        assert len(project_to_value_list['global']) == 2  # Note: Not 3 since 'extra1' is overridden by the project
+        assert len(project_to_value_list[test_project_name]) == 1
+
+
[email protected](
+    "bool_str, expected_value, is_error", [
+        pytest.param('t', True, False),
+        pytest.param('true', True, False),
+        pytest.param('T', True, False),
+        pytest.param('True', True, False),
+        pytest.param('TRUE', True, False),
+        pytest.param('1', True, False),
+        pytest.param('on', True, False),
+        pytest.param('On', True, False),
+        pytest.param('ON', True, False),
+        pytest.param('yes', True, False),
+        pytest.param('Bad', True, True),
+        pytest.param('f', False, False),
+        pytest.param('F', False, False),
+        pytest.param('false', False, False),
+        pytest.param('FALSE', False, False),
+        pytest.param('False', False, False),
+        pytest.param('0', False, False),
+        pytest.param('off', False, False),
+        pytest.param('OFF', False, False),
+        pytest.param('Off', False, False),
+        pytest.param('no', False, False)
+    ]
+)
+def test_set_config_boolean_value(tmpdir,bool_str, expected_value, is_error):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+extra1 = XXXX
+""")
+
+    settings = [command_utils.SettingsDescription("sdk_api_level", "sdk_api_level desc"),
+                command_utils.SettingsDescription("strip.debug", "strip.debug desc", is_boolean=True),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+        try:
+            # Test the method
+            config = command_utils.O3DEConfig(project_name=None,
+                                              settings_filename=settings_filename,
+                                              settings_section_name=settings_section_name,
+                                              settings_description_list=settings)
+
+            config.set_config_value('strip.debug', bool_str)
+
+            # Validate success values
+            mock_apply_default_global_settings.assert_called()
+            str_result = config.get_value('strip.debug')
+            assert str_result == bool_str
+            bool_result = config.get_boolean_value('strip.debug')
+            assert bool_result == expected_value
+
+        except command_utils.O3DEConfigError as e:
+            # Validate errors
+            assert is_error, f"Unexpected Error: {e}"
+        else:
+            assert not is_error, "Error expected"
+
+
[email protected](
+    "input, restricted_regex, is_error", [
+        pytest.param('LOOSE', '(LOOSE|PAK)', False),
+        pytest.param('PAK', '(LOOSE|PAK)', False),
+        pytest.param('VFS', '(LOOSE|PAK)', True),
+    ]
+)
+def test_set_config_boolean_value(tmpdir, input, restricted_regex, is_error):
+
+    # Test Data
+    settings_filename = '.unit_test_settings'
+    settings_section_name = 'testing'
+
+    tmpdir.ensure('.o3de/o3de_manifest.json')
+    global_settings_file = tmpdir.join(f'.o3de/{settings_filename}')
+    global_settings_file.write(f"""
+[{settings_section_name}]
+extra1 = XXXX
+""")
+    key = 'test'
+    settings = [command_utils.SettingsDescription(key, f"{key} desc", restricted_regex=restricted_regex, restricted_regex_description=f"regex: {restricted_regex}"),
+                command_utils.SettingsDescription("extra1", "extra1 desc")]
+
+    # Mocks
+    with patch("o3de.command_utils.O3DEConfig.apply_default_global_settings") as mock_apply_default_global_settings:
+
+        mock_apply_default_global_settings.return_value = pathlib.Path(tmpdir.join(f'.o3de/{settings_filename}').realpath())
+
+        try:
+            config = command_utils.O3DEConfig(project_path=None,
+                                              settings_filename=settings_filename,
+                                              settings_section_name=settings_section_name,
+                                              settings_description_list=settings)
+
+            config.set_config_value(key, input)
+
+            # Validate success conditions
+            mock_apply_default_global_settings.assert_called()
+            str_result = config.get_value(key)
+            assert str_result == input
+        except command_utils.O3DEConfigError as e:
+            # Validation error conditions
+            assert is_error, f"Unexpected Error: {e}"
+        else:
+            assert not is_error, "Error expected"