diff --git a/resources/src/main/res/values-in-rID/strings.xml b/resources/src/main/res/values-in-rID/strings.xml
index ac217e0794..c941297363 100644
--- a/resources/src/main/res/values-in-rID/strings.xml
+++ b/resources/src/main/res/values-in-rID/strings.xml
@@ -1165,6 +1165,8 @@
Gagal menulis file output: %1$s %2$s
Gagal menyalin entri biner: %1$s %2$s
Gagal memproses entri template: %1$s %2$s
+ Pembuatan proyek dihentikan: %1$s\n\nProyek yang belum selesai telah dihapus. Perbaiki file template yang disebutkan di atas (atau pilih template lain), lalu coba lagi.
+ Tidak dapat menghapus proyek yang belum selesai di %1$s. Hapus secara manual sebelum mencoba lagi.
appName tidak ditemukan, menggunakan default %1$s
packageName tidak ditemukan, menggunakan default %1$s
diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index 059e605be8..76ab541502 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -1348,6 +1348,8 @@
Failed writing output file: %1$s %2$s
Failed copying binary entry: %1$s %2$s
Failed to process template entry: %1$s %2$s
+ Project creation stopped: %1$s\n\nThe incomplete project was removed. Fix the template file mentioned above (or choose a different template) and try again.
+ Could not remove the incomplete project at %1$s. Delete it manually before trying again.
Missing appName, defaulted to %1$s
Missing packageName, defaulted to %1$s
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
index 519e52d73f..4f57d64655 100644
--- 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
@@ -3,19 +3,6 @@ package com.itsaky.androidide.templates.impl.zip
import android.annotation.SuppressLint
import android.content.Context
import androidx.annotation.StringRes
-import dalvik.system.DexClassLoader
-
-import java.io.File
-import java.io.StringWriter
-import java.util.zip.ZipFile
-import java.util.ServiceLoader
-
-import io.pebbletemplates.pebble.PebbleEngine
-import io.pebbletemplates.pebble.loader.StringLoader
-import io.pebbletemplates.pebble.lexer.Syntax
-import io.pebbletemplates.pebble.error.PebbleException
-import io.pebbletemplates.pebble.extension.Extension
-
import com.itsaky.androidide.templates.Language
import com.itsaky.androidide.templates.ModuleTemplateData
import com.itsaky.androidide.templates.Parameter
@@ -26,468 +13,634 @@ import com.itsaky.androidide.templates.TemplateRecipe
import com.itsaky.androidide.templates.impl.R
import com.itsaky.androidide.templates.impl.base.ProjectTemplateRecipeResultImpl
import com.itsaky.androidide.utils.Environment
-
-import org.slf4j.LoggerFactory
+import dalvik.system.DexClassLoader
+import io.pebbletemplates.pebble.PebbleEngine
+import io.pebbletemplates.pebble.error.PebbleException
+import io.pebbletemplates.pebble.extension.Extension
+import io.pebbletemplates.pebble.lexer.Syntax
+import io.pebbletemplates.pebble.loader.StringLoader
import org.adfa.constants.ANDROID_GRADLE_PLUGIN_VERSION
import org.adfa.constants.KOTLIN_VERSION
import org.adfa.constants.Sdk
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.io.StringWriter
+import java.util.ServiceLoader
import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Fatal template error: execution terminates, the partially created project
+ * directory is removed and project creation is reported as failed.
+ */
+class TemplateExecutionException(
+ message: String,
+ cause: Throwable?,
+) : RuntimeException(message, cause)
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,
+ 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 {
-
- var hasErrorsWarnings: Boolean = false
-
- companion object {
- private val log = LoggerFactory.getLogger(ZipRecipeExecutor::class.java)
- private val CLASS_NAME_PATTERN = Regex("[^a-zA-Z0-9]")
- }
-
- override fun execute(
- executor: RecipeExecutor
- ): ProjectTemplateRecipeResult {
- val ctx = requireNotNull(executor.context) { "context null" }
-
- info(ctx, R.string.template_exec_info_basepath, basePath)
-
- val projectDir = data.projectDir
- if (projectDir.exists()) {
- return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings)
- }
-
- 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 builder = PebbleEngine.Builder()
-
- val extensionsEntry = zip.getEntry(META_EXTENSION_JAR)
- if (extensionsEntry != null) {
- val extensions = loadExtensionFromArchive(zip, extensionsEntry, ctx)
- for (ext in extensions) {
- builder.extension(ext)
- }
- }
-
- val pebbleEngine = builder.loader(StringLoader())
- .strictVariables(true)
- .syntax(customSyntax)
- .build()
-
-
- val className = data.name.replace(CLASS_NAME_PATTERN, "")
- val (baseIdentifiers, warnings) = metaJson.pebbleParams(ctx, data, defModule, params)
- val identifiers = baseIdentifiers + (KEY_CLASS_NAME to className)
-
- if (warnings.isNotEmpty()) {
- warn(ctx, R.string.template_exec_warn_identifiers, 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(".", "/"))
- .replace(KEY_CLASS_NAME, className)
-
- val outFile = File(projectDir, relativePath.removeSuffix(TEMPLATE_EXTENSION)).canonicalFile
-
- if (!outFile.toPath().startsWith(projectRoot.toPath())) {
- warn(ctx, R.string.template_exec_warn_suspicious_entry, entry.name)
- continue
- }
-
- if (entry.isDirectory) {
- outFile.mkdirs()
- } else {
- try {
- outFile.parentFile?.mkdirs()
-
- if (entry.name.endsWith(TEMPLATE_EXTENSION)) {
- info(ctx, R.string.template_exec_info_processing, entry.name)
- val content = try {
- zip.getInputStream(entry).bufferedReader().use { it.readText() }
- } catch (e: Exception) {
- throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name)
- }
-
- val template = try {
- pebbleEngine.getTemplate(content)
- } catch (e: PebbleException) {
- throw e.wrap(
- ctx, R.string.template_exec_error_parse_line,
- entry.name, e.lineNumber, e.message
- )
- } catch (e: Exception) {
- throw e.wrap(ctx, R.string.template_exec_error_parse, entry.name)
- }
-
- val writer = StringWriter()
- val rendered = try {
- template.evaluate(writer, identifiers)
- } catch (e: PebbleException) {
- error(
- ctx, R.string.template_exec_error_evaluate_line,
- entry.name, e.lineNumber, e.message
- )
- null
- } catch (e: Exception) {
- error(
- ctx, R.string.template_exec_error_evaluate,
- entry.name, e.toString()
- )
- null
- }
- if (rendered == null) continue
-
- try {
- outFile.writeText(writer.toString(), Charsets.UTF_8)
- } catch (e: Exception) {
- error(
- ctx, R.string.template_exec_error_write,
- outFile.absolutePath, e.toString()
- )
- }
-
- } else {
- try {
- zip.getInputStream(entry).use { input ->
- outFile.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- } catch (e: Exception) {
- error(
- ctx, R.string.template_exec_error_write,
- entry.name, e.toString()
- )
- }
- }
- } catch (e: Exception) {
- error(
- ctx, R.string.template_exec_error_process,
- entry.name, e.toString()
- )
- }
- }
- }
- }
-
- keystore(executor)
-
- return ProjectTemplateRecipeResultImpl(data, hasErrorsWarnings)
- }
-
- 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(
- ctx: Context,
- data: ProjectTemplateData,
- defModule: ModuleTemplateData,
- params: MutableMap>
- ): Pair