You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
10 KiB
288 lines
10 KiB
package org.jetbrains.compose.resources |
|
|
|
import org.gradle.api.Project |
|
import org.gradle.api.file.DirectoryProperty |
|
import org.gradle.api.file.FileSystemOperations |
|
import org.gradle.api.file.FileTree |
|
import org.gradle.api.provider.Property |
|
import org.gradle.api.provider.Provider |
|
import org.gradle.api.tasks.IgnoreEmptyDirectories |
|
import org.gradle.api.tasks.Input |
|
import org.gradle.api.tasks.InputFiles |
|
import org.gradle.api.tasks.Internal |
|
import org.gradle.api.tasks.OutputDirectory |
|
import org.gradle.api.tasks.OutputFiles |
|
import org.gradle.api.tasks.SkipWhenEmpty |
|
import org.gradle.api.tasks.TaskProvider |
|
import org.jetbrains.compose.internal.utils.uppercaseFirstChar |
|
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet |
|
import org.w3c.dom.Node |
|
import org.xml.sax.SAXParseException |
|
import java.io.File |
|
import java.util.* |
|
import javax.inject.Inject |
|
import javax.xml.parsers.DocumentBuilderFactory |
|
|
|
internal fun Project.registerPrepareComposeResourcesTask( |
|
sourceSet: KotlinSourceSet |
|
): TaskProvider<PrepareComposeResourcesTask> { |
|
val resDir = "${sourceSet.name}/$COMPOSE_RESOURCES_DIR" |
|
val userComposeResourcesDir = project.projectDir.resolve("src/$resDir") |
|
val preparedComposeResourcesDir = layout.buildDirectory.dir("$RES_GEN_DIR/preparedResources/$resDir") |
|
|
|
val convertXmlValueResources = tasks.register( |
|
"convertXmlValueResourcesFor${sourceSet.name.uppercaseFirstChar()}", |
|
XmlValuesConverterTask::class.java |
|
) { task -> |
|
task.fileSuffix.set(sourceSet.name) |
|
task.originalResourcesDir.set(userComposeResourcesDir) |
|
task.outputDir.set(preparedComposeResourcesDir) |
|
} |
|
|
|
val copyNonXmlValueResources = tasks.register( |
|
"copyNonXmlValueResourcesFor${sourceSet.name.uppercaseFirstChar()}", |
|
CopyNonXmlValueResourcesTask::class.java |
|
) { task -> |
|
task.originalResourcesDir.set(userComposeResourcesDir) |
|
task.outputDir.set(preparedComposeResourcesDir) |
|
} |
|
|
|
val prepareComposeResourcesTask = tasks.register( |
|
getPrepareComposeResourcesTaskName(sourceSet), |
|
PrepareComposeResourcesTask::class.java |
|
) { task -> |
|
task.convertedXmls.set(convertXmlValueResources.map { it.realOutputFiles.get() }) |
|
task.copiedNonXmls.set(copyNonXmlValueResources.map { it.realOutputFiles.get() }) |
|
task.outputDir.set(preparedComposeResourcesDir) |
|
} |
|
|
|
return prepareComposeResourcesTask |
|
} |
|
|
|
internal fun Project.getPreparedComposeResourcesDir(sourceSet: KotlinSourceSet): Provider<File> = tasks |
|
.named( |
|
getPrepareComposeResourcesTaskName(sourceSet), |
|
PrepareComposeResourcesTask::class.java |
|
) |
|
.flatMap { it.outputDir.asFile } |
|
|
|
private fun getPrepareComposeResourcesTaskName(sourceSet: KotlinSourceSet) = |
|
"prepareComposeResourcesTaskFor${sourceSet.name.uppercaseFirstChar()}" |
|
|
|
internal abstract class CopyNonXmlValueResourcesTask : IdeaImportTask() { |
|
@get:Inject |
|
abstract val fileSystem: FileSystemOperations |
|
|
|
@get:Internal |
|
abstract val originalResourcesDir: DirectoryProperty |
|
|
|
@get:InputFiles |
|
@get:SkipWhenEmpty |
|
@get:IgnoreEmptyDirectories |
|
val realInputFiles = originalResourcesDir.map { dir -> |
|
dir.asFileTree.matching { it.exclude("values*/*.xml") } |
|
} |
|
|
|
@get:Internal |
|
abstract val outputDir: DirectoryProperty |
|
|
|
@get:OutputFiles |
|
val realOutputFiles = outputDir.map { dir -> |
|
dir.asFileTree.matching { it.exclude("values*/*.${XmlValuesConverterTask.CONVERTED_RESOURCE_EXT}") } |
|
} |
|
|
|
override fun safeAction() { |
|
realOutputFiles.get().forEach { f -> f.delete() } |
|
fileSystem.copy { copy -> |
|
copy.includeEmptyDirs = false |
|
copy.from(originalResourcesDir) { |
|
it.exclude("values*/*.xml") |
|
} |
|
copy.into(outputDir) |
|
} |
|
} |
|
} |
|
|
|
internal abstract class PrepareComposeResourcesTask : IdeaImportTask() { |
|
@get:InputFiles |
|
@get:SkipWhenEmpty |
|
@get:IgnoreEmptyDirectories |
|
abstract val convertedXmls: Property<FileTree> |
|
|
|
@get:InputFiles |
|
@get:SkipWhenEmpty |
|
@get:IgnoreEmptyDirectories |
|
abstract val copiedNonXmls: Property<FileTree> |
|
|
|
@get:OutputDirectory |
|
abstract val outputDir: DirectoryProperty |
|
|
|
override fun safeAction() = Unit |
|
} |
|
|
|
internal data class ValueResourceRecord( |
|
val type: ResourceType, |
|
val key: String, |
|
val content: String |
|
) { |
|
fun getAsString(): String { |
|
return listOf(type.typeName, key, content).joinToString(SEPARATOR) |
|
} |
|
|
|
companion object { |
|
private const val SEPARATOR = "|" |
|
fun createFromString(string: String): ValueResourceRecord { |
|
val parts = string.split(SEPARATOR) |
|
return ValueResourceRecord( |
|
ResourceType.fromString(parts[0])!!, |
|
parts[1], |
|
parts[2] |
|
) |
|
} |
|
} |
|
} |
|
|
|
internal abstract class XmlValuesConverterTask : IdeaImportTask() { |
|
companion object { |
|
const val CONVERTED_RESOURCE_EXT = "cvr" //Compose Value Resource |
|
private const val FORMAT_VERSION = 0 |
|
} |
|
|
|
@get:Input |
|
abstract val fileSuffix: Property<String> |
|
|
|
@get:Internal |
|
abstract val originalResourcesDir: DirectoryProperty |
|
|
|
@get:InputFiles |
|
@get:SkipWhenEmpty |
|
@get:IgnoreEmptyDirectories |
|
val realInputFiles = originalResourcesDir.map { dir -> |
|
dir.asFileTree.matching { it.include("values*/*.xml") } |
|
} |
|
|
|
@get:Internal |
|
abstract val outputDir: DirectoryProperty |
|
|
|
@get:OutputFiles |
|
val realOutputFiles = outputDir.map { dir -> |
|
val suffix = fileSuffix.get() |
|
dir.asFileTree.matching { it.include("values*/*.$suffix.$CONVERTED_RESOURCE_EXT") } |
|
} |
|
|
|
override fun safeAction() { |
|
val outDir = outputDir.get().asFile |
|
val suffix = fileSuffix.get() |
|
realOutputFiles.get().forEach { f -> f.delete() } |
|
originalResourcesDir.get().asFile.listNotHiddenFiles().forEach { valuesDir -> |
|
if (valuesDir.isDirectory && valuesDir.name.startsWith("values")) { |
|
valuesDir.listNotHiddenFiles().forEach { f -> |
|
if (f.extension.equals("xml", true)) { |
|
val output = outDir |
|
.resolve(f.parentFile.name) |
|
.resolve(f.nameWithoutExtension + ".$suffix.$CONVERTED_RESOURCE_EXT") |
|
output.parentFile.mkdirs() |
|
try { |
|
convert(f, output) |
|
} catch (e: SAXParseException) { |
|
error("XML file ${f.absolutePath} is not valid. Check the file content.") |
|
} catch (e: Exception) { |
|
error("XML file ${f.absolutePath} is not valid. ${e.message}") |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
private fun convert(original: File, converted: File) { |
|
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(original) |
|
val items = doc.getElementsByTagName("resources").item(0).childNodes |
|
val records = List(items.length) { items.item(it) } |
|
.filter { it.hasAttributes() } |
|
.map { getItemRecord(it) } |
|
|
|
//check there are no duplicates type + key |
|
records.groupBy { it.key } |
|
.filter { it.value.size > 1 } |
|
.forEach { (key, records) -> |
|
val allTypes = records.map { it.type } |
|
require(allTypes.size == allTypes.toSet().size) { "Duplicated key '$key'." } |
|
} |
|
|
|
val fileContent = buildString { |
|
appendLine("version:$FORMAT_VERSION") |
|
records.map { it.getAsString() }.sorted().forEach { appendLine(it) } |
|
} |
|
converted.writeText(fileContent) |
|
} |
|
|
|
private fun getItemRecord(node: Node): ValueResourceRecord { |
|
val type = ResourceType.fromString(node.nodeName) ?: error("Unknown resource type: '${node.nodeName}'.") |
|
val key = node.attributes.getNamedItem("name")?.nodeValue ?: error("Attribute 'name' not found.") |
|
val value: String |
|
when (type) { |
|
ResourceType.STRING -> { |
|
val content = handleSpecialCharacters(node.textContent) |
|
value = content.asBase64() |
|
} |
|
|
|
ResourceType.STRING_ARRAY -> { |
|
val children = node.childNodes |
|
value = List(children.length) { children.item(it) } |
|
.filter { it.nodeName == "item" } |
|
.joinToString(",") { child -> |
|
val content = handleSpecialCharacters(child.textContent) |
|
content.asBase64() |
|
} |
|
} |
|
|
|
ResourceType.PLURAL_STRING -> { |
|
val children = node.childNodes |
|
value = List(children.length) { children.item(it) } |
|
.filter { it.nodeName == "item" } |
|
.joinToString(",") { child -> |
|
val content = handleSpecialCharacters(child.textContent) |
|
val quantity = child.attributes.getNamedItem("quantity").nodeValue |
|
quantity.uppercase() + ":" + content.asBase64() |
|
} |
|
} |
|
|
|
else -> error("Unknown string resource type: '$type'.") |
|
} |
|
return ValueResourceRecord(type, key, value) |
|
} |
|
|
|
private fun String.asBase64() = |
|
Base64.getEncoder().encode(this.encodeToByteArray()).decodeToString() |
|
} |
|
|
|
//https://developer.android.com/guide/topics/resources/string-resource#escaping_quotes |
|
/** |
|
* Replaces |
|
* |
|
* '\n' -> new line |
|
* |
|
* '\t' -> tab |
|
* |
|
* '\uXXXX' -> unicode symbol |
|
* |
|
* '\\' -> '\' |
|
* |
|
* @param string The input string to handle. |
|
* @return The string with special characters replaced according to the logic. |
|
*/ |
|
internal fun handleSpecialCharacters(string: String): String { |
|
val unicodeNewLineTabRegex = Regex("""\\u[a-fA-F\d]{4}|\\n|\\t""") |
|
val doubleSlashRegex = Regex("""\\\\""") |
|
val doubleSlashIndexes = doubleSlashRegex.findAll(string).map { it.range.first } |
|
val handledString = unicodeNewLineTabRegex.replace(string) { matchResult -> |
|
if (doubleSlashIndexes.contains(matchResult.range.first - 1)) matchResult.value |
|
else when (matchResult.value) { |
|
"\\n" -> "\n" |
|
"\\t" -> "\t" |
|
else -> matchResult.value.substring(2).toInt(16).toChar().toString() |
|
} |
|
}.replace("""\\""", """\""") |
|
return handledString |
|
} |