diff --git a/Engine/CMakeLists.txt b/Engine/CMakeLists.txt index 1387dee2..1936129d 100644 --- a/Engine/CMakeLists.txt +++ b/Engine/CMakeLists.txt @@ -2,6 +2,8 @@ cmake_minimum_required(VERSION 3.16) project(Tempest) +include(cmake/TempestAndroid.cmake) + set(CMAKE_CXX_STANDARD 20) option(TEMPEST_BUILD_SHARED "Build shared Tempest." ON) diff --git a/Engine/cmake/TempestAndroid.cmake b/Engine/cmake/TempestAndroid.cmake new file mode 100644 index 00000000..df41abba --- /dev/null +++ b/Engine/cmake/TempestAndroid.cmake @@ -0,0 +1,200 @@ +include_guard(GLOBAL) + +function(_tempest_android_quote output value) + string(REPLACE "\\" "\\\\" value "${value}") + string(REPLACE "'" "\\'" value "${value}") + string(REPLACE "\n" "\\n" value "${value}") + string(REPLACE "\r" "\\r" value "${value}") + set(${output} "'${value}'" PARENT_SCOPE) +endfunction() + +function(_tempest_android_list output) + set(result "") + foreach(value IN LISTS ARGN) + _tempest_android_quote(quoted "${value}") + string(APPEND result "${quoted}, ") + endforeach() + set(${output} "[${result}]" PARENT_SCOPE) +endfunction() + +# Call after defining the application's native shared-library target. +# Including this module alone never searches for Android or Java tools. +function(add_android_apk name) + if(NOT name MATCHES "^[A-Za-z][A-Za-z0-9_-]*$") + message(FATAL_ERROR "Use letters, digits, underscores and hyphens for the packaging target name") + endif() + if(CMAKE_VERSION VERSION_LESS 3.22) + message(FATAL_ERROR "Android project generation requires CMake 3.22 or newer") + endif() + if(NOT ANDROID) + message(FATAL_ERROR "add_android_apk requires an Android NDK build") + endif() + cmake_parse_arguments(APP "SHRINK_RELEASE" + "PACKAGE_NAME;CODE;MANIFEST;VERSION_CODE;VERSION_NAME;PROPERTY_PREFIX;SIGNING_ENV_PREFIX;NATIVE_SYMBOLS" + "JAVA_DIRS;RESOURCE_DIRS;ASSET_DIRS;DEPENDENCIES;CMAKE_ARGUMENTS;CPP_FLAGS;PROGUARD_FILES;NO_COMPRESS" ${ARGN}) + if(APP_UNPARSED_ARGUMENTS OR APP_KEYWORDS_MISSING_VALUES) + message(FATAL_ERROR "Invalid arguments to add_android_apk: ${APP_UNPARSED_ARGUMENTS};${APP_KEYWORDS_MISSING_VALUES}") + endif() + foreach(required PACKAGE_NAME CODE MANIFEST) + if(NOT APP_${required}) + message(FATAL_ERROR "add_android_apk requires ${required}") + endif() + endforeach() + set(APP_APPLICATION_ID "${APP_PACKAGE_NAME}") + set(APP_NATIVE_TARGET "${APP_CODE}") + if(NOT APP_APPLICATION_ID MATCHES "^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$") + message(FATAL_ERROR "Invalid Android application ID: ${APP_APPLICATION_ID}") + endif() + if(NOT TARGET "${APP_CODE}") + message(FATAL_ERROR "CODE must name an existing shared-library target") + endif() + get_target_property(kind "${APP_CODE}" TYPE) + get_target_property(imported "${APP_CODE}" IMPORTED) + if(NOT kind STREQUAL "SHARED_LIBRARY" OR imported) + message(FATAL_ERROR "CODE must name a shared library built by this project") + endif() + target_link_options(${APP_CODE} PRIVATE + "-Wl,-u,ANativeActivity_onCreate" + "-Wl,-z,max-page-size=16384") + + # Gradle builds the same CMake project, but must not regenerate its own build.gradle. + if(TEMPEST_ANDROID_GRADLE_BUILD) + return() + endif() + if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) + message(FATAL_ERROR "Android packaging requires an out-of-source build") + endif() + get_filename_component(manifest "${APP_MANIFEST}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + if(NOT EXISTS "${manifest}" OR IS_DIRECTORY "${manifest}") + message(FATAL_ERROR "MANIFEST must name an application-owned AndroidManifest.xml") + endif() + + # Use the API, ABI and NDK already selected by the native toolchain. + if(DEFINED ANDROID_PLATFORM_LEVEL) + # The NDK's default toolchain sets CMAKE_SYSTEM_VERSION to 1, not the API level. + set(MIN_SDK "${ANDROID_PLATFORM_LEVEL}") + else() + set(MIN_SDK "${CMAKE_SYSTEM_VERSION}") + endif() + if(NOT DEFINED CMAKE_ANDROID_STL_TYPE) + set(CMAKE_ANDROID_STL_TYPE "c++_static" CACHE STRING "Android C++ runtime") + endif() + if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Native build type" FORCE) + endif() + if(NOT CMAKE_BUILD_TYPE MATCHES "^(Debug|Release|RelWithDebInfo|MinSizeRel)$") + message(FATAL_ERROR "Unsupported Android native build type: ${CMAKE_BUILD_TYPE}") + endif() + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(variant Debug) + set(release_type Release) + else() + set(variant Release) + set(release_type "${CMAKE_BUILD_TYPE}") + endif() + if(NOT CMAKE_ANDROID_ARCH_ABI MATCHES "^(arm64-v8a|armeabi-v7a|x86|x86_64)$") + message(FATAL_ERROR "CMAKE_ANDROID_ARCH_ABI must name one Android ABI") + endif() + if(NOT CMAKE_ANDROID_STL_TYPE MATCHES "^(c\\+\\+_static|c\\+\\+_shared|none|system)$") + message(FATAL_ERROR "Unsupported Android STL: ${CMAKE_ANDROID_STL_TYPE}") + endif() + + # Gradle's compile/target SDKs are separate from the native minimum API. + set(TEMPEST_ANDROID_COMPILE_SDK 35 CACHE STRING "Android compile SDK") + set(TEMPEST_ANDROID_TARGET_SDK "${TEMPEST_ANDROID_COMPILE_SDK}" CACHE STRING "Android target SDK") + set(TEMPEST_ANDROID_BUILD_TOOLS "35.0.0" CACHE STRING "Android build tools version") + set(TEMPEST_ANDROID_CMAKE "3.22.1" CACHE STRING "Android native CMake version") + set(TEMPEST_ANDROID_AGP "8.7.3" CACHE STRING "Android Gradle plugin version") + foreach(api MIN_SDK TEMPEST_ANDROID_COMPILE_SDK TEMPEST_ANDROID_TARGET_SDK) + if(NOT "${${api}}" MATCHES "^[1-9][0-9]*$") + message(FATAL_ERROR "${api} must be a numeric Android API level") + endif() + endforeach() + if(MIN_SDK GREATER TEMPEST_ANDROID_TARGET_SDK OR TEMPEST_ANDROID_TARGET_SDK GREATER TEMPEST_ANDROID_COMPILE_SDK) + message(FATAL_ERROR "Android APIs must satisfy minimum <= target <= compile") + endif() + if(NOT EXISTS "${CMAKE_ANDROID_NDK}/build/cmake/android.toolchain.cmake") + message(FATAL_ERROR "CMAKE_ANDROID_NDK must point to an NDK installation") + endif() + _tempest_android_quote(ndk "${CMAKE_ANDROID_NDK}") + set(NDK_CONFIGURATION "ndkPath ${ndk}") + if(NOT APP_NATIVE_SYMBOLS) + set(APP_NATIVE_SYMBOLS NONE) + endif() + if(NOT APP_NATIVE_SYMBOLS MATCHES "^(NONE|SYMBOL_TABLE|FULL)$") + message(FATAL_ERROR "NATIVE_SYMBOLS must be NONE, SYMBOL_TABLE or FULL") + endif() + if(NOT APP_VERSION_CODE) + set(APP_VERSION_CODE 1) + endif() + if(NOT APP_VERSION_CODE MATCHES "^[1-9][0-9]*$") + message(FATAL_ERROR "VERSION_CODE must be a positive integer") + endif() + if(NOT APP_VERSION_NAME) + set(APP_VERSION_NAME "1.0") + endif() + if(NOT APP_PROPERTY_PREFIX) + set(APP_PROPERTY_PREFIX "tempest") + endif() + if(NOT APP_SIGNING_ENV_PREFIX) + set(APP_SIGNING_ENV_PREFIX "TEMPEST") + endif() + + set(templates "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/android") + set(output "${CMAKE_CURRENT_BINARY_DIR}/${name}") + file(MAKE_DIRECTORY "${output}") + if(manifest MATCHES "\\.in$") + configure_file("${manifest}" "${output}/AndroidManifest.xml" @ONLY) + set(manifest "${output}/AndroidManifest.xml") + endif() + foreach(kind JAVA RESOURCE ASSET PROGUARD) + if(kind STREQUAL "PROGUARD") + set(list_name PROGUARD_FILES) + else() + set(list_name "${kind}_DIRS") + endif() + set(paths "") + foreach(path IN LISTS APP_${list_name}) + get_filename_component(path "${path}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") + list(APPEND paths "${path}") + endforeach() + set(APP_${list_name} "${paths}") + endforeach() + # AGP also reads the NDK alias when deciding whether to package libc++_shared.so. + list(PREPEND APP_CMAKE_ARGUMENTS "-DCMAKE_ANDROID_STL_TYPE=${CMAKE_ANDROID_STL_TYPE}" + "-DANDROID_STL=${CMAKE_ANDROID_STL_TYPE}") + list(APPEND APP_CMAKE_ARGUMENTS "-DTEMPEST_ANDROID_GRADLE_BUILD=ON") + foreach(value APPLICATION_ID VERSION_NAME PROPERTY_PREFIX SIGNING_ENV_PREFIX NATIVE_TARGET NATIVE_SYMBOLS) + _tempest_android_quote(${value} "${APP_${value}}") + endforeach() + _tempest_android_quote(NATIVE_CMAKE "${CMAKE_SOURCE_DIR}/CMakeLists.txt") + foreach(value JAVA_DIRS RESOURCE_DIRS ASSET_DIRS DEPENDENCIES CMAKE_ARGUMENTS CPP_FLAGS PROGUARD_FILES NO_COMPRESS) + _tempest_android_list(${value} ${APP_${value}}) + endforeach() + _tempest_android_list(ABIS "${CMAKE_ANDROID_ARCH_ABI}") + _tempest_android_quote(RELEASE_TYPE "-DCMAKE_BUILD_TYPE=${release_type}") + if(APP_SHRINK_RELEASE) + set(SHRINK_RELEASE true) + else() + set(SHRINK_RELEASE false) + endif() + _tempest_android_quote(MANIFEST "${manifest}") + configure_file("${templates}/build.gradle.in" "${output}/build.gradle" @ONLY NEWLINE_STYLE LF) + find_program(TEMPEST_ANDROID_GRADLE_EXECUTABLE NAMES gradle gradle.bat HINTS "$ENV{GRADLE_HOME}/bin" + DOC "Gradle executable used by the APK build target" NO_CMAKE_FIND_ROOT_PATH) + if(TEMPEST_ANDROID_GRADLE_EXECUTABLE) + add_custom_target(${name} + COMMAND "${TEMPEST_ANDROID_GRADLE_EXECUTABLE}" -p "${output}" --no-daemon --max-workers=2 + "-Dorg.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8" + "assemble${variant}" "lint${variant}" + USES_TERMINAL VERBATIM) + else() + # Generation still works for IDE users without a Gradle command on PATH. + add_custom_target(${name} + COMMAND "${CMAKE_COMMAND}" -E echo "Install Gradle 8.9 and configure TEMPEST_ANDROID_GRADLE_EXECUTABLE, then rerun CMake." + COMMAND "${CMAKE_COMMAND}" -E false + VERBATIM) + endif() + message(STATUS "Generated Android project: ${output}") + message(STATUS "Build APK: cmake --build ${CMAKE_BINARY_DIR} --target ${name}") +endfunction() diff --git a/Engine/cmake/android/build.gradle.in b/Engine/cmake/android/build.gradle.in new file mode 100644 index 00000000..2a75ab62 --- /dev/null +++ b/Engine/cmake/android/build.gradle.in @@ -0,0 +1,103 @@ +// Generated by Tempest. Change the application's CMake packaging configuration. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:@TEMPEST_ANDROID_AGP@' + } +} +apply plugin: 'com.android.application' + +repositories { + google() + mavenCentral() +} + +dependencies { + @DEPENDENCIES@.each { implementation it } +} + +def signingPrefix = @SIGNING_ENV_PREFIX@ +def signingValues = ['KEYSTORE', 'KEY_ALIAS', 'STORE_PASSWORD', 'KEY_PASSWORD'].collect { + providers.environmentVariable(signingPrefix + '_' + it).orNull +} +if (signingValues.any { it != null } && signingValues.any { !it }) { + throw new GradleException('Set all four ' + signingPrefix + ' signing variables, or leave all unset for local signing.') +} +def propertyPrefix = @PROPERTY_PREFIX@ + +android { + namespace @APPLICATION_ID@ + compileSdk @TEMPEST_ANDROID_COMPILE_SDK@ + buildToolsVersion '@TEMPEST_ANDROID_BUILD_TOOLS@' + @NDK_CONFIGURATION@ + + defaultConfig { + applicationId @APPLICATION_ID@ + minSdk @MIN_SDK@ + targetSdk @TEMPEST_ANDROID_TARGET_SDK@ + versionCode providers.gradleProperty(propertyPrefix + 'VersionCode').orElse('@APP_VERSION_CODE@').get().toInteger() + versionName providers.gradleProperty(propertyPrefix + 'VersionName').orElse(@VERSION_NAME@).get() + ndk { + abiFilters.addAll(@ABIS@) + } + externalNativeBuild { + cmake { + arguments.addAll(@CMAKE_ARGUMENTS@) + cppFlags.addAll(@CPP_FLAGS@) + targets.add(@NATIVE_TARGET@) + } + } + } + signingConfigs { + if (signingValues[0] != null) { + distribution { + storeFile file(signingValues[0]) + keyAlias signingValues[1] + storePassword signingValues[2] + keyPassword signingValues[3] + } + } + } + buildTypes { + debug { + debuggable true + jniDebuggable true + externalNativeBuild.cmake.arguments '-DCMAKE_BUILD_TYPE=Debug' + } + release { + debuggable false + jniDebuggable false + minifyEnabled @SHRINK_RELEASE@ + shrinkResources @SHRINK_RELEASE@ + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') + proguardFiles.addAll(@PROGUARD_FILES@.collect { file(it) }) + // Reuse the local key for testing unless distribution signing is configured. + signingConfig signingValues[0] != null ? signingConfigs.distribution : signingConfigs.debug + ndk.debugSymbolLevel @NATIVE_SYMBOLS@ + externalNativeBuild.cmake.arguments @RELEASE_TYPE@ + } + } + externalNativeBuild { + cmake { + path file(@NATIVE_CMAKE@) + version '@TEMPEST_ANDROID_CMAKE@' + } + } + sourceSets { + main { + manifest.srcFile file(@MANIFEST@) + java.srcDirs = @JAVA_DIRS@ + res.srcDirs = @RESOURCE_DIRS@ + assets.srcDirs = @ASSET_DIRS@ + } + } + androidResources { + noCompress.addAll(@NO_COMPRESS@) + } + lint { + disable 'ChromeOsAbiSupport' + } +} diff --git a/Examples/Android/AndroidManifest.xml b/Examples/Android/AndroidManifest.xml new file mode 100644 index 00000000..1915b1bd --- /dev/null +++ b/Examples/Android/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/Examples/Android/CMakeLists.txt b/Examples/Android/CMakeLists.txt new file mode 100644 index 00000000..7dc73837 --- /dev/null +++ b/Examples/Android/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.22) +project(TempestExample LANGUAGES CXX) + +if(NOT ANDROID) + message(FATAL_ERROR "Configure this example with the Android NDK toolchain") +endif() + +# Tempest applications get this helper through add_subdirectory(Engine). +# This packaging sample does not depend on the Android backend yet. +include(../../Engine/cmake/TempestAndroid.cmake) + +add_library(TempestExample SHARED main.cpp) +set_target_properties(TempestExample PROPERTIES OUTPUT_NAME tempest-example) +target_link_libraries(TempestExample PRIVATE android log) + +add_android_apk(TempestExample-apk + PACKAGE_NAME org.tempest.example + CODE TempestExample + MANIFEST AndroidManifest.xml) diff --git a/Examples/Android/README.md b/Examples/Android/README.md new file mode 100644 index 00000000..6cb013d6 --- /dev/null +++ b/Examples/Android/README.md @@ -0,0 +1,29 @@ +# Android packaging + +A small NativeActivity packaging example. It will move into `Examples/Empty` when the Android backend is available upstream. + +With JDK 17, Gradle 8.9, Ninja and the Android SDK configured (`ANDROID_HOME`), install SDK 35, build-tools 35.0.0, NDK 27.0.12077973 and CMake 3.22.1. Replace `/path/to/ndk` below with the NDK installation directory. + +```sh +cmake -S Examples/Android -B build/android-example -G Ninja -DCMAKE_TOOLCHAIN_FILE=/path/to/ndk/build/cmake/android.toolchain.cmake -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24 -DCMAKE_BUILD_TYPE=Release +cmake --build build/android-example --target TempestExample-apk +adb install -r build/android-example/TempestExample-apk/build/outputs/apk/release/TempestExample-apk-release.apk +adb shell am start -n org.tempest.example/android.app.NativeActivity +``` + +Use `-DCMAKE_BUILD_TYPE=Debug` for a debug APK. Gradle must be on `PATH`, under `GRADLE_HOME`, or selected with `TEMPEST_ANDROID_GRADLE_EXECUTABLE`. Release APKs use the local debug key unless the `TEMPEST_KEYSTORE`, `TEMPEST_KEY_ALIAS`, `TEMPEST_STORE_PASSWORD` and `TEMPEST_KEY_PASSWORD` environment variables are set. + +In an application's existing CMakeLists.txt, after defining its shared-library target and adding Tempest: + +```cmake +if(ANDROID) + add_android_apk(MyGame-apk + CODE MyGame + PACKAGE_NAME org.example.mygame + MANIFEST AndroidManifest.xml) +endif() +``` + +The application owns the manifest; `.in` templates are also supported. NativeActivity's `android.app.lib_name` must match the library's `OUTPUT_NAME` without `lib` or `.so`. Use `CMAKE_ARGUMENTS` to pass project-specific CMake options into Gradle's native build. + +The helper generates one `build.gradle` and points Gradle at this same CMake project. Its inner native build skips packaging generation. The generated directory can also be imported into Android Studio. No separate packaging CMake project or checked-in Gradle wrapper is needed, and desktop builds do not look for Android tools. diff --git a/Examples/Android/main.cpp b/Examples/Android/main.cpp new file mode 100644 index 00000000..30a01449 --- /dev/null +++ b/Examples/Android/main.cpp @@ -0,0 +1,28 @@ +#include +#include +#include +#include + +// A packaging smoke test using the platform activity, independent of Tempest's Android backend. +// Fold this into Examples/Empty once that backend is available upstream. +static void draw(ANativeActivity*, ANativeWindow* window) { + ANativeWindow_setBuffersGeometry(window,0,0,WINDOW_FORMAT_RGBA_8888); + ANativeWindow_Buffer buffer = {}; + if(ANativeWindow_lock(window,&buffer,nullptr)!=0) + return; + auto pixels = static_cast(buffer.bits); + for(int y=0; ybuffer.width/3 && xbuffer.height/3 && ycallbacks->onNativeWindowCreated = draw; + activity->callbacks->onNativeWindowResized = draw; + activity->callbacks->onNativeWindowRedrawNeeded = draw; + __android_log_print(ANDROID_LOG_INFO,"TempestExample","Native packaging example started"); + } diff --git a/README.md b/README.md index 38ed6f9a..8331d4b0 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ auto pm = device.readPixels(tex); pm.save(outImg); ``` +### Android builds + +The [Android packaging example](Examples/Android/README.md) uses `add_android_apk` in the application's existing CMake project to generate a single Gradle build. It uses Android's built-in NativeActivity; the Tempest Android backend is separate work. Desktop builds do not require Java, the Android SDK or Gradle. + ### Ecosystem During development various issues of Vulkan stack been found, reported and some were fixed.