diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 34538dfb6b..b37f62c183 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -541,6 +541,7 @@ fun createAssetsZip(arch: String) { "documentation.db", bootstrapName, "plugin-artifacts.zip", + "core.cgt" ).forEach { fileName -> val filePath = sourceDir.resolve(fileName) if (!filePath.exists()) { @@ -1059,6 +1060,12 @@ val debugAssets = "localMvnRepository.zip", "debug", ), + Asset( + "assets/core.cgt", + "https://appdevforall.org/dev-assets/debug/core.cgt", + "core.cgt", + "debug", + ), ) val releaseAssets = @@ -1111,6 +1118,12 @@ val releaseAssets = "v8/bootstrap.zip.br", "release", ), + Asset( + "assets/release/common/data/common/core.cgt.br", + "https://appdevforall.org/dev-assets/release/core.cgt.br", + "core.cgt.br", + "release", + ), ) fun assetsBatch( diff --git a/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt index 5f6616b31a..f6e95acd19 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/TemplateListAdapter.kt @@ -22,6 +22,7 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.blankj.utilcode.util.ConvertUtils +import com.bumptech.glide.Glide import com.google.android.material.shape.CornerFamily import com.itsaky.androidide.adapters.TemplateListAdapter.ViewHolder import com.itsaky.androidide.databinding.LayoutTemplateListItemBinding @@ -37,6 +38,7 @@ class TemplateListAdapter( private val onClick: ((Template<*>, ViewHolder) -> Unit)? = null, private val onLongClick: ((Template<*>, View) -> Unit)? = null, ) : RecyclerView.Adapter() { + private val templates = templates.toMutableList() class ViewHolder( @@ -61,14 +63,24 @@ class TemplateListAdapter( holder: ViewHolder, position: Int, ) { - holder.binding.apply { - val template = templates[position] - if (template == Template.EMPTY) { + + holder.binding.apply { + val template = templates[position] + if (template == Template.EMPTY) { root.visibility = View.INVISIBLE return@apply } - templateName.text = templateName.context.getString(template.templateName) - templateIcon.setImageResource(template.thumb) + + templateName.text = template.templateNameStr + if (template.thumbData != null) { + Glide.with(templateIcon.context) + .asBitmap() + .load(template.thumbData) + .into(templateIcon) + } else { + templateIcon.setImageResource(template.thumb) + } + templateIcon.shapeAppearanceModel = templateIcon.shapeAppearanceModel .toBuilder() diff --git a/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt index b339c49bbc..55b5e21a73 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/AssetsInstallationHelper.kt @@ -20,6 +20,7 @@ import org.adfa.constants.DOCUMENTATION_DB import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.slf4j.LoggerFactory import com.itsaky.androidide.resources.R import java.io.File @@ -113,7 +114,8 @@ object AssetsInstallationHelper { BOOTSTRAP_ENTRY_NAME, GRADLE_API_NAME_JAR_ZIP, LLAMA_AAR, - PLUGIN_ARTIFACTS_ZIP + PLUGIN_ARTIFACTS_ZIP, + TEMPLATE_CORE_ARCHIVE, ) val stagingDir = Files.createTempDirectory(UUID.randomUUID().toString()) diff --git a/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt index d96eba2114..b69dfd21e1 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt @@ -20,6 +20,8 @@ import org.adfa.constants.GRADLE_API_NAME_JAR_BR import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE_BR import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException @@ -78,26 +80,36 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { } } - AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { + TEMPLATE_CORE_ARCHIVE -> { + val assetPath = ToolsManager.getCommonAsset(TEMPLATE_CORE_ARCHIVE_BR) + BrotliInputStream(assets.open(assetPath)).use { input -> + val destFile = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { val assetPath = ToolsManager.getCommonAsset("${AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME}.br") val result = retryOnceOnNoSuchFile ( onFirstFailure = { Files.createDirectories(stagingDir) }, onSecondFailure = { e2 -> - throw IOException( - context.getString(R.string.terminal_installation_failed_low_storage), - e2 - ) - } - ) { - withTempZipChannel( - stagingDir = stagingDir, - prefix = "bootstrap", - writeTo = { path -> writeBrotliAssetToPath(context, assetPath, path) }, - useChannel = { ch -> TerminalInstaller.installIfNeeded(context, ch) } - ) - } + throw IOException( + context.getString(R.string.terminal_installation_failed_low_storage), + e2 + ) + } + ) { + withTempZipChannel( + stagingDir = stagingDir, + prefix = "bootstrap", + writeTo = { path -> writeBrotliAssetToPath(context, assetPath, path) }, + useChannel = { ch -> TerminalInstaller.installIfNeeded(context, ch) } + ) + } when (result) { is TerminalInstaller.InstallResult.Success -> {} @@ -217,6 +229,7 @@ data object BundledAssetsInstaller : BaseAssetsInstaller() { AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 124120151L GRADLE_API_NAME_JAR_ZIP -> 29447748L AssetsInstallationHelper.PLUGIN_ARTIFACTS_ZIP -> 86442L + TEMPLATE_CORE_ARCHIVE -> 133120L else -> 0L } diff --git a/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt b/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt index a94e4cfe06..935b69c585 100644 --- a/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt @@ -15,6 +15,7 @@ import org.adfa.constants.DOCUMENTATION_DB import org.adfa.constants.GRADLE_API_NAME_JAR_ZIP import org.adfa.constants.GRADLE_DISTRIBUTION_ARCHIVE_NAME import org.adfa.constants.LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.slf4j.LoggerFactory import java.io.File import java.io.FileNotFoundException @@ -72,6 +73,13 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { logger.debug("Completed extracting '{}' to dir: {}", entry.name, destDir) } + TEMPLATE_CORE_ARCHIVE -> { + val coreCgt = Environment.TEMPLATES_DIR.resolve(TEMPLATE_CORE_ARCHIVE) + coreCgt.outputStream().use { output -> + zipInput.copyTo(output) + } + } + AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> { logger.debug("Extracting 'bootstrap.zip' to dir: {}", stagingDir) @@ -194,6 +202,7 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> 215389106L AssetsInstallationHelper.BOOTSTRAP_ENTRY_NAME -> 456462823L GRADLE_API_NAME_JAR_ZIP -> 46758608L + TEMPLATE_CORE_ARCHIVE -> 702001L else -> 0L } @@ -203,6 +212,7 @@ data object SplitAssetsInstaller : BaseAssetsInstaller() { ANDROID_SDK_ZIP -> Environment.ANDROID_HOME LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> Environment.LOCAL_MAVEN_DIR GRADLE_API_NAME_JAR_ZIP -> Environment.GRADLE_GEN_JARS + TEMPLATE_CORE_ARCHIVE -> Environment.TEMPLATES_DIR else -> throw IllegalStateException("Entry '$entryName' is not expected to be an archive") } diff --git a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt index 16b6a02f8c..10b0a5c9fe 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/TemplateDetailsFragment.kt @@ -136,8 +136,8 @@ class TemplateDetailsFragment : name = result.data.name, createdAt = now, lastModified = now, - templateName = getString(template.templateName), - language = result.data.language.name + templateName = template.templateNameStr, + language = result.data.language?.name ?: "unknown" ) ) @@ -163,6 +163,6 @@ class TemplateDetailsFragment : template ?: return binding.widgets.adapter = TemplateWidgetsListAdapter(template.widgets) - binding.title.setText(template.templateName) + binding.title.text = template.templateNameStr } } \ No newline at end of file diff --git a/common/src/main/java/com/itsaky/androidide/utils/Environment.java b/common/src/main/java/com/itsaky/androidide/utils/Environment.java index a3e1e64637..6b80b83c23 100755 --- a/common/src/main/java/com/itsaky/androidide/utils/Environment.java +++ b/common/src/main/java/com/itsaky/androidide/utils/Environment.java @@ -120,6 +120,8 @@ public final class Environment { public static final String NDK_TAR_XZ = "ndk-cmake.tar.xz"; public static File NDK_DIR; + public static File TEMPLATES_DIR; + public static String getArchitecture() { return IDEBuildConfigProvider.getInstance().getCpuAbiName(); } @@ -188,6 +190,8 @@ public static void init(Context context) { NDK_DIR = new File(ANDROID_HOME, "ndk"); + TEMPLATES_DIR = mkdirIfNotExists(new File(ANDROIDIDE_HOME, "templates")); + isInitialized.set(true); } diff --git a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt index 4899f6e7d8..2c3d803607 100644 --- a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt +++ b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt @@ -76,3 +76,8 @@ const val LOGSENDER_AAR_NAME = "logsender.aar" const val GRADLE_API_NAME_JAR = "gradle-api-$GRADLE_DISTRIBUTION_VERSION.jar" const val GRADLE_API_NAME_JAR_ZIP = "${GRADLE_API_NAME_JAR}.zip" const val GRADLE_API_NAME_JAR_BR = "${GRADLE_API_NAME_JAR}.br" + +// Templates archive +const val TEMPLATE_ARCHIVE_EXTENSION = "cgt" +const val TEMPLATE_CORE_ARCHIVE = "core.$TEMPLATE_ARCHIVE_EXTENSION" +const val TEMPLATE_CORE_ARCHIVE_BR = "${TEMPLATE_CORE_ARCHIVE}.br" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 433a98d96f..30bb172a63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,6 +78,9 @@ compose-compiler = "2.1.21" leakcanary = "2.14" +pebble = "4.1.1" + + [libraries] # Dependencies in composite build @@ -307,6 +310,8 @@ androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "m monitor = { group = "androidx.test", name = "monitor", version.ref = "monitorVersion" } org-json = { module = "org.json:json", version = "20210307"} +pebble = { module = "io.pebbletemplates:pebble", version.ref = "pebble" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/templates-api/build.gradle.kts b/templates-api/build.gradle.kts index dd97575406..a8ca000bc1 100644 --- a/templates-api/build.gradle.kts +++ b/templates-api/build.gradle.kts @@ -39,4 +39,6 @@ dependencies { api(libs.androidx.annotation) api(libs.androidx.appcompat) api(libs.google.material) + + implementation(libs.google.gson) } diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/base/ProjectTemplateBuilder.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/base/ProjectTemplateBuilder.kt index e31494fa2c..bed627c668 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/base/ProjectTemplateBuilder.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/base/ProjectTemplateBuilder.kt @@ -199,5 +199,7 @@ class ProjectTemplateBuilder : ExecutorDataTemplateBuilder baseFile( return FileTemplateBuilder(dir).apply(configurator) .build() as FileTemplate } + +/** + * Setup base files for zip project templates. + * + * @param block Function to configure the template. + */ +inline fun baseZipProject( + projectName: StringParameter = projectNameParameter(), + packageName: StringParameter = packageNameParameter(), + useKts: BooleanParameter = useKtsParameter(), + minSdk: EnumParameter = minSdkParameter(), + language: EnumParameter = projectLanguageParameter(), + projectVersionData: ProjectVersionData = ProjectVersionData(), + isToml: Boolean = false, + showUseKts: Boolean = false, + showMinSdk: Boolean = true, + showLanguage: Boolean = true, + crossinline block: ProjectTemplateBuilder.() -> Unit +): ProjectTemplate { + return ProjectTemplateBuilder().apply { + + // When project name is changed, change the package name accordingly + projectName.observe { name -> + val newPackage = AndroidUtils.appNameToPackageName(name.value, packageName.value) + packageName.setValue(newPackage) + } + + Environment.mkdirIfNotExists(Environment.PROJECTS_DIR) + + val saveLocation = stringParameter { + name = R.string.wizard_save_location + default = Environment.PROJECTS_DIR.absolutePath + endIcon = { R.drawable.ic_folder } + constraints = listOf(NONEMPTY, DIRECTORY, EXISTS) + inputType = + android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE + maxLines = 1 + tooltipTag = "setup.save.location" + } + + projectName.doBeforeCreateView { + it.setValue(getNewProjectName(saveLocation.value, projectName.value)) + } + + widgets( + TextFieldWidget(projectName), TextFieldWidget(packageName), + TextFieldWidget(saveLocation) + ) + + if (showLanguage) { + widgets(SpinnerWidget(language)) + } + + if (showMinSdk) { + widgets(SpinnerWidget(minSdk)) + } + + if (showUseKts) { + widgets(CheckBoxWidget(useKts)) + } + + // Setup the required properties before executing the recipe + preRecipe = { + this@apply._executor = this + + if (!showUseKts) { + useKts.setValue(true, notify = false) + } + + this@apply._data = ProjectTemplateData( + projectName.value, + File(saveLocation.value, projectName.value), + projectVersionData, + language = if (showLanguage) language.value else null, + useKts = useKts.value, + useToml = isToml + ) + + if (data.projectDir.exists() && data.projectDir.listFiles() + ?.isNotEmpty() == true + ) { + throw IllegalArgumentException("Project directory already exists") + } + + setDefaultModuleData( + ModuleTemplateData( + ":app", appName = data.name, packageName.value, + data.moduleNameToDir(":app"), type = AndroidApp, + language = if (showLanguage) language.value else null, + minSdk = if (showMinSdk) minSdk.value else null, + useKts = data.useKts, useToml = isToml + ) + ) + } + + block() + + }.build() as ProjectTemplate +} diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/base/modules/android/buildGradle.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/base/modules/android/buildGradle.kt index 72be971037..0fa07f55ef 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/base/modules/android/buildGradle.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/base/modules/android/buildGradle.kt @@ -97,7 +97,7 @@ android { defaultConfig { applicationId = "${data.packageName}" - minSdk = ${data.versions.minSdk.api} + minSdk = ${data.versions.minSdk?.api} targetSdk = ${if (isComposeModule) data.versions.composeSdk.api else data.versions.targetSdk.api} versionCode = 1 versionName = "1.0" @@ -188,7 +188,7 @@ android { defaultConfig { applicationId = "${data.packageName}" - minSdk = ${data.versions.minSdk.api} + minSdk = ${data.versions.minSdk?.api} targetSdk = ${data.versions.targetSdk.api} versionCode = 1 versionName = "1.0" @@ -448,7 +448,7 @@ android { defaultConfig { applicationId = "${data.packageName}" - minSdk = ${data.versions.minSdk.api} + minSdk = ${data.versions.minSdk?.api} targetSdk = ${if (isComposeModule) data.versions.composeSdk.api else data.versions.targetSdk.api} versionCode = 1 versionName = "1.0" @@ -558,7 +558,7 @@ android { defaultConfig { applicationId = "${data.packageName}" - minSdk = ${data.versions.minSdk.api} + minSdk = ${data.versions.minSdk?.api} targetSdk = ${data.versions.targetSdk.api} versionCode = 1 versionName = "1.0" diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt index f8ec1fd075..f96589df70 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/parameters.kt @@ -86,7 +86,9 @@ abstract class Parameter( @StringRes val name: Int, @StringRes val description: Int?, val default: T, val tooltipTag: String? = null, - var constraints: List + var constraints: List, + var id: Int? = null, + val nameStr: String? = null ) { private val observers = hashSetOf>() @@ -252,8 +254,12 @@ abstract class ParameterBuilder { var constraints: List = emptyList() + var id: Int? = null + var nameStr: String? = null + protected open fun validate() { - checkNotNull(name) { "Parameter must have a name" } + val nameAll: Any? = if (name != null) name else nameStr + checkNotNull(nameAll) { "Parameter must have a name" } checkNotNull(default) { "Parameter must have a default value" } } @@ -262,13 +268,14 @@ abstract class ParameterBuilder { class BooleanParameter( @StringRes name: Int, @StringRes description: Int?, - default: Boolean, tooltipTag: String?, constraints: List -) : Parameter(name, description, default, tooltipTag, constraints) + default: Boolean, tooltipTag: String?, constraints: List, + id: Int? = null, nameStr: String? = null +) : Parameter(name, description, default, tooltipTag, constraints, id, nameStr) class BooleanParameterBuilder : ParameterBuilder() { override fun build(): BooleanParameter { - return BooleanParameter(name!!, description, default!!, tooltipTag, constraints) + return BooleanParameter(name!!, description, default!!, tooltipTag, constraints, id, nameStr) } } @@ -292,8 +299,9 @@ abstract class TextFieldParameter( val onEndIconClick: View.OnClickListener?, val inputType: Int?, @StyleableRes val imeOptions: Int?, - val maxLines: Int?, tooltipTag: String?, constraints: List -) : Parameter(name, description, default, tooltipTag, constraints) + val maxLines: Int?, tooltipTag: String?, constraints: List, + id: Int?, nameStr: String? +) : Parameter(name, description, default, tooltipTag, constraints, id, nameStr) abstract class TextFieldParameterBuilder( var startIcon: ((TextFieldParameter) -> Int)? = null, @@ -302,7 +310,7 @@ abstract class TextFieldParameterBuilder( var onEndIconClick: View.OnClickListener? = null, var inputType: Int? = null, var imeOptions: Int? = null, - var maxLines: Int? = null + var maxLines: Int? = null, ) : ParameterBuilder() class StringParameter( @@ -316,10 +324,13 @@ class StringParameter( @StyleableRes imeOptions: Int? = null, maxLines: Int? = null, tooltipTag: String?, - constraints: List + constraints: List, + id: Int?, + nameStr: String? ) : TextFieldParameter( name, description, default, startIcon, endIcon, - onStartIconClick, onEndIconClick, inputType, imeOptions, maxLines, tooltipTag, constraints + onStartIconClick, onEndIconClick, inputType, imeOptions, maxLines, tooltipTag, constraints, + id, nameStr ) class StringParameterBuilder : TextFieldParameterBuilder() { @@ -338,6 +349,8 @@ class StringParameterBuilder : TextFieldParameterBuilder() { maxLines = maxLines, tooltipTag = tooltipTag, constraints = constraints, + id = id, + nameStr = nameStr ) } } @@ -351,10 +364,12 @@ class EnumParameter>( onEndIconClick: View.OnClickListener?, tooltipTag: String?, constraints: List, val displayName: ((T) -> String)? = null, - val filter: ((T) -> Boolean)? = null + val filter: ((T) -> Boolean)? = null, + id: Int? = null, nameStr: String? = null ) : TextFieldParameter( name, description, default, startIcon, endIcon, onStartIconClick, - onEndIconClick, null, null, null, tooltipTag, constraints + onEndIconClick, null, null, null, tooltipTag, constraints, + id, nameStr ) { /** diff --git a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt index 55e70297f0..677fcc9a80 100644 --- a/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt +++ b/templates-api/src/main/java/com/itsaky/androidide/templates/template.kt @@ -106,7 +106,7 @@ typealias TemplateRecipeFinalizer = RecipeExecutor.() -> Unit * @property language The source language for the module. * @property useKts Whether to use Kotlin DSL for Gradle build scripts. */ -abstract class BaseTemplateData(val name: String, val projectDir: File, val language: Language, +abstract class BaseTemplateData(val name: String, val projectDir: File, var language: Language?, val useKts: Boolean, val useToml: Boolean = false) : TemplateData() { /** @@ -176,7 +176,7 @@ open class ProjectVersionData(val gradlePlugin: String = ANDROID_GRADLE_PLUGIN_V * @property targetSdk The target SDK version for modules. * @property buildTools The build tools version for modules. */ -data class ModuleVersionData(val minSdk: Sdk, +data class ModuleVersionData(val minSdk: Sdk?, val targetSdk: Sdk = TARGET_SDK_VERSION, val compileSdk: Sdk = COMPILE_SDK_VERSION, val javaSource: String = JAVA_SOURCE_VERSION, @@ -203,7 +203,7 @@ private fun javaVersionPrefix(version: String): String = "JavaVersion.VERSION_${ * @property version The version information for this project. */ class ProjectTemplateData(name: String, projectDir: File, val version: ProjectVersionData, -language: Language, useKts: Boolean, useToml: Boolean = false) : BaseTemplateData(name, projectDir, language, useKts, useToml) +language: Language?, useKts: Boolean, useToml: Boolean = false) : BaseTemplateData(name, projectDir, language, useKts, useToml) /** * Data for creating module projects. @@ -217,7 +217,7 @@ language: Language, useKts: Boolean, useToml: Boolean = false) : BaseTemplateDat * So in future we would not get confused. */ open class ModuleTemplateData(name: String, val appName: String?, val packageName: String, - projectDir: File, val type: ModuleType, language: Language, useKts: Boolean = true, minSdk: Sdk, + projectDir: File, val type: ModuleType, language: Language?, useKts: Boolean = true, minSdk: Sdk?, val versions: ModuleVersionData = ModuleVersionData(minSdk), useToml: Boolean = false) : BaseTemplateData(name, projectDir, language, useKts, useToml) { @@ -233,12 +233,14 @@ fun srcFolder(srcSet: SrcSet): File { /** * Model for a template. * - * @property templateName The name of the template. + * @property templateName The name id of the template. + * @property templateNameStr The name of the template. * @property thumb The thumbnail for the template. */ open class Template(@StringRes open val templateName: Int, -@DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, -open val recipe: TemplateRecipe) { + @DrawableRes open val thumb: Int, open val tooltipTag: String?, open val widgets: List>, + open val recipe: TemplateRecipe, open val templateNameStr: String = "", open val thumbData: ByteArray? = null +) { /** * The ID for this template. @@ -263,8 +265,8 @@ companion object { open class ProjectTemplate(val moduleTemplates: List>, @StringRes templateName: Int, @DrawableRes thumb: Int, tooltipTag: String?, widgets: List>, -recipe: TemplateRecipe) : -Template(templateName, thumb, tooltipTag, widgets, recipe) { +recipe: TemplateRecipe, templateNameStr: String, thumbData: ByteArray?) : +Template(templateName, thumb, tooltipTag, widgets, recipe, templateNameStr, thumbData) { override val parameters: Collection> get() = if (moduleTemplates.isEmpty()) super.parameters else super.parameters.toMutableList() @@ -319,7 +321,9 @@ widgets: List>, recipe: TemplateRecipe) : Template(name, thumb, abstract class TemplateBuilder( @StringRes open var templateName: Int? = null, @DrawableRes open var thumb: Int? = null, open var tooltipTag: String? = null, -open var widgets: List>? = null, open var recipe: TemplateRecipe? = null) { +open var widgets: List>? = null, open var recipe: TemplateRecipe? = null, +open var templateNameStr: String? = null, open var thumbData: ByteArray? = null +) { /** * Adds the given widgets to the widgets list. @@ -338,9 +342,10 @@ fun widgets(vararg widgets: Widget<*>) { } fun build(): Template { - requireNotNull(templateName) { "Template must have a name" } + requireNotNull(templateName) { "Template must have a name id" } requireNotNull(thumb) { "Template must have a thumbnail" } requireNotNull(recipe) { "Template must have a recipe" } + requireNotNull(templateNameStr) {"Template must have a name"} this.widgets = this.widgets ?: emptyList() diff --git a/templates-impl/build.gradle.kts b/templates-impl/build.gradle.kts index 19c7e7bba5..db9b091c47 100644 --- a/templates-impl/build.gradle.kts +++ b/templates-impl/build.gradle.kts @@ -41,7 +41,9 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.google.auto.service.annotations) - testImplementation(projects.templatesApi) + implementation(libs.pebble) + + testImplementation(projects.templatesApi) testImplementation(projects.lsp.api) testImplementation(projects.preferences) testImplementation(projects.testing.unit) diff --git a/templates-impl/proguard-rules.pro b/templates-impl/proguard-rules.pro index 481bb43481..518cb9ec1d 100644 --- a/templates-impl/proguard-rules.pro +++ b/templates-impl/proguard-rules.pro @@ -18,4 +18,12 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-keep class com.itsaky.androidide.templates.impl.zip.** { *; } + +-keepattributes Signature +-keepattributes *Annotation* + +-keepattributes InnerClasses +-keepattributes EnclosingMethod diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt index 70312efa4f..e3edf59a25 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt @@ -21,16 +21,14 @@ import com.google.auto.service.AutoService import com.google.common.collect.ImmutableList import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.templates.Template -import com.itsaky.androidide.templates.impl.basicActivity.basicActivityProject -import com.itsaky.androidide.templates.impl.bottomNavActivity.bottomNavActivityProject -import com.itsaky.androidide.templates.impl.composeActivity.composeActivityProject -import com.itsaky.androidide.templates.impl.emptyActivity.emptyActivityProject -import com.itsaky.androidide.templates.impl.navDrawerActivity.navDrawerActivityProject -import com.itsaky.androidide.templates.impl.noActivity.noActivityProjectTemplate -import com.itsaky.androidide.templates.impl.noAndroidXActivity.noAndroidXActivityProject -import com.itsaky.androidide.templates.impl.pluginProject.pluginProjectTemplate -import com.itsaky.androidide.templates.impl.tabbedActivity.tabbedActivityProject -import com.itsaky.androidide.templates.impl.ndkActivity.ndkActivityProject +import com.itsaky.androidide.templates.impl.zip.ZipRecipeExecutor +import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader + +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import com.itsaky.androidide.utils.Environment.TEMPLATES_DIR + +import org.slf4j.LoggerFactory +import java.util.zip.ZipFile /** * Default implementation of the [ITemplateProvider]. @@ -41,49 +39,55 @@ import com.itsaky.androidide.templates.impl.ndkActivity.ndkActivityProject @AutoService(ITemplateProvider::class) class TemplateProviderImpl : ITemplateProvider { - private val templates = mutableMapOf>() - - init { - initializeTemplates() - } - - private fun templates() = - //@formatter:off - listOfNotNull( - noActivityProjectTemplate(), - emptyActivityProject(), - basicActivityProject(), - navDrawerActivityProject(), - bottomNavActivityProject(), - tabbedActivityProject(), - noAndroidXActivityProject(), - composeActivityProject(), - pluginProjectTemplate(), - ndkActivityProject() - ) - - private fun initializeTemplates() { - templates().forEach { template -> - templates[template.templateId] = template + companion object { + private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java) + } + + private val templates = mutableMapOf>() + + init { + reload() + } + + private fun initializeTemplates() { + val folder = TEMPLATES_DIR + log.debug("Template listing archives in: ${folder.toString()} with extension: ${TEMPLATE_ARCHIVE_EXTENSION}") + val list = folder.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } ?: return + + for (zipFile in list) { + log.debug("Template archive: $zipFile") + try { + val zipTemplates = ZipTemplateReader.read(zipFile) { json, params, path, data, defModule -> + ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule) + } + + for (t in zipTemplates) { + log.debug("template: $t") + templates[t.templateId] = t + } + + log.debug("templates: $templates") + } catch (e: Exception) { + log.error("Failed to load template from archive: $zipFile", e) + } + } + } + + override fun getTemplates(): List> { + return ImmutableList.copyOf(templates.values) + } + + override fun getTemplate(templateId: String): Template<*>? { + return templates[templateId] + } + + override fun reload() { + release() + initializeTemplates() + } + + override fun release() { + templates.forEach { it.value.release() } + templates.clear() } - } - //@formatter:on - - override fun getTemplates(): List> { - return ImmutableList.copyOf(templates.values) - } - - override fun getTemplate(templateId: String): Template<*>? { - return templates[templateId] - } - - override fun reload() { - release() - initializeTemplates() - } - - override fun release() { - templates.forEach { it.value.release() } - templates.clear() - } } \ No newline at end of file diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateWidgetViewProviderImpl.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateWidgetViewProviderImpl.kt index 5b3a136e24..c5ff6a5ec0 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateWidgetViewProviderImpl.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateWidgetViewProviderImpl.kt @@ -19,7 +19,6 @@ package com.itsaky.androidide.templates.impl import android.annotation.SuppressLint import android.content.Context -import android.graphics.drawable.ColorDrawable import android.util.TypedValue import android.view.LayoutInflater import android.view.MotionEvent @@ -114,7 +113,11 @@ class TemplateWidgetViewProviderImpl : ITemplateWidgetViewProvider { private fun createCheckBox(context: Context, widget: CheckBoxWidget): View { return LayoutCheckboxBinding.inflate(LayoutInflater.from(context)).apply { val param = widget.parameter as BooleanParameter - root.setText(param.name) + if (param.nameStr != null) { + root.text = param.nameStr + } else { + root.setText(param.name) + } root.isChecked = param.value val observer = object : DefaultObserver() { @@ -327,7 +330,11 @@ class TemplateWidgetViewProviderImpl : ITemplateWidgetViewProvider { root: TextInputLayout, crossinline onTextChanged: (String) -> Unit ) { - root.setHint(name) + if (nameStr != null) { + root.hint = nameStr + } else { + root.setHint(name) + } resetStartAndEndIcons(context, root) root.editText!!.addTextChangedListener(object : SingleTextWatcher() { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipJson.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipJson.kt new file mode 100644 index 0000000000..f7b1bd25a5 --- /dev/null +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipJson.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.templates.impl.zip + + +data class TemplatesIndex(val templates: List) +data class TemplateRef(val path: String, val experimental: Boolean = false,) + +data class TemplateJson( + val name: String, + val description: String?, + val version: String?, + val tooltipTag: String = "", + val parameters: ParametersJson? = null, + val system: SystemParametersJson? = null +) + +data class ParametersJson( + val required: RequiredParametersJson? = null, + val optional: OptionalParametersJson? = null, + val user: UserParametersJson? = null +) + +data class RequiredParametersJson( + val appName: IdentifierJson, + val packageName: IdentifierJson, + val saveLocation: IdentifierJson +) + +data class OptionalParametersJson( + val language: IdentifierJson? = null, + val minsdk: IdentifierJson? = null +) + +data class SystemParametersJson( + val agpVersion: IdentifierJson, + val kotlinVersion: IdentifierJson, + val gradleVersion: IdentifierJson, + val compileSdk: IdentifierJson, + val targetSdk: IdentifierJson, + val javaSourceCompat: IdentifierJson, + val javaTargetCompat: IdentifierJson, + val javaTarget: IdentifierJson +) + +data class IdentifierJson( + val identifier: String +) + +data class UserParametersJson( + val text: List = emptyList(), + val checkbox: List = emptyList() +) + +data class TextParameterJson( + val label: String, + val identifier: String, + val default: String? = null +) + +data class CheckboxParameterJson( + val label: String, + val identifier: String, + val default: Boolean? = null +) + diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt new file mode 100644 index 0000000000..a7975e7f81 --- /dev/null +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -0,0 +1,269 @@ +package com.itsaky.androidide.templates.impl.zip + +import com.itsaky.androidide.templates.Language +import java.io.File +import java.io.StringWriter +import java.util.zip.ZipFile + +import org.slf4j.LoggerFactory +import io.pebbletemplates.pebble.PebbleEngine +import io.pebbletemplates.pebble.loader.StringLoader +import io.pebbletemplates.pebble.lexer.Syntax + +import com.itsaky.androidide.templates.ModuleTemplateData +import com.itsaky.androidide.templates.Parameter +import com.itsaky.androidide.templates.ProjectTemplateData +import com.itsaky.androidide.templates.ProjectTemplateRecipeResult +import com.itsaky.androidide.templates.RecipeExecutor +import com.itsaky.androidide.templates.TemplateRecipe +import com.itsaky.androidide.templates.impl.base.ProjectTemplateRecipeResultImpl +import com.itsaky.androidide.utils.Environment + +import org.adfa.constants.ANDROID_GRADLE_PLUGIN_VERSION +import org.adfa.constants.KOTLIN_VERSION +import org.adfa.constants.Sdk + +class ZipRecipeExecutor( + private val zipProvider: () -> ZipFile, + private val metaJson: TemplateJson, + private val params: MutableMap>, + private val basePath: String, + private val data: ProjectTemplateData, + private val defModule: ModuleTemplateData, +) : TemplateRecipe { + + companion object { + private val log = LoggerFactory.getLogger(ZipRecipeExecutor::class.java) + } + + override fun execute( + executor: RecipeExecutor + ): ProjectTemplateRecipeResult { + + log.debug("executor called!!") + + val projectDir = data.projectDir + if (projectDir.exists()) { + return ProjectTemplateRecipeResultImpl(data) + } + + val projectRoot = projectDir.canonicalFile + + val flags: Map = + params.mapNotNull { (identifier, param) -> + (param.value as? Boolean)?.let { identifier to it } + }.toMap() + + zipProvider().use { zip -> + + val customSyntax = Syntax.Builder() + .setPrintOpenDelimiter(DELIM_PRINT_OPEN) + .setPrintCloseDelimiter(DELIM_PRINT_CLOSE) + .setExecuteOpenDelimiter(DELIM_EXECUTE_OPEN) + .setExecuteCloseDelimiter(DELIM_EXECUTE_CLOSE) + .setCommentOpenDelimiter(DELIM_COMMENT_OPEN) + .setCommentCloseDelimiter(DELIM_COMMENT_CLOSE) + .build() + + val pebbleEngine = PebbleEngine.Builder() + .loader(StringLoader()) + .syntax(customSyntax) + .build() + + val (identifiers, warnings) = metaJson.pebbleParams(data, defModule, params) + log.debug("identifiers warnings: ${warnings.joinToString(System.lineSeparator())}") + + val packageName = + resolveString(metaJson.parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) + + for (entry in zip.entries()) { + if (!entry.name.startsWith("$basePath/")) continue + if (entry.name == "$basePath/") continue + if (entry.name.startsWith("$basePath/$META_FOLDER/")) continue + + if ((metaJson.parameters?.optional?.language != null) && + (data.language != null) && + shouldSkipFile( + entry.name.removeSuffix(TEMPLATE_EXTENSION), + safeLanguageName(data.language) + ) + ) continue + + val normalized = filterAndNormalizeZipEntry(entry.name, flags) ?: continue + + val relativePath = normalized.removePrefix("$basePath/") + .replace(packageName.value, defModule.packageName.replace(".", "/")) + + val outFile = File(projectDir, relativePath.removeSuffix(TEMPLATE_EXTENSION)).canonicalFile + + if (!outFile.toPath().startsWith(projectRoot.toPath())) { + log.warn("Skipping suspicious ZIP entry outside project dir: {}", entry.name) + continue + } + + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + + if (entry.name.endsWith(TEMPLATE_EXTENSION)) { + log.debug("template processing ${entry.name}") + val content = zip.getInputStream(entry).bufferedReader().use { it.readText() } + val template = pebbleEngine.getTemplate(content) + val writer = StringWriter() + template.evaluate(writer, identifiers) + outFile.writeText(writer.toString(), Charsets.UTF_8) + } else { + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } + } + } + + keystore(executor) + + return ProjectTemplateRecipeResultImpl(data) + } + + private fun keystore(executor: RecipeExecutor) { + val storeSrc = Environment.KEYSTORE_RELEASE + val storeDest = File(data.projectDir, Environment.KEYSTORE_RELEASE_NAME) + if (storeSrc.exists()) { + executor.copy(storeSrc, storeDest) + } + + + val propsSrc = Environment.KEYSTORE_PROPERTIES + val propsDest = File(data.projectDir, Environment.KEYSTORE_PROPERTIES_NAME) + if (propsSrc.exists()) { + executor.copy(propsSrc, propsDest) + } + } + + private fun shouldSkipFile(name: String, language: String): Boolean { + // If language is Kotlin, skip .java files + // If language is Java, skip .kt files + val ext = name.substringAfterLast('.', "").lowercase() + return when (language.lowercase()) { + LANGUAGE_KOTLIN -> ext == FILE_EXT_JAVA + LANGUAGE_JAVA -> ext == FILE_EXT_KOTLIN + else -> false + } + } + + private fun safeLanguageName(language: Language?): String = + language?.name?.lowercase() ?: "" + + private fun safeMinSdkApi(minSdk: Sdk?): String = + minSdk?.api?.toString() ?: "" + + private fun TemplateJson.pebbleParams( + data: ProjectTemplateData, + defModule: ModuleTemplateData, + params: MutableMap> + ): Pair, List> { + + val warnings = mutableListOf() + + val appName = resolveString(parameters?.required?.appName?.identifier, KEY_APP_NAME) + if (appName.usedDefault) warnings += "Missing 'appName', defaulted to $KEY_APP_NAME" + + val packageName = resolveString(parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) + if (packageName.usedDefault) warnings += "Missing 'packageName', defaulted to $KEY_PACKAGE_NAME" + + val saveLocation = resolveString(parameters?.required?.saveLocation?.identifier, KEY_SAVE_LOCATION) + if (saveLocation.usedDefault) warnings += "Missing 'saveLocation', defaulted to $KEY_SAVE_LOCATION" + + val language = resolveString(parameters?.optional?.language?.identifier, KEY_LANGUAGE) + if (language.usedDefault) warnings += "Missing 'language', defaulted to $KEY_LANGUAGE" + + val minSdk = resolveString(parameters?.optional?.minsdk?.identifier, KEY_MIN_SDK) + if (minSdk.usedDefault) warnings += "Missing 'minsdk', defaulted to $KEY_MIN_SDK" + + val agpVersion = resolveString(system?.agpVersion?.identifier, KEY_AGP_VERSION) + if (agpVersion.usedDefault) warnings += "Missing 'agpVersion', defaulted to $KEY_AGP_VERSION" + + val kotlinVersion = resolveString(system?.kotlinVersion?.identifier, KEY_KOTLIN_VERSION) + if (kotlinVersion.usedDefault) warnings += "Missing 'kotlinVersion', defaulted to $KEY_KOTLIN_VERSION" + + val gradleVersion = resolveString(system?.gradleVersion?.identifier, KEY_GRADLE_VERSION) + if (gradleVersion.usedDefault) warnings += "Missing 'gradleVersion', defaulted to $KEY_GRADLE_VERSION" + + val compileSdk = resolveString(system?.compileSdk?.identifier, KEY_COMPILE_SDK) + if (compileSdk.usedDefault) warnings += "Missing 'compileSdk', defaulted to $KEY_COMPILE_SDK" + + val targetSdk = resolveString(system?.targetSdk?.identifier, KEY_TARGET_SDK) + if (targetSdk.usedDefault) warnings += "Missing 'targetSdk', defaulted to $KEY_TARGET_SDK" + + val javaSourceCompat = resolveString(system?.javaSourceCompat?.identifier, KEY_JAVA_SOURCE_COMPAT) + if (javaSourceCompat.usedDefault) warnings += "Missing 'javaSourceCompat', defaulted to $KEY_JAVA_SOURCE_COMPAT" + + val javaTargetCompat = resolveString(system?.javaTargetCompat?.identifier, KEY_JAVA_TARGET_COMPAT) + if (javaTargetCompat.usedDefault) warnings += "Missing 'javaTargetCompat', defaulted to $KEY_JAVA_TARGET_COMPAT" + + val javaTarget = resolveString(system?.javaTarget?.identifier, KEY_JAVA_TARGET) + if (javaTarget.usedDefault) warnings += "Missing 'javaTarget', defaulted to $KEY_JAVA_TARGET" + + val baseMap = mapOf( + appName.value to data.name, + packageName.value to defModule.packageName, + saveLocation.value to data.projectDir.toString(), + language.value to safeLanguageName(data.language), + minSdk.value to safeMinSdkApi(defModule.versions.minSdk), + agpVersion.value to ANDROID_GRADLE_PLUGIN_VERSION, + kotlinVersion.value to KOTLIN_VERSION, + gradleVersion.value to data.version.gradle, + compileSdk.value to defModule.versions.compileSdk.api.toString(), + targetSdk.value to defModule.versions.targetSdk.api.toString(), + javaSourceCompat.value to defModule.versions.javaSource(), + javaTargetCompat.value to defModule.versions.javaTarget(), + javaTarget.value to defModule.versions.javaTarget + ) + + val map = baseMap + params.mapValues { (_, param) -> + param.value ?: "" + } + + return map to warnings + } + + data class ResolvedParam( + val value: T, + val usedDefault: Boolean + ) + + private fun resolveString(value: String?, default: String): ResolvedParam { + return if (value.isNullOrBlank()) ResolvedParam(default, true) + else ResolvedParam(value, false) + } + + private fun resolveBoolean(raw: Boolean?, default: Boolean): ResolvedParam { + return if (raw == null) ResolvedParam(default, true) + else ResolvedParam(raw, false) + } + + private fun filterAndNormalizeZipEntry( + entryName: String, + flags: Map + ): String? { + val parts = entryName.split(File.separator).filter { it.isNotEmpty() } + if (parts.isEmpty()) return null + + val normalizedParts = mutableListOf() + + for (part in parts) { + when (flags[part]) { + null -> normalizedParts.add(part) + true -> { } + false -> return null + } + } + + return normalizedParts.joinToString(File.separator) + } + +} diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateConstants.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateConstants.kt new file mode 100644 index 0000000000..0afb7c77d8 --- /dev/null +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateConstants.kt @@ -0,0 +1,36 @@ +package com.itsaky.androidide.templates.impl.zip + +const val ARCHIVE_JSON = "templates.json" + +const val META_FOLDER = "template" +const val META_JSON = "template.json" +const val META_THUMBNAIL = "thumb.png" + +const val TEMPLATE_EXTENSION = ".peb" + +const val DELIM_PRINT_OPEN = "\${{" +const val DELIM_PRINT_CLOSE = "}}" +const val DELIM_EXECUTE_OPEN = "\${%" +const val DELIM_EXECUTE_CLOSE = "%}" +const val DELIM_COMMENT_OPEN = "\${#" +const val DELIM_COMMENT_CLOSE = "#}" + +const val KEY_PACKAGE_NAME = "PACKAGE_NAME" +const val KEY_APP_NAME = "APP_NAME" +const val KEY_SAVE_LOCATION = "SAVE_LOCATION" +const val KEY_AGP_VERSION = "AGP_VERSION" +const val KEY_KOTLIN_VERSION = "KOTLIN_VERSION" +const val KEY_GRADLE_VERSION = "GRADLE_VERSION" +const val KEY_LANGUAGE = "LANGUAGE" +const val KEY_COMPILE_SDK = "COMPILE_SDK" +const val KEY_MIN_SDK = "MIN_SDK" +const val KEY_TARGET_SDK = "TARGET_SDK" +const val KEY_JAVA_SOURCE_COMPAT = "JAVA_SOURCE_COMPAT" +const val KEY_JAVA_TARGET_COMPAT = "JAVA_TARGET_COMPAT" +const val KEY_JAVA_TARGET = "JAVA_TARGET" + +const val LANGUAGE_KOTLIN = "kotlin" +const val LANGUAGE_JAVA = "java" + +const val FILE_EXT_KOTLIN = "kt" +const val FILE_EXT_JAVA = "java" diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateReader.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateReader.kt new file mode 100644 index 0000000000..8cfab63bdb --- /dev/null +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipTemplateReader.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.templates.impl.zip + +import com.google.gson.Gson +import com.itsaky.androidide.templates.CheckBoxWidget +import com.itsaky.androidide.templates.ModuleTemplateData +import com.itsaky.androidide.templates.Parameter +import com.itsaky.androidide.templates.ProjectTemplate +import com.itsaky.androidide.templates.ProjectTemplateData +import com.itsaky.androidide.templates.ProjectTemplateRecipeResult +import com.itsaky.androidide.templates.R +import com.itsaky.androidide.templates.TemplateRecipe +import com.itsaky.androidide.templates.TextFieldWidget +import com.itsaky.androidide.templates.Widget +import com.itsaky.androidide.templates.base.baseZipProject +import com.itsaky.androidide.templates.booleanParameter +import com.itsaky.androidide.templates.stringParameter +import org.slf4j.LoggerFactory +import java.io.File +import java.util.zip.ZipFile + +object ZipTemplateReader { + private val log = LoggerFactory.getLogger(ZipTemplateReader::class.java) + + private val gson = Gson() + + fun read( + zipFile: File, + recipeFactory: (TemplateJson, MutableMap>, String, ProjectTemplateData, ModuleTemplateData) -> TemplateRecipe + ): List { + + val templates = mutableListOf() + + try { + ZipFile(zipFile).use { zip -> + + log.debug("zipFile: $zipFile") + + val indexEntry = zip.getEntry(ARCHIVE_JSON) ?: return emptyList() + val indexJson = zip.getInputStream(indexEntry).bufferedReader().use { + gson.fromJson(it, TemplatesIndex::class.java) + } + + log.debug("indexJson: $indexJson") + for (templateRef in indexJson.templates) { + try { + val basePath = templateRef.path + log.debug("basePath: $basePath") + val metaEntry = zip.getEntry("$basePath/$META_FOLDER/$META_JSON") ?: continue + + val metaJsonString = zip.getInputStream(metaEntry).bufferedReader().use { reader -> + reader.readText() + } + + val metaJson = gson.fromJson(metaJsonString, TemplateJson::class.java) + + log.debug("metaJson: $metaJson") + + val thumbEntry = zip.getEntry("$basePath/$META_FOLDER/$META_THUMBNAIL") + val thumbData = thumbEntry?.let { zip.getInputStream(it).use { s -> s.readBytes() } } + + if (thumbData == null) log.error("template $basePath/$META_FOLDER/$META_THUMBNAIL not found or is invalid") + log.debug("thumbData: $thumbData") + + val userWidgets = mutableListOf>() + val params = mutableMapOf>() + + metaJson.parameters?.user?.text?.forEach { textParam -> + val param = stringParameter { + name = 0 + nameStr = textParam.label ?: "" + default = textParam.default ?: "" + } + userWidgets.add(TextFieldWidget(param)) + params[textParam.identifier] = param + } + + metaJson.parameters?.user?.checkbox?.forEach { checkboxParam -> + val param = booleanParameter { + name = 0 + nameStr = checkboxParam.label ?: "" + default = checkboxParam.default ?: false + } + userWidgets.add(CheckBoxWidget(param)) + params[checkboxParam.identifier] = param + } + + val project = baseZipProject( + showLanguage = (metaJson.parameters?.optional?.language != null), + showMinSdk = (metaJson.parameters?.optional?.minsdk != null) + ) { + + this.templateNameStr = metaJson.name + this.tooltipTag = metaJson.tooltipTag + this.thumbData = thumbData + + this.templateName = 0 + this.thumb = R.drawable.template_no_activity + + for (widget in userWidgets) { + widgets(widget) + } + + log.debug("this.name: ${this.templateNameStr}") + this.recipe = TemplateRecipe { executor -> + val innerRecipe = recipeFactory(metaJson, params, basePath, data, defModule) + innerRecipe.execute(executor) + } + } + + log.debug("adding project ${metaJson.name}") + templates.add(project) + } catch (e: Exception) { + log.error("Failed to load template at ${templateRef.path}", e) + } + } + } + } catch (e: Exception) { + log.error("Failed to read zip file $zipFile", e) + } + + return templates + } +}