Browse Source
Users noticed if an app has big a `string.xml` file it affects the app startup time: https://github.com/JetBrains/compose-multiplatform/issues/4537 The problem is slow XML parsing. Possible ways for optimization: 1) inject text resources direct to the source code 2) convert XMLs to an optimized format to read it faster We selected the second way because texts injected to source code have several problems: - strict limitations on text size - increase compilation and analysation time - affects a class loader and GC > Note: android resources do the same and converts xml values to own `resources.arsc` file Things was done in the PR: 1) added support any XML files in the `values` directory 2) **[BREAKING CHANGE]** added `Res.array` accessor for string-array resources 3) in a final app there won't be original `values*/*.xml` files. There will be converted `values*/*.cvr` files. 4) generated code points on string resources as file -> offset+size 5) string resource cache is by item now (it was by the full xml file before) 6) implemented random access to read CVR files 7) tasks for syncing ios resources to a final app were seriously refactored to support generated resources (CVR files) 8) restriction for 3-party resources plugin were deleted 9) Gradle property `compose.resources.always.generate.accessors` was deleted. It was for internal needs only. Fixes https://github.com/JetBrains/compose-multiplatform/issues/4537pull/4577/head v1.6.10-dev1559
Konstantin
8 months ago
committed by
GitHub
75 changed files with 1747 additions and 1808 deletions
@ -1 +0,0 @@
|
||||
compose.resources.always.generate.accessors=true |
@ -1,18 +1,33 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import java.io.File |
||||
import java.io.InputStream |
||||
|
||||
private object AndroidResourceReader |
||||
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader { |
||||
override suspend fun read(path: String): ByteArray { |
||||
val resource = getResourceAsStream(path) |
||||
return resource.readBytes() |
||||
} |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
@InternalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: AndroidResourceReader.javaClass.classLoader |
||||
val resource = classLoader.getResourceAsStream(path) ?: run { |
||||
//try to find a font in the android assets |
||||
if (File(path).parentFile?.name.orEmpty().startsWith("font")) { |
||||
classLoader.getResourceAsStream("assets/$path") |
||||
} else null |
||||
} ?: throw MissingResourceException(path) |
||||
return resource.readBytes() |
||||
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray { |
||||
val resource = getResourceAsStream(path) |
||||
val result = ByteArray(size.toInt()) |
||||
resource.use { input -> |
||||
input.skip(offset) |
||||
input.read(result, 0, size.toInt()) |
||||
} |
||||
return result |
||||
} |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
private fun getResourceAsStream(path: String): InputStream { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: this.javaClass.classLoader |
||||
val resource = classLoader.getResourceAsStream(path) ?: run { |
||||
//try to find a font in the android assets |
||||
if (File(path).parentFile?.name.orEmpty().startsWith("font")) { |
||||
classLoader.getResourceAsStream("assets/$path") |
||||
} else null |
||||
} ?: throw MissingResourceException(path) |
||||
return resource |
||||
} |
||||
} |
@ -0,0 +1,121 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.* |
||||
import org.jetbrains.compose.resources.plural.PluralCategory |
||||
import org.jetbrains.compose.resources.plural.PluralRuleList |
||||
|
||||
/** |
||||
* Represents a quantity string resource in the application. |
||||
* |
||||
* @param id The unique identifier of the resource. |
||||
* @param key The key used to retrieve the string resource. |
||||
* @param items The set of resource items associated with the string resource. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Immutable |
||||
class PluralStringResource |
||||
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items) |
||||
|
||||
/** |
||||
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource. |
||||
* |
||||
* @param resource The quantity string resource to be used. |
||||
* @param quantity The quantity of the pluralization to use. |
||||
* @return The retrieved string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun pluralStringResource(resource: PluralStringResource, quantity: Int): String { |
||||
val resourceReader = LocalResourceReader.current |
||||
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env -> |
||||
loadPluralString(resource, quantity, resourceReader, env) |
||||
} |
||||
return pluralStr |
||||
} |
||||
|
||||
/** |
||||
* Loads a string using the specified string resource. |
||||
* |
||||
* @param resource The string resource to be used. |
||||
* @param quantity The quantity of the pluralization to use. |
||||
* @return The loaded string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String = |
||||
loadPluralString(resource, quantity, DefaultResourceReader, getResourceEnvironment()) |
||||
|
||||
@OptIn(InternalResourceApi::class, ExperimentalResourceApi::class) |
||||
private suspend fun loadPluralString( |
||||
resource: PluralStringResource, |
||||
quantity: Int, |
||||
resourceReader: ResourceReader, |
||||
environment: ResourceEnvironment |
||||
): String { |
||||
val resourceItem = resource.getResourceItemByEnvironment(environment) |
||||
val item = getStringItem(resourceItem, resourceReader) as StringItem.Plurals |
||||
val pluralRuleList = PluralRuleList.getInstance( |
||||
environment.language, |
||||
environment.region, |
||||
) |
||||
val pluralCategory = pluralRuleList.getCategory(quantity) |
||||
val str = item.items[pluralCategory] |
||||
?: item.items[PluralCategory.OTHER] |
||||
?: error("Quantity string ID=`${resource.key}` does not have the pluralization $pluralCategory for quantity $quantity!") |
||||
return str |
||||
} |
||||
|
||||
/** |
||||
* Retrieves the string for the pluralization for the given quantity using the specified quantity string resource. |
||||
* |
||||
* @param resource The quantity string resource to be used. |
||||
* @param quantity The quantity of the pluralization to use. |
||||
* @param formatArgs The arguments to be inserted into the formatted string. |
||||
* @return The retrieved string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun pluralStringResource(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String { |
||||
val resourceReader = LocalResourceReader.current |
||||
val args = formatArgs.map { it.toString() } |
||||
val pluralStr by rememberResourceState(resource, quantity, args, { "" }) { env -> |
||||
loadPluralString(resource, quantity, args, resourceReader, env) |
||||
} |
||||
return pluralStr |
||||
} |
||||
|
||||
/** |
||||
* Loads a string using the specified string resource. |
||||
* |
||||
* @param resource The string resource to be used. |
||||
* @param quantity The quantity of the pluralization to use. |
||||
* @param formatArgs The arguments to be inserted into the formatted string. |
||||
* @return The loaded string resource. |
||||
* |
||||
* @throws IllegalArgumentException If the provided ID or the pluralization is not found in the resource file. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String = |
||||
loadPluralString( |
||||
resource, quantity, |
||||
formatArgs.map { it.toString() }, |
||||
DefaultResourceReader, |
||||
getResourceEnvironment(), |
||||
) |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
private suspend fun loadPluralString( |
||||
resource: PluralStringResource, |
||||
quantity: Int, |
||||
args: List<String>, |
||||
resourceReader: ResourceReader, |
||||
environment: ResourceEnvironment |
||||
): String { |
||||
val str = loadPluralString(resource, quantity, resourceReader, environment) |
||||
return str.replaceWithArgs(args) |
||||
} |
@ -0,0 +1,63 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import androidx.compose.runtime.* |
||||
import kotlinx.coroutines.* |
||||
import kotlinx.coroutines.sync.Mutex |
||||
import kotlinx.coroutines.sync.withLock |
||||
import org.jetbrains.compose.resources.plural.PluralCategory |
||||
import org.jetbrains.compose.resources.plural.PluralRuleList |
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.NodeList |
||||
|
||||
/** |
||||
* Represents a string array resource in the application. |
||||
* |
||||
* @param id The unique identifier of the resource. |
||||
* @param key The key used to retrieve the string array resource. |
||||
* @param items The set of resource items associated with the string array resource. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Immutable |
||||
class StringArrayResource |
||||
@InternalResourceApi constructor(id: String, val key: String, items: Set<ResourceItem>) : Resource(id, items) |
||||
|
||||
/** |
||||
* Retrieves a list of strings using the specified string array resource. |
||||
* |
||||
* @param resource The string array resource to be used. |
||||
* @return A list of strings representing the items in the string array. |
||||
* |
||||
* @throws IllegalStateException if the string array with the given ID is not found. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
@Composable |
||||
fun stringArrayResource(resource: StringArrayResource): List<String> { |
||||
val resourceReader = LocalResourceReader.current |
||||
val array by rememberResourceState(resource, { emptyList() }) { env -> |
||||
loadStringArray(resource, resourceReader, env) |
||||
} |
||||
return array |
||||
} |
||||
|
||||
/** |
||||
* Loads a list of strings using the specified string array resource. |
||||
* |
||||
* @param resource The string array resource to be used. |
||||
* @return A list of strings representing the items in the string array. |
||||
* |
||||
* @throws IllegalStateException if the string array with the given ID is not found. |
||||
*/ |
||||
@ExperimentalResourceApi |
||||
suspend fun getStringArray(resource: StringArrayResource): List<String> = |
||||
loadStringArray(resource, DefaultResourceReader, getResourceEnvironment()) |
||||
|
||||
@OptIn(ExperimentalResourceApi::class, InternalResourceApi::class) |
||||
private suspend fun loadStringArray( |
||||
resource: StringArrayResource, |
||||
resourceReader: ResourceReader, |
||||
environment: ResourceEnvironment |
||||
): List<String> { |
||||
val resourceItem = resource.getResourceItemByEnvironment(environment) |
||||
val item = getStringItem(resourceItem, resourceReader) as StringItem.Array |
||||
return item.items |
||||
} |
@ -0,0 +1,76 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import kotlinx.coroutines.* |
||||
import kotlinx.coroutines.sync.Mutex |
||||
import kotlinx.coroutines.sync.withLock |
||||
import org.jetbrains.compose.resources.plural.PluralCategory |
||||
import org.jetbrains.compose.resources.vector.xmldom.Element |
||||
import org.jetbrains.compose.resources.vector.xmldom.NodeList |
||||
import kotlin.io.encoding.Base64 |
||||
import kotlin.io.encoding.ExperimentalEncodingApi |
||||
|
||||
private val SimpleStringFormatRegex = Regex("""%(\d)\$[ds]""") |
||||
internal fun String.replaceWithArgs(args: List<String>) = SimpleStringFormatRegex.replace(this) { matchResult -> |
||||
args[matchResult.groupValues[1].toInt() - 1] |
||||
} |
||||
|
||||
internal sealed interface StringItem { |
||||
data class Value(val text: String) : StringItem |
||||
data class Plurals(val items: Map<PluralCategory, String>) : StringItem |
||||
data class Array(val items: List<String>) : StringItem |
||||
} |
||||
|
||||
private val stringsCacheMutex = Mutex() |
||||
private val stringItemsCache = mutableMapOf<String, Deferred<StringItem>>() |
||||
//@TestOnly |
||||
internal fun dropStringItemsCache() { |
||||
stringItemsCache.clear() |
||||
} |
||||
|
||||
internal suspend fun getStringItem( |
||||
resourceItem: ResourceItem, |
||||
resourceReader: ResourceReader |
||||
): StringItem = coroutineScope { |
||||
val deferred = stringsCacheMutex.withLock { |
||||
stringItemsCache.getOrPut("${resourceItem.path}/${resourceItem.offset}-${resourceItem.size}") { |
||||
//LAZY - to free the mutex lock as fast as possible |
||||
async(start = CoroutineStart.LAZY) { |
||||
val record = resourceReader.readPart( |
||||
resourceItem.path, |
||||
resourceItem.offset, |
||||
resourceItem.size |
||||
).decodeToString() |
||||
val recordItems = record.split('|') |
||||
val recordType = recordItems.first() |
||||
val recordData = recordItems.last() |
||||
when (recordType) { |
||||
"plurals" -> recordData.decodeAsPlural() |
||||
"string-array" -> recordData.decodeAsArray() |
||||
else -> recordData.decodeAsString() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
deferred.await() |
||||
} |
||||
|
||||
@OptIn(ExperimentalEncodingApi::class) |
||||
private fun String.decodeAsString(): StringItem.Value = StringItem.Value( |
||||
Base64.decode(this).decodeToString() |
||||
) |
||||
|
||||
@OptIn(ExperimentalEncodingApi::class) |
||||
private fun String.decodeAsArray(): StringItem.Array = StringItem.Array( |
||||
split(",").map { item -> |
||||
Base64.decode(item).decodeToString() |
||||
} |
||||
) |
||||
|
||||
@OptIn(ExperimentalEncodingApi::class) |
||||
private fun String.decodeAsPlural(): StringItem.Plurals = StringItem.Plurals( |
||||
split(",").associate { item -> |
||||
val category = item.substringBefore(':') |
||||
val valueBase64 = item.substringAfter(':') |
||||
PluralCategory.fromString(category)!! to Base64.decode(valueBase64).decodeToString() |
||||
} |
||||
) |
@ -0,0 +1,9 @@
|
||||
version:0 |
||||
plurals|another_plurals|ONE:YW5vdGhlciBvbmU=,OTHER:YW5vdGhlciBvdGhlcg== |
||||
plurals|messages|ONE:JTEkZCBtZXNzYWdlIGZvciAlMiRz,OTHER:JTEkZCBtZXNzYWdlcyBmb3IgJTIkcw== |
||||
plurals|plurals|ONE:b25l,OTHER:b3RoZXI= |
||||
string-array|str_arr|aXRlbSAx,aXRlbSAy,aXRlbSAz |
||||
string|accentuated_characters|Q3LDqWVyIHVuZSB0YWJsZQ== |
||||
string|app_name|Q29tcG9zZSBSZXNvdXJjZXMgQXBw |
||||
string|hello|8J+YiiBIZWxsbyB3b3JsZCE= |
||||
string|str_template|SGVsbG8sICUxJHMhIFlvdSBoYXZlICUyJGQgbmV3IG1lc3NhZ2VzLg== |
@ -1,23 +0,0 @@
|
||||
<resources> |
||||
<string name="app_name">Compose Resources App</string> |
||||
<string name="hello">😊 Hello world!</string> |
||||
<string name="accentuated_characters">Créer une table</string> |
||||
<string name="str_template">Hello, %1$s! You have %2$d new messages.</string> |
||||
<string-array name="str_arr"> |
||||
<item>item 1</item> |
||||
<item>item 2</item> |
||||
<item>item 3</item> |
||||
</string-array> |
||||
<plurals name="plurals"> |
||||
<item quantity="one">one</item> |
||||
<item quantity="other">other</item> |
||||
</plurals> |
||||
<plurals name="another_plurals"> |
||||
<item quantity="one">another one</item> |
||||
<item quantity="other">another other</item> |
||||
</plurals> |
||||
<plurals name="messages"> |
||||
<item quantity="one">%1$d message for %2$s</item> |
||||
<item quantity="other">%1$d messages for %2$s</item> |
||||
</plurals> |
||||
</resources> |
@ -1,11 +1,26 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
private object JvmResourceReader |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
@InternalResourceApi |
||||
actual suspend fun readResourceBytes(path: String): ByteArray { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: JvmResourceReader.javaClass.classLoader |
||||
val resource = classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path) |
||||
return resource.readBytes() |
||||
import java.io.InputStream |
||||
|
||||
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader { |
||||
override suspend fun read(path: String): ByteArray { |
||||
val resource = getResourceAsStream(path) |
||||
return resource.readBytes() |
||||
} |
||||
|
||||
override suspend fun readPart(path: String, offset: Long, size: Long): ByteArray { |
||||
val resource = getResourceAsStream(path) |
||||
val result = ByteArray(size.toInt()) |
||||
resource.use { input -> |
||||
input.skip(offset) |
||||
input.read(result, 0, size.toInt()) |
||||
} |
||||
return result |
||||
} |
||||
|
||||
@OptIn(ExperimentalResourceApi::class) |
||||
private fun getResourceAsStream(path: String): InputStream { |
||||
val classLoader = Thread.currentThread().contextClassLoader ?: this.javaClass.classLoader |
||||
return classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path) |
||||
} |
||||
} |
@ -1,26 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.utils |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.plugins.ExtensionAware |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension |
||||
|
||||
private const val COCOAPODS_PLUGIN_ID = "org.jetbrains.kotlin.native.cocoapods" |
||||
internal fun Project.withCocoapodsPlugin(fn: () -> Unit) { |
||||
project.plugins.withId(COCOAPODS_PLUGIN_ID) { |
||||
fn() |
||||
} |
||||
} |
||||
|
||||
internal val KotlinMultiplatformExtension.cocoapodsExt: CocoapodsExtension |
||||
get() { |
||||
val extensionAware = (this as? ExtensionAware) ?: error("KotlinMultiplatformExtension is not ExtensionAware") |
||||
val extName = "cocoapods" |
||||
val ext = extensionAware.extensions.findByName(extName) ?: error("KotlinMultiplatformExtension does not contain '$extName' extension") |
||||
return ext as CocoapodsExtension |
||||
} |
@ -1,22 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.internal.utils |
||||
|
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget |
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
|
||||
internal fun KotlinNativeTarget.isIosSimulatorTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_X64 || konanTarget === KonanTarget.IOS_SIMULATOR_ARM64 |
||||
|
||||
internal fun KotlinNativeTarget.isIosDeviceTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_ARM64 || konanTarget === KonanTarget.IOS_ARM32 |
||||
|
||||
internal fun KotlinNativeTarget.isIosTarget(): Boolean = |
||||
isIosSimulatorTarget() || isIosDeviceTarget() |
||||
|
||||
internal fun KotlinTarget.asIosNativeTargetOrNull(): KotlinNativeTarget? = |
||||
(this as? KotlinNativeTarget)?.takeIf { it.isIosTarget() } |
@ -1,53 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.experimental.uikit.tasks |
||||
|
||||
import org.gradle.api.DefaultTask |
||||
import org.gradle.api.file.Directory |
||||
import org.gradle.api.file.FileSystemOperations |
||||
import org.gradle.api.file.ProjectLayout |
||||
import org.gradle.api.model.ObjectFactory |
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.provider.ProviderFactory |
||||
import org.gradle.api.tasks.Internal |
||||
import org.gradle.api.tasks.LocalState |
||||
import org.gradle.process.ExecOperations |
||||
import org.jetbrains.compose.desktop.application.internal.ComposeProperties |
||||
import org.jetbrains.compose.desktop.application.internal.ExternalToolRunner |
||||
import org.jetbrains.compose.internal.utils.notNullProperty |
||||
import javax.inject.Inject |
||||
|
||||
abstract class AbstractComposeIosTask : DefaultTask() { |
||||
@get:Inject |
||||
protected abstract val objects: ObjectFactory |
||||
|
||||
@get:Inject |
||||
protected abstract val providers: ProviderFactory |
||||
|
||||
@get:Inject |
||||
protected abstract val execOperations: ExecOperations |
||||
|
||||
@get:Inject |
||||
protected abstract val fileOperations: FileSystemOperations |
||||
|
||||
@get:Inject |
||||
protected abstract val layout: ProjectLayout |
||||
|
||||
@get:LocalState |
||||
protected val logsDir: Provider<Directory> = project.layout.buildDirectory.dir("compose/logs/$name") |
||||
|
||||
@get:Internal |
||||
val verbose: Property<Boolean> = objects.notNullProperty<Boolean>().apply { |
||||
set(providers.provider { |
||||
logger.isDebugEnabled || ComposeProperties.isVerbose(providers).get() |
||||
}) |
||||
} |
||||
|
||||
@get:Internal |
||||
internal val runExternalTool: ExternalToolRunner |
||||
get() = ExternalToolRunner(verbose, logsDir, execOperations) |
||||
} |
@ -0,0 +1,100 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import com.android.build.api.variant.AndroidComponentsExtension |
||||
import com.android.build.gradle.BaseExtension |
||||
import org.gradle.api.DefaultTask |
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.DirectoryProperty |
||||
import org.gradle.api.file.FileSystemOperations |
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.* |
||||
import org.jetbrains.compose.internal.utils.registerTask |
||||
import org.jetbrains.compose.internal.utils.uppercaseFirstChar |
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation |
||||
import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull |
||||
import org.jetbrains.kotlin.gradle.utils.ObservableSet |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class) |
||||
internal fun Project.configureAndroidComposeResources( |
||||
kotlinExtension: KotlinMultiplatformExtension, |
||||
androidExtension: BaseExtension, |
||||
preparedCommonResources: Provider<File> |
||||
) { |
||||
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME |
||||
val commonResourcesDir = projectDir.resolve("src/$commonMain/$COMPOSE_RESOURCES_DIR") |
||||
|
||||
// 1) get the Kotlin Android Target Compilation -> [A] |
||||
// 2) get default source set name for the 'A' |
||||
// 3) find the associated Android SourceSet in the AndroidExtension -> [B] |
||||
// 4) get all source sets in the 'A' and add its resources to the 'B' |
||||
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).all { androidTarget -> |
||||
androidTarget.compilations.all { compilation: KotlinJvmAndroidCompilation -> |
||||
|
||||
//fix for AGP < 8.0 |
||||
//usually 'androidSourceSet.resources.srcDir(preparedCommonResources)' should be enough |
||||
compilation.androidVariant.processJavaResourcesProvider.configure { it.dependsOn(preparedCommonResources) } |
||||
|
||||
compilation.defaultSourceSet.androidSourceSetInfoOrNull?.let { kotlinAndroidSourceSet -> |
||||
androidExtension.sourceSets |
||||
.matching { it.name == kotlinAndroidSourceSet.androidSourceSetName } |
||||
.all { androidSourceSet -> |
||||
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet -> |
||||
if (kotlinSourceSet.name == commonMain) { |
||||
androidSourceSet.resources.srcDir(preparedCommonResources) |
||||
} else { |
||||
androidSourceSet.resources.srcDir( |
||||
projectDir.resolve("src/${kotlinSourceSet.name}/$COMPOSE_RESOURCES_DIR") |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
//copy fonts from the compose resources dir to android assets |
||||
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return |
||||
androidComponents.onVariants { variant -> |
||||
val copyFonts = registerTask<CopyAndroidFontsToAssetsTask>( |
||||
"copy${variant.name.uppercaseFirstChar()}FontsToAndroidAssets" |
||||
) { |
||||
from.set(commonResourcesDir) |
||||
} |
||||
variant.sources?.assets?.addGeneratedSourceDirectory( |
||||
taskProvider = copyFonts, |
||||
wiredWith = CopyAndroidFontsToAssetsTask::outputDirectory |
||||
) |
||||
//exclude a duplication of fonts in apks |
||||
variant.packaging.resources.excludes.add("**/font*/*") |
||||
} |
||||
} |
||||
|
||||
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API |
||||
internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() { |
||||
@get:Inject |
||||
abstract val fileSystem: FileSystemOperations |
||||
|
||||
@get:InputFiles |
||||
@get:IgnoreEmptyDirectories |
||||
abstract val from: Property<File> |
||||
|
||||
@get:OutputDirectory |
||||
abstract val outputDirectory: DirectoryProperty |
||||
|
||||
@TaskAction |
||||
fun action() { |
||||
fileSystem.copy { |
||||
it.includeEmptyDirs = false |
||||
it.from(from) |
||||
it.include("**/font*/*") |
||||
it.into(outputDirectory) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,110 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import com.android.build.gradle.BaseExtension |
||||
import org.gradle.api.Project |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.SourceSet |
||||
import org.gradle.api.tasks.TaskProvider |
||||
import org.gradle.util.GradleVersion |
||||
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID |
||||
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet |
||||
import org.jetbrains.kotlin.gradle.plugin.extraProperties |
||||
import java.io.File |
||||
|
||||
|
||||
internal const val COMPOSE_RESOURCES_DIR = "composeResources" |
||||
internal const val RES_GEN_DIR = "generated/compose/resourceGenerator" |
||||
private const val KMP_RES_EXT = "multiplatformResourcesPublication" |
||||
private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6" |
||||
private val androidPluginIds = listOf( |
||||
"com.android.application", |
||||
"com.android.library" |
||||
) |
||||
|
||||
internal fun Project.configureComposeResources(extension: ResourcesExtension) { |
||||
val config = provider { extension } |
||||
plugins.withId(KOTLIN_MPP_PLUGIN_ID) { onKgpApplied(config) } |
||||
plugins.withId(KOTLIN_JVM_PLUGIN_ID) { onKotlinJvmApplied(config) } |
||||
} |
||||
|
||||
private fun Project.onKgpApplied(config: Provider<ResourcesExtension>) { |
||||
val kotlinExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java) |
||||
|
||||
//common resources must be converted (XML -> CVR) |
||||
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME |
||||
val preparedCommonResources = prepareCommonResources(commonMain) |
||||
|
||||
val hasKmpResources = extraProperties.has(KMP_RES_EXT) |
||||
val currentGradleVersion = GradleVersion.current() |
||||
val minGradleVersion = GradleVersion.version(MIN_GRADLE_VERSION_FOR_KMP_RESOURCES) |
||||
val kmpResourcesAreAvailable = hasKmpResources && currentGradleVersion >= minGradleVersion |
||||
|
||||
if (kmpResourcesAreAvailable) { |
||||
configureKmpResources(kotlinExtension, extraProperties.get(KMP_RES_EXT)!!, preparedCommonResources, config) |
||||
} else { |
||||
if (!hasKmpResources) logger.info( |
||||
""" |
||||
Compose resources publication requires Kotlin Gradle Plugin >= 2.0 |
||||
Current Kotlin Gradle Plugin is ${KotlinVersion.CURRENT} |
||||
""".trimIndent() |
||||
) |
||||
if (currentGradleVersion < minGradleVersion) logger.info( |
||||
""" |
||||
Compose resources publication requires Gradle >= $MIN_GRADLE_VERSION_FOR_KMP_RESOURCES |
||||
Current Gradle is ${currentGradleVersion.version} |
||||
""".trimIndent() |
||||
) |
||||
|
||||
configureComposeResources(kotlinExtension, commonMain, preparedCommonResources, config) |
||||
|
||||
//when applied AGP then configure android resources |
||||
androidPluginIds.forEach { pluginId -> |
||||
plugins.withId(pluginId) { |
||||
val androidExtension = project.extensions.getByType(BaseExtension::class.java) |
||||
configureAndroidComposeResources(kotlinExtension, androidExtension, preparedCommonResources) |
||||
} |
||||
} |
||||
} |
||||
|
||||
configureSyncIosComposeResources(kotlinExtension) |
||||
} |
||||
|
||||
private fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) { |
||||
val kotlinExtension = project.extensions.getByType(KotlinProjectExtension::class.java) |
||||
val main = SourceSet.MAIN_SOURCE_SET_NAME |
||||
val preparedCommonResources = prepareCommonResources(main) |
||||
configureComposeResources(kotlinExtension, main, preparedCommonResources, config) |
||||
} |
||||
|
||||
//common resources must be converted (XML -> CVR) |
||||
private fun Project.prepareCommonResources(commonSourceSetName: String): Provider<File> { |
||||
val preparedResourcesTask = registerPrepareComposeResourcesTask( |
||||
project.projectDir.resolve("src/$commonSourceSetName/$COMPOSE_RESOURCES_DIR"), |
||||
layout.buildDirectory.dir("$RES_GEN_DIR/preparedResources/$commonSourceSetName/$COMPOSE_RESOURCES_DIR") |
||||
) |
||||
return preparedResourcesTask.flatMap { it.outputDir } |
||||
} |
||||
|
||||
// sourceSet.resources.srcDirs doesn't work for Android targets. |
||||
// Android resources should be configured separately |
||||
private fun Project.configureComposeResources( |
||||
kotlinExtension: KotlinProjectExtension, |
||||
commonSourceSetName: String, |
||||
preparedCommonResources: Provider<File>, |
||||
config: Provider<ResourcesExtension> |
||||
) { |
||||
logger.info("Configure compose resources") |
||||
kotlinExtension.sourceSets.all { sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
val resourcesDir = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") |
||||
if (sourceSetName == commonSourceSetName) { |
||||
sourceSet.resources.srcDirs(preparedCommonResources) |
||||
configureGenerationComposeResClass(preparedCommonResources, sourceSet, config, false) |
||||
} else { |
||||
sourceSet.resources.srcDirs(resourcesDir) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,132 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.Directory |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.Copy |
||||
import org.jetbrains.compose.desktop.application.internal.ComposeProperties |
||||
import org.jetbrains.compose.internal.utils.* |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.cocoapods.CocoapodsExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.* |
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
import java.io.File |
||||
|
||||
private const val COCOAPODS_PLUGIN_ID = "org.jetbrains.kotlin.native.cocoapods" |
||||
private const val IOS_COMPOSE_RESOURCES_ROOT_DIR = "compose-resources" |
||||
|
||||
internal fun Project.configureSyncIosComposeResources( |
||||
kotlinExtension: KotlinMultiplatformExtension |
||||
) { |
||||
if (ComposeProperties.dontSyncResources(project).get()) { |
||||
logger.info( |
||||
"Compose Multiplatform resource management for iOS is disabled: " + |
||||
"'${ComposeProperties.SYNC_RESOURCES_PROPERTY}' value is 'false'" |
||||
) |
||||
return |
||||
} |
||||
|
||||
kotlinExtension.targets.withType(KotlinNativeTarget::class.java).all { nativeTarget -> |
||||
if (nativeTarget.isIosTarget()) { |
||||
nativeTarget.binaries.withType(Framework::class.java).all { iosFramework -> |
||||
val frameworkClassifier = iosFramework.getClassifier() |
||||
val checkNoSandboxTask = tasks.registerOrConfigure<CheckCanAccessComposeResourcesDirectory>( |
||||
"checkCanSync${frameworkClassifier}ComposeResourcesForIos" |
||||
) {} |
||||
|
||||
val frameworkResources = files() |
||||
iosFramework.compilation.allKotlinSourceSets.forAll { ss -> |
||||
frameworkResources.from(ss.resources.sourceDirectories) |
||||
} |
||||
val syncComposeResourcesTask = tasks.registerOrConfigure<SyncComposeResourcesForIosTask>( |
||||
iosFramework.getSyncResourcesTaskName() |
||||
) { |
||||
dependsOn(checkNoSandboxTask) |
||||
dependsOn(frameworkResources) //!!! explicit dependency because targetResources is not an input |
||||
|
||||
outputDir.set(iosFramework.getFinalResourcesDir()) |
||||
targetResources.put(iosFramework.target.konanTarget.name, frameworkResources) |
||||
} |
||||
|
||||
val externalTaskName = if (iosFramework.isCocoapodsFramework()) { |
||||
"syncFramework" |
||||
} else { |
||||
"embedAndSign${frameworkClassifier}AppleFrameworkForXcode" |
||||
} |
||||
|
||||
project.tasks.named(externalTaskName).dependsOn(syncComposeResourcesTask) |
||||
} |
||||
|
||||
nativeTarget.binaries.withType(TestExecutable::class.java).all { testExec -> |
||||
val copyTestResourcesTask = tasks.registerOrConfigure<Copy>( |
||||
"copyTestComposeResourcesFor${testExec.target.targetName.uppercaseFirstChar()}" |
||||
) { |
||||
from({ |
||||
(testExec.compilation.associatedCompilations + testExec.compilation).flatMap { compilation -> |
||||
compilation.allKotlinSourceSets.map { it.resources } |
||||
} |
||||
}) |
||||
into(testExec.outputDirectory.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR)) |
||||
} |
||||
testExec.linkTask.dependsOn(copyTestResourcesTask) |
||||
} |
||||
} |
||||
} |
||||
|
||||
plugins.withId(COCOAPODS_PLUGIN_ID) { |
||||
project.extensions.getByType(CocoapodsExtension::class.java).apply { |
||||
framework { podFramework -> |
||||
val syncDir = podFramework.getFinalResourcesDir().get().asFile.relativeTo(projectDir) |
||||
val specAttr = "['${syncDir.path}']" |
||||
extraSpecAttributes["resources"] = specAttr |
||||
project.tasks.named("podInstall").configure { |
||||
it.doFirst { |
||||
if (extraSpecAttributes["resources"] != specAttr) { |
||||
error(""" |
||||
|Kotlin.cocoapods.extraSpecAttributes["resources"] is not compatible with Compose Multiplatform's resources management for iOS. |
||||
| * Recommended action: remove extraSpecAttributes["resources"] from '${project.buildFile}' and run '${project.path}:podInstall' once; |
||||
| * Alternative action: turn off Compose Multiplatform's resources management for iOS by adding '${ComposeProperties.SYNC_RESOURCES_PROPERTY}=false' to your gradle.properties; |
||||
""".trimMargin()) |
||||
} |
||||
syncDir.mkdirs() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun Framework.getClassifier(): String { |
||||
val suffix = joinLowerCamelCase(buildType.getName(), outputKind.taskNameClassifier) |
||||
return if (name == suffix) "" |
||||
else name.substringBeforeLast(suffix.uppercaseFirstChar()).uppercaseFirstChar() |
||||
} |
||||
|
||||
internal fun Framework.getSyncResourcesTaskName() = "sync${getClassifier()}ComposeResourcesForIos" |
||||
private fun Framework.isCocoapodsFramework() = name.startsWith("pod") |
||||
|
||||
private fun Framework.getFinalResourcesDir(): Provider<Directory> { |
||||
val providers = project.providers |
||||
return if (isCocoapodsFramework()) { |
||||
project.layout.buildDirectory.dir("compose/ios/$baseName/$IOS_COMPOSE_RESOURCES_ROOT_DIR/") |
||||
} else { |
||||
providers.environmentVariable("BUILT_PRODUCTS_DIR") |
||||
.zip( |
||||
providers.environmentVariable("CONTENTS_FOLDER_PATH") |
||||
) { builtProductsDir, contentsFolderPath -> |
||||
File("$builtProductsDir/$contentsFolderPath/$IOS_COMPOSE_RESOURCES_ROOT_DIR").canonicalPath |
||||
} |
||||
.flatMap { |
||||
project.objects.directoryProperty().apply { set(File(it)) } |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun KotlinNativeTarget.isIosSimulatorTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_X64 || konanTarget === KonanTarget.IOS_SIMULATOR_ARM64 |
||||
|
||||
private fun KotlinNativeTarget.isIosDeviceTarget(): Boolean = |
||||
konanTarget === KonanTarget.IOS_ARM64 || konanTarget === KonanTarget.IOS_ARM32 |
||||
|
||||
private fun KotlinNativeTarget.isIosTarget(): Boolean = |
||||
isIosSimulatorTarget() || isIosDeviceTarget() |
@ -0,0 +1,148 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.gradle.api.DefaultTask |
||||
import org.gradle.api.file.DirectoryProperty |
||||
import org.gradle.api.file.FileCollection |
||||
import org.gradle.api.model.ObjectFactory |
||||
import org.gradle.api.provider.* |
||||
import org.gradle.api.tasks.* |
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
import javax.inject.Inject |
||||
|
||||
internal abstract class SyncComposeResourcesForIosTask : DefaultTask() { |
||||
|
||||
private fun Provider<String>.orElseThrowMissingAttributeError(attribute: String): Provider<String> { |
||||
val noProvidedValue = "__NO_PROVIDED_VALUE__" |
||||
return this.orElse(noProvidedValue).map { |
||||
if (it == noProvidedValue) { |
||||
error( |
||||
"Could not infer iOS target $attribute. Make sure to build " + |
||||
"via XCode (directly or via Kotlin Multiplatform Mobile plugin for Android Studio)" |
||||
) |
||||
} |
||||
it |
||||
} |
||||
} |
||||
|
||||
@get:Inject |
||||
protected abstract val providers: ProviderFactory |
||||
|
||||
@get:Inject |
||||
protected abstract val objects: ObjectFactory |
||||
|
||||
@get:Input |
||||
val xcodeTargetPlatform: Provider<String> = |
||||
providers.gradleProperty("compose.ios.resources.platform") |
||||
.orElse(providers.environmentVariable("PLATFORM_NAME")) |
||||
.orElseThrowMissingAttributeError("platform") |
||||
|
||||
|
||||
@get:Input |
||||
val xcodeTargetArchs: Provider<List<String>> = |
||||
providers.gradleProperty("compose.ios.resources.archs") |
||||
.orElse(providers.environmentVariable("ARCHS")) |
||||
.orElseThrowMissingAttributeError("architectures") |
||||
.map { str -> str.split(",", " ").filter { it.isNotBlank() } } |
||||
|
||||
@get:Internal |
||||
internal abstract val targetResources: MapProperty<String, FileCollection> |
||||
|
||||
@get:PathSensitive(PathSensitivity.ABSOLUTE) |
||||
@get:InputFiles |
||||
val resourceFiles: Provider<FileCollection> = |
||||
xcodeTargetPlatform.zip(xcodeTargetArchs, ::Pair).map { (xcodeTargetPlatform, xcodeTargetArchs) -> |
||||
val allResources = getRequestedKonanTargetsByXcode(xcodeTargetPlatform, xcodeTargetArchs) |
||||
.mapNotNull { konanTarget -> targetResources.getting(konanTarget.name).get() } |
||||
objects.fileCollection().from(*allResources.toTypedArray()) |
||||
} |
||||
|
||||
@get:OutputDirectory |
||||
abstract val outputDir: DirectoryProperty |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
val outputDir = outputDir.get().asFile |
||||
outputDir.deleteRecursively() |
||||
outputDir.mkdirs() |
||||
logger.info("Clean ${outputDir.path}") |
||||
|
||||
resourceFiles.get().forEach { dir -> |
||||
if (dir.exists() && dir.isDirectory) { |
||||
logger.info("Copy '${dir.path}' to '${outputDir.path}'") |
||||
dir.walkTopDown().filter { !it.isDirectory && !it.isHidden }.forEach { file -> |
||||
val targetFile = outputDir.resolve(file.relativeTo(dir)) |
||||
if (targetFile.exists()) { |
||||
logger.info("Skip [already exists] '${file.path}'") |
||||
} else { |
||||
logger.info(" -> '${file.path}'") |
||||
file.copyTo(targetFile) |
||||
} |
||||
} |
||||
} else { |
||||
logger.info("File '${dir.path}' is not a dir or doesn't exist") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// based on AppleSdk.kt from Kotlin Gradle Plugin |
||||
// See https://github.com/JetBrains/kotlin/blob/142421da5b966049b4eab44ce6856eb172cf122a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/apple/AppleSdk.kt |
||||
private fun getRequestedKonanTargetsByXcode(platform: String, archs: List<String>): List<KonanTarget> { |
||||
val targets: MutableSet<KonanTarget> = mutableSetOf() |
||||
|
||||
when { |
||||
platform.startsWith("iphoneos") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_ARM64 |
||||
"armv7", "armv7s" -> KonanTarget.IOS_ARM32 |
||||
else -> error("Unknown iOS device arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
|
||||
platform.startsWith("iphonesimulator") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_SIMULATOR_ARM64 |
||||
"x86_64" -> KonanTarget.IOS_X64 |
||||
else -> error("Unknown iOS simulator arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
|
||||
else -> error("Unknown iOS platform: '$platform'") |
||||
} |
||||
|
||||
return targets.toList() |
||||
} |
||||
|
||||
/** |
||||
* Since Xcode 15, there is a new default setting: `ENABLE_USER_SCRIPT_SANDBOXING = YES` |
||||
* It's set in project.pbxproj |
||||
* |
||||
* SyncComposeResourcesForIosTask fails to work with it right now. |
||||
* |
||||
* Gradle attempts to create an output folder for SyncComposeResourcesForIosTask on our behalf, |
||||
* so we can't handle an exception when it occurs. Therefore, we make SyncComposeResourcesForIosTask |
||||
* depend on CheckCanAccessComposeResourcesDirectory, where we check ENABLE_USER_SCRIPT_SANDBOXING. |
||||
*/ |
||||
internal abstract class CheckCanAccessComposeResourcesDirectory : DefaultTask() { |
||||
@get:Input |
||||
val enabled = project.providers.environmentVariable("ENABLE_USER_SCRIPT_SANDBOXING") |
||||
.orElse("NOT_DEFINED") |
||||
.map { it == "YES" } |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
if (enabled.get()) { |
||||
logger.error(""" |
||||
Failed to sync compose resources! |
||||
Please make sure ENABLE_USER_SCRIPT_SANDBOXING is set to 'NO' in 'project.pbxproj' |
||||
""".trimIndent()) |
||||
throw IllegalStateException( |
||||
"Sandbox environment detected (ENABLE_USER_SCRIPT_SANDBOXING = YES). It's not supported so far." |
||||
) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,123 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.provider.Provider |
||||
import org.jetbrains.kotlin.gradle.ComposeKotlinGradlePluginApi |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.* |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.* |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication |
||||
import java.io.File |
||||
|
||||
|
||||
@OptIn(ComposeKotlinGradlePluginApi::class) |
||||
internal fun Project.configureKmpResources( |
||||
kotlinExtension: KotlinProjectExtension, |
||||
kmpResources: Any, |
||||
preparedCommonResources: Provider<File>, |
||||
config: Provider<ResourcesExtension> |
||||
) { |
||||
kotlinExtension as KotlinMultiplatformExtension |
||||
kmpResources as KotlinTargetResourcesPublication |
||||
|
||||
logger.info("Configure KMP resources") |
||||
|
||||
//configure KMP resources publishing for each supported target |
||||
kotlinExtension.targets |
||||
.matching { target -> kmpResources.canPublishResources(target) } |
||||
.all { target -> |
||||
logger.info("Configure resources publication for '${target.targetName}' target") |
||||
val packedResourceDir = config.getModuleResourcesDir(project) |
||||
|
||||
kmpResources.publishResourcesAsKotlinComponent( |
||||
target, |
||||
{ sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
val composeResDir: Provider<File> |
||||
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) { |
||||
composeResDir = preparedCommonResources |
||||
} else { |
||||
composeResDir = provider { project.file("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") } |
||||
} |
||||
|
||||
KotlinTargetResourcesPublication.ResourceRoot( |
||||
composeResDir, |
||||
emptyList(), |
||||
//for android target exclude fonts |
||||
if (target is KotlinAndroidTarget) listOf("**/font*/*") else emptyList() |
||||
) |
||||
}, |
||||
packedResourceDir |
||||
) |
||||
|
||||
if (target is KotlinAndroidTarget) { |
||||
//for android target publish fonts in assets |
||||
logger.info("Configure fonts relocation for '${target.targetName}' target") |
||||
kmpResources.publishInAndroidAssets( |
||||
target, |
||||
{ sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
val composeResDir: Provider<File> |
||||
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) { |
||||
composeResDir = preparedCommonResources |
||||
} else { |
||||
composeResDir = provider { project.file("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") } |
||||
} |
||||
KotlinTargetResourcesPublication.ResourceRoot( |
||||
composeResDir, |
||||
listOf("**/font*/*"), |
||||
emptyList() |
||||
) |
||||
}, |
||||
packedResourceDir |
||||
) |
||||
} |
||||
} |
||||
|
||||
//generate accessors for common resources |
||||
kotlinExtension.sourceSets.all { sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) { |
||||
configureGenerationComposeResClass( |
||||
preparedCommonResources, |
||||
sourceSet, |
||||
config, |
||||
true |
||||
) |
||||
} |
||||
} |
||||
|
||||
//add all resolved resources for browser and native compilations |
||||
val platformsForSetupCompilation = listOf(KotlinPlatformType.native, KotlinPlatformType.js, KotlinPlatformType.wasm) |
||||
kotlinExtension.targets |
||||
.matching { target -> target.platformType in platformsForSetupCompilation } |
||||
.all { target: KotlinTarget -> |
||||
val allResources = kmpResources.resolveResources(target) |
||||
target.compilations.all { compilation -> |
||||
if (compilation.name == KotlinCompilation.MAIN_COMPILATION_NAME) { |
||||
configureResourcesForCompilation(compilation, allResources) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Add resolved resources to a kotlin compilation to include it into a resulting platform artefact |
||||
* It is required for JS and Native targets. |
||||
* For JVM and Android it works automatically via jar files |
||||
*/ |
||||
private fun Project.configureResourcesForCompilation( |
||||
compilation: KotlinCompilation<*>, |
||||
directoryWithAllResourcesForCompilation: Provider<File> |
||||
) { |
||||
logger.info("Add all resolved resources to '${compilation.target.targetName}' target '${compilation.name}' compilation") |
||||
compilation.defaultSourceSet.resources.srcDir(directoryWithAllResourcesForCompilation) |
||||
|
||||
//JS packaging requires explicit dependency |
||||
if (compilation is KotlinJsCompilation) { |
||||
tasks.named(compilation.processResourcesTaskName).configure { processResourcesTask -> |
||||
processResourcesTask.dependsOn(directoryWithAllResourcesForCompilation) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,235 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.gradle.api.DefaultTask |
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.* |
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.* |
||||
import org.w3c.dom.Node |
||||
import java.io.File |
||||
import java.util.* |
||||
import javax.inject.Inject |
||||
import javax.xml.parsers.DocumentBuilderFactory |
||||
|
||||
internal fun Project.registerPrepareComposeResourcesTask( |
||||
userComposeResourcesDir: File, |
||||
preparedComposeResourcesDir: Provider<Directory> |
||||
): TaskProvider<PrepareComposeResourcesTask> { |
||||
val convertXmlValueResources = tasks.register( |
||||
"convertXmlValueResources", |
||||
XmlValuesConverterTask::class.java |
||||
) { task -> |
||||
task.originalResourcesDir.set(userComposeResourcesDir) |
||||
task.outputDir.set(preparedComposeResourcesDir) |
||||
} |
||||
|
||||
val copyNonXmlValueResources = tasks.register( |
||||
"copyNonXmlValueResources", |
||||
CopyNonXmlValueResourcesTask::class.java |
||||
) { task -> |
||||
task.originalResourcesDir.set(userComposeResourcesDir) |
||||
task.outputDir.set(preparedComposeResourcesDir) |
||||
} |
||||
|
||||
val prepareComposeResourcesTask = tasks.register( |
||||
"prepareComposeResourcesTask", |
||||
PrepareComposeResourcesTask::class.java |
||||
) { task -> |
||||
task.convertedXmls.set(convertXmlValueResources.map { it.realOutputFiles.get() }) |
||||
task.copiedNonXmls.set(copyNonXmlValueResources.map { it.realOutputFiles.get() }) |
||||
task.outputDir.set(preparedComposeResourcesDir.map { it.asFile }) |
||||
} |
||||
|
||||
return prepareComposeResourcesTask |
||||
} |
||||
|
||||
internal abstract class CopyNonXmlValueResourcesTask : DefaultTask() { |
||||
@get:Inject |
||||
abstract val fileSystem: FileSystemOperations |
||||
|
||||
@get:Internal |
||||
abstract val originalResourcesDir: DirectoryProperty |
||||
|
||||
@get:InputFiles |
||||
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}") } |
||||
} |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
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 : DefaultTask() { |
||||
@get:InputFiles |
||||
abstract val convertedXmls: Property<FileTree> |
||||
|
||||
@get:InputFiles |
||||
abstract val copiedNonXmls: Property<FileTree> |
||||
|
||||
@get:OutputDirectory |
||||
abstract val outputDir: Property<File> |
||||
|
||||
@TaskAction |
||||
fun run() {} |
||||
} |
||||
|
||||
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 : DefaultTask() { |
||||
companion object { |
||||
const val CONVERTED_RESOURCE_EXT = "cvr" //Compose Value Resource |
||||
private const val FORMAT_VERSION = 0 |
||||
} |
||||
|
||||
@get:Internal |
||||
abstract val originalResourcesDir: DirectoryProperty |
||||
|
||||
@get:InputFiles |
||||
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 -> |
||||
dir.asFileTree.matching { it.include("values*/*.$CONVERTED_RESOURCE_EXT") } |
||||
} |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
val outDir = outputDir.get().asFile |
||||
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 + ".$CONVERTED_RESOURCE_EXT") |
||||
output.parentFile.mkdirs() |
||||
convert(f, output) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
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) }.mapNotNull { getItemRecord(it)?.getAsString() } |
||||
val fileContent = buildString { |
||||
appendLine("version:$FORMAT_VERSION") |
||||
records.sorted().forEach { appendLine(it) } |
||||
} |
||||
converted.writeText(fileContent) |
||||
} |
||||
|
||||
private fun getItemRecord(node: Node): ValueResourceRecord? { |
||||
val type = ResourceType.fromString(node.nodeName) ?: return null |
||||
val key = node.attributes.getNamedItem("name").nodeValue |
||||
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 |
||||
} |
@ -0,0 +1,65 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.provider.Provider |
||||
import org.jetbrains.compose.ComposePlugin |
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet |
||||
import java.io.File |
||||
|
||||
internal fun Project.configureGenerationComposeResClass( |
||||
commonComposeResourcesDir: Provider<File>, |
||||
commonSourceSet: KotlinSourceSet, |
||||
config: Provider<ResourcesExtension>, |
||||
generateModulePath: Boolean |
||||
) { |
||||
logger.info("Configure accessors for '${commonSourceSet.name}'") |
||||
|
||||
//lazy check a dependency on the Resources library |
||||
val shouldGenerateResClass = config.map { |
||||
when (it.generateResClass) { |
||||
ResourcesExtension.ResourceClassGeneration.Auto -> { |
||||
configurations.run { |
||||
//because the implementation configuration doesn't extend the api in the KGP ¯\_(ツ)_/¯ |
||||
getByName(commonSourceSet.implementationConfigurationName).allDependencies + |
||||
getByName(commonSourceSet.apiConfigurationName).allDependencies |
||||
}.any { dep -> |
||||
val depStringNotation = dep.let { "${it.group}:${it.name}:${it.version}" } |
||||
depStringNotation == ComposePlugin.CommonComponentsDependencies.resources |
||||
} |
||||
} |
||||
|
||||
ResourcesExtension.ResourceClassGeneration.Always -> { |
||||
true |
||||
} |
||||
|
||||
ResourcesExtension.ResourceClassGeneration.Never -> { |
||||
false |
||||
} |
||||
} |
||||
} |
||||
|
||||
val genTask = tasks.register( |
||||
"generateComposeResClass", |
||||
GenerateResClassTask::class.java |
||||
) { task -> |
||||
task.packageName.set(config.getResourcePackage(project)) |
||||
task.shouldGenerateResClass.set(shouldGenerateResClass) |
||||
task.makeResClassPublic.set(config.map { it.publicResClass }) |
||||
task.resDir.set(commonComposeResourcesDir) |
||||
task.codeDir.set(layout.buildDirectory.dir("$RES_GEN_DIR/kotlin").map { it.asFile }) |
||||
|
||||
if (generateModulePath) { |
||||
task.moduleDir.set(config.getModuleResourcesDir(project)) |
||||
} |
||||
} |
||||
|
||||
//register generated source set |
||||
commonSourceSet.kotlin.srcDir(genTask.map { it.codeDir }) |
||||
|
||||
//setup task execution during IDE import |
||||
tasks.configureEach { |
||||
if (it.name == "prepareKotlinIdeaImport") { |
||||
it.dependsOn(genTask) |
||||
} |
||||
} |
||||
} |
@ -1,381 +0,0 @@
|
||||
package org.jetbrains.compose.resources |
||||
|
||||
import com.android.build.api.variant.AndroidComponentsExtension |
||||
import com.android.build.gradle.BaseExtension |
||||
import org.gradle.api.DefaultTask |
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.DirectoryProperty |
||||
import org.gradle.api.file.FileSystemOperations |
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.* |
||||
import org.gradle.util.GradleVersion |
||||
import org.jetbrains.compose.ComposePlugin |
||||
import org.jetbrains.compose.desktop.application.internal.ComposeProperties |
||||
import org.jetbrains.compose.internal.KOTLIN_JVM_PLUGIN_ID |
||||
import org.jetbrains.compose.internal.KOTLIN_MPP_PLUGIN_ID |
||||
import org.jetbrains.compose.internal.utils.registerTask |
||||
import org.jetbrains.compose.internal.utils.uppercaseFirstChar |
||||
import org.jetbrains.compose.resources.ios.getSyncResourcesTaskName |
||||
import org.jetbrains.kotlin.gradle.ComposeKotlinGradlePluginApi |
||||
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.* |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.* |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication |
||||
import org.jetbrains.kotlin.gradle.plugin.sources.android.androidSourceSetInfoOrNull |
||||
import org.jetbrains.kotlin.gradle.utils.ObservableSet |
||||
import java.io.File |
||||
import javax.inject.Inject |
||||
|
||||
private const val COMPOSE_RESOURCES_DIR = "composeResources" |
||||
private const val RES_GEN_DIR = "generated/compose/resourceGenerator" |
||||
private const val KMP_RES_EXT = "multiplatformResourcesPublication" |
||||
private const val MIN_GRADLE_VERSION_FOR_KMP_RESOURCES = "7.6" |
||||
private val androidPluginIds = listOf( |
||||
"com.android.application", |
||||
"com.android.library" |
||||
) |
||||
|
||||
internal fun Project.configureComposeResources(config: ResourcesExtension) { |
||||
val resourcePackage = provider { |
||||
config.packageOfResClass.takeIf { it.isNotEmpty() } ?: run { |
||||
val groupName = project.group.toString().lowercase().asUnderscoredIdentifier() |
||||
val moduleName = project.name.lowercase().asUnderscoredIdentifier() |
||||
val id = if (groupName.isNotEmpty()) "$groupName.$moduleName" else moduleName |
||||
"$id.generated.resources" |
||||
} |
||||
} |
||||
|
||||
val publicResClass = provider { config.publicResClass } |
||||
|
||||
val generateResClassMode = provider { config.generateResClass } |
||||
|
||||
plugins.withId(KOTLIN_MPP_PLUGIN_ID) { |
||||
val kotlinExtension = project.extensions.getByType(KotlinMultiplatformExtension::class.java) |
||||
|
||||
val hasKmpResources = extraProperties.has(KMP_RES_EXT) |
||||
val currentGradleVersion = GradleVersion.current() |
||||
val minGradleVersion = GradleVersion.version(MIN_GRADLE_VERSION_FOR_KMP_RESOURCES) |
||||
if (hasKmpResources && currentGradleVersion >= minGradleVersion) { |
||||
configureKmpResources( |
||||
kotlinExtension, |
||||
extraProperties.get(KMP_RES_EXT)!!, |
||||
resourcePackage, |
||||
publicResClass, |
||||
generateResClassMode |
||||
) |
||||
} else { |
||||
if (!hasKmpResources) { |
||||
logger.info( |
||||
""" |
||||
Compose resources publication requires Kotlin Gradle Plugin >= 2.0 |
||||
Current Kotlin Gradle Plugin is ${KotlinVersion.CURRENT} |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
if (currentGradleVersion < minGradleVersion) { |
||||
logger.info( |
||||
""" |
||||
Compose resources publication requires Gradle >= $MIN_GRADLE_VERSION_FOR_KMP_RESOURCES |
||||
Current Gradle is ${currentGradleVersion.version} |
||||
""".trimIndent() |
||||
) |
||||
} |
||||
|
||||
//current KGP doesn't have KPM resources |
||||
configureComposeResources( |
||||
kotlinExtension, |
||||
KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME, |
||||
resourcePackage, |
||||
publicResClass, |
||||
generateResClassMode |
||||
) |
||||
|
||||
//when applied AGP then configure android resources |
||||
androidPluginIds.forEach { pluginId -> |
||||
plugins.withId(pluginId) { |
||||
val androidExtension = project.extensions.getByType(BaseExtension::class.java) |
||||
configureAndroidComposeResources(kotlinExtension, androidExtension) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
plugins.withId(KOTLIN_JVM_PLUGIN_ID) { |
||||
val kotlinExtension = project.extensions.getByType(KotlinProjectExtension::class.java) |
||||
configureComposeResources( |
||||
kotlinExtension, |
||||
SourceSet.MAIN_SOURCE_SET_NAME, |
||||
resourcePackage, |
||||
publicResClass, |
||||
generateResClassMode |
||||
) |
||||
} |
||||
} |
||||
|
||||
private fun Project.configureComposeResources( |
||||
kotlinExtension: KotlinProjectExtension, |
||||
commonSourceSetName: String, |
||||
resourcePackage: Provider<String>, |
||||
publicResClass: Provider<Boolean>, |
||||
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration> |
||||
) { |
||||
logger.info("Configure compose resources") |
||||
kotlinExtension.sourceSets.all { sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
val composeResourcesPath = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") |
||||
|
||||
//To compose resources will be packed to a final artefact we need to mark them as resources |
||||
//sourceSet.resources works for all targets except ANDROID! |
||||
sourceSet.resources.srcDirs(composeResourcesPath) |
||||
|
||||
if (sourceSetName == commonSourceSetName) { |
||||
configureResourceGenerator( |
||||
composeResourcesPath, |
||||
sourceSet, |
||||
resourcePackage, |
||||
publicResClass, |
||||
generateResClassMode, |
||||
false |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
@OptIn(ComposeKotlinGradlePluginApi::class) |
||||
private fun Project.configureKmpResources( |
||||
kotlinExtension: KotlinProjectExtension, |
||||
kmpResources: Any, |
||||
resourcePackage: Provider<String>, |
||||
publicResClass: Provider<Boolean>, |
||||
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration> |
||||
) { |
||||
kotlinExtension as KotlinMultiplatformExtension |
||||
kmpResources as KotlinTargetResourcesPublication |
||||
|
||||
logger.info("Configure KMP resources") |
||||
|
||||
//configure KMP resources publishing for each supported target |
||||
kotlinExtension.targets |
||||
.matching { target -> kmpResources.canPublishResources(target) } |
||||
.all { target -> |
||||
logger.info("Configure resources publication for '${target.targetName}' target") |
||||
kmpResources.publishResourcesAsKotlinComponent( |
||||
target, |
||||
{ sourceSet -> |
||||
KotlinTargetResourcesPublication.ResourceRoot( |
||||
project.provider { project.file("src/${sourceSet.name}/$COMPOSE_RESOURCES_DIR") }, |
||||
emptyList(), |
||||
//for android target exclude fonts |
||||
if (target is KotlinAndroidTarget) listOf("**/font*/*") else emptyList() |
||||
) |
||||
}, |
||||
resourcePackage.asModuleDir() |
||||
) |
||||
|
||||
if (target is KotlinAndroidTarget) { |
||||
//for android target publish fonts in assets |
||||
logger.info("Configure fonts relocation for '${target.targetName}' target") |
||||
kmpResources.publishInAndroidAssets( |
||||
target, |
||||
{ sourceSet -> |
||||
KotlinTargetResourcesPublication.ResourceRoot( |
||||
project.provider { project.file("src/${sourceSet.name}/$COMPOSE_RESOURCES_DIR") }, |
||||
listOf("**/font*/*"), |
||||
emptyList() |
||||
) |
||||
}, |
||||
resourcePackage.asModuleDir() |
||||
) |
||||
} |
||||
} |
||||
|
||||
//generate accessors for common resources |
||||
kotlinExtension.sourceSets.all { sourceSet -> |
||||
val sourceSetName = sourceSet.name |
||||
if (sourceSetName == KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME) { |
||||
val composeResourcesPath = project.projectDir.resolve("src/$sourceSetName/$COMPOSE_RESOURCES_DIR") |
||||
configureResourceGenerator( |
||||
composeResourcesPath, |
||||
sourceSet, |
||||
resourcePackage, |
||||
publicResClass, |
||||
generateResClassMode, |
||||
true |
||||
) |
||||
} |
||||
} |
||||
|
||||
//add all resolved resources for browser and native compilations |
||||
val platformsForSetupCompilation = listOf(KotlinPlatformType.native, KotlinPlatformType.js, KotlinPlatformType.wasm) |
||||
kotlinExtension.targets |
||||
.matching { target -> target.platformType in platformsForSetupCompilation } |
||||
.all { target: KotlinTarget -> |
||||
val allResources = kmpResources.resolveResources(target) |
||||
target.compilations.all { compilation -> |
||||
if (compilation.name == KotlinCompilation.MAIN_COMPILATION_NAME) { |
||||
configureResourcesForCompilation(compilation, allResources) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Add resolved resources to a kotlin compilation to include it into a resulting platform artefact |
||||
* It is required for JS and Native targets. |
||||
* For JVM and Android it works automatically via jar files |
||||
*/ |
||||
private fun Project.configureResourcesForCompilation( |
||||
compilation: KotlinCompilation<*>, |
||||
directoryWithAllResourcesForCompilation: Provider<File> |
||||
) { |
||||
logger.info("Add all resolved resources to '${compilation.target.targetName}' target '${compilation.name}' compilation") |
||||
compilation.defaultSourceSet.resources.srcDir(directoryWithAllResourcesForCompilation) |
||||
if (compilation is KotlinJsCompilation) { |
||||
tasks.named(compilation.processResourcesTaskName).configure { processResourcesTask -> |
||||
processResourcesTask.dependsOn(directoryWithAllResourcesForCompilation) |
||||
} |
||||
} |
||||
if (compilation is KotlinNativeCompilation) { |
||||
compilation.target.binaries.withType(Framework::class.java).all { framework -> |
||||
tasks.configureEach { task -> |
||||
if (task.name == framework.getSyncResourcesTaskName()) { |
||||
task.dependsOn(directoryWithAllResourcesForCompilation) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@OptIn(ExperimentalKotlinGradlePluginApi::class) |
||||
private fun Project.configureAndroidComposeResources( |
||||
kotlinExtension: KotlinMultiplatformExtension, |
||||
androidExtension: BaseExtension |
||||
) { |
||||
//mark all composeResources as Android resources |
||||
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).all { androidTarget -> |
||||
androidTarget.compilations.all { compilation: KotlinJvmAndroidCompilation -> |
||||
compilation.defaultSourceSet.androidSourceSetInfoOrNull?.let { kotlinAndroidSourceSet -> |
||||
androidExtension.sourceSets |
||||
.matching { it.name == kotlinAndroidSourceSet.androidSourceSetName } |
||||
.all { androidSourceSet -> |
||||
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet -> |
||||
androidSourceSet.resources.srcDir( |
||||
projectDir.resolve("src/${kotlinSourceSet.name}/$COMPOSE_RESOURCES_DIR") |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
//copy fonts from the compose resources dir to android assets |
||||
val commonResourcesDir = projectDir.resolve( |
||||
"src/${KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME}/$COMPOSE_RESOURCES_DIR" |
||||
) |
||||
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return |
||||
androidComponents.onVariants { variant -> |
||||
val copyFonts = registerTask<CopyAndroidFontsToAssetsTask>( |
||||
"copy${variant.name.uppercaseFirstChar()}FontsToAndroidAssets" |
||||
) { |
||||
from.set(commonResourcesDir) |
||||
} |
||||
variant.sources?.assets?.addGeneratedSourceDirectory( |
||||
taskProvider = copyFonts, |
||||
wiredWith = CopyAndroidFontsToAssetsTask::outputDirectory |
||||
) |
||||
//exclude a duplication of fonts in apks |
||||
variant.packaging.resources.excludes.add("**/font*/*") |
||||
} |
||||
} |
||||
|
||||
private fun Project.configureResourceGenerator( |
||||
commonComposeResourcesDir: File, |
||||
commonSourceSet: KotlinSourceSet, |
||||
resourcePackage: Provider<String>, |
||||
publicResClass: Provider<Boolean>, |
||||
generateResClassMode: Provider<ResourcesExtension.ResourceClassGeneration>, |
||||
generateModulePath: Boolean |
||||
) { |
||||
logger.info("Configure accessors for '${commonSourceSet.name}'") |
||||
|
||||
fun buildDir(path: String) = layout.dir(layout.buildDirectory.map { File(it.asFile, path) }) |
||||
|
||||
//lazy check a dependency on the Resources library |
||||
val shouldGenerateResClass = generateResClassMode.map { mode -> |
||||
when (mode) { |
||||
ResourcesExtension.ResourceClassGeneration.Auto -> { |
||||
//todo remove the gradle property when the gradle plugin will be published |
||||
if (ComposeProperties.alwaysGenerateResourceAccessors(project).get()) { |
||||
true |
||||
} else { |
||||
configurations.run { |
||||
//because the implementation configuration doesn't extend the api in the KGP ¯\_(ツ)_/¯ |
||||
getByName(commonSourceSet.implementationConfigurationName).allDependencies + |
||||
getByName(commonSourceSet.apiConfigurationName).allDependencies |
||||
}.any { dep -> |
||||
val depStringNotation = dep.let { "${it.group}:${it.name}:${it.version}" } |
||||
depStringNotation == ComposePlugin.CommonComponentsDependencies.resources |
||||
} |
||||
} |
||||
} |
||||
ResourcesExtension.ResourceClassGeneration.Always -> { |
||||
true |
||||
} |
||||
ResourcesExtension.ResourceClassGeneration.Never -> { |
||||
false |
||||
} |
||||
} |
||||
} |
||||
|
||||
val genTask = tasks.register( |
||||
"generateComposeResClass", |
||||
GenerateResClassTask::class.java |
||||
) { task -> |
||||
task.packageName.set(resourcePackage) |
||||
task.shouldGenerateResClass.set(shouldGenerateResClass) |
||||
task.makeResClassPublic.set(publicResClass) |
||||
task.resDir.set(commonComposeResourcesDir) |
||||
task.codeDir.set(buildDir("$RES_GEN_DIR/kotlin")) |
||||
|
||||
if (generateModulePath) { |
||||
task.moduleDir.set(resourcePackage.asModuleDir()) |
||||
} |
||||
} |
||||
|
||||
//register generated source set |
||||
commonSourceSet.kotlin.srcDir(genTask.map { it.codeDir }) |
||||
|
||||
//setup task execution during IDE import |
||||
tasks.configureEach { |
||||
if (it.name == "prepareKotlinIdeaImport") { |
||||
it.dependsOn(genTask) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun Provider<String>.asModuleDir() = map { File("$COMPOSE_RESOURCES_DIR/$it") } |
||||
|
||||
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API |
||||
internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() { |
||||
@get:Inject |
||||
abstract val fileSystem: FileSystemOperations |
||||
|
||||
@get:InputFiles |
||||
@get:IgnoreEmptyDirectories |
||||
abstract val from: Property<File> |
||||
|
||||
@get:OutputDirectory |
||||
abstract val outputDirectory: DirectoryProperty |
||||
|
||||
@TaskAction |
||||
fun action() { |
||||
fileSystem.copy { |
||||
it.includeEmptyDirs = false |
||||
it.from(from) |
||||
it.include("**/font*/*") |
||||
it.into(outputDirectory) |
||||
} |
||||
} |
||||
} |
@ -1,56 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources.ios |
||||
|
||||
import org.gradle.api.provider.Property |
||||
import org.gradle.api.provider.SetProperty |
||||
import org.gradle.api.tasks.Input |
||||
import java.io.ObjectInputStream |
||||
import java.io.ObjectOutputStream |
||||
import java.io.Serializable |
||||
|
||||
internal abstract class IosTargetResources : Serializable { |
||||
@get:Input |
||||
abstract val name: Property<String> |
||||
|
||||
@get:Input |
||||
abstract val konanTarget: Property<String> |
||||
|
||||
@get:Input |
||||
abstract val dirs: SetProperty<String> |
||||
|
||||
@Suppress("unused") // used by Gradle Configuration Cache |
||||
fun readObject(input: ObjectInputStream) { |
||||
name.set(input.readUTF()) |
||||
konanTarget.set(input.readUTF()) |
||||
dirs.set(input.readUTFStrings()) |
||||
} |
||||
|
||||
@Suppress("unused") // used by Gradle Configuration Cache |
||||
fun writeObject(output: ObjectOutputStream) { |
||||
output.writeUTF(name.get()) |
||||
output.writeUTF(konanTarget.get()) |
||||
output.writeUTFStrings(dirs.get()) |
||||
} |
||||
|
||||
private fun ObjectOutputStream.writeUTFStrings(collection: Collection<String>) { |
||||
writeInt(collection.size) |
||||
collection.forEach { writeUTF(it) } |
||||
} |
||||
|
||||
private fun ObjectInputStream.readUTFStrings(): Set<String> { |
||||
val size = readInt() |
||||
return LinkedHashSet<String>(size).apply { |
||||
repeat(size) { |
||||
add(readUTF()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
companion object { |
||||
private const val serialVersionUID: Long = 0 |
||||
} |
||||
} |
@ -1,108 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources.ios |
||||
|
||||
import org.gradle.api.file.DirectoryProperty |
||||
import org.gradle.api.file.FileCollection |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.provider.SetProperty |
||||
import org.gradle.api.tasks.* |
||||
import org.jetbrains.compose.experimental.uikit.tasks.AbstractComposeIosTask |
||||
import org.jetbrains.compose.internal.utils.clearDirs |
||||
import java.io.File |
||||
import kotlin.io.path.Path |
||||
import kotlin.io.path.pathString |
||||
import kotlin.io.path.relativeTo |
||||
|
||||
abstract class SyncComposeResourcesForIosTask : AbstractComposeIosTask() { |
||||
|
||||
private fun Provider<String>.orElseThrowMissingAttributeError(attribute: String): Provider<String> { |
||||
val noProvidedValue = "__NO_PROVIDED_VALUE__" |
||||
return this.orElse(noProvidedValue).map { |
||||
if (it == noProvidedValue) { |
||||
error( |
||||
"Could not infer iOS target $attribute. Make sure to build " + |
||||
"via XCode (directly or via Kotlin Multiplatform Mobile plugin for Android Studio)") |
||||
} |
||||
it |
||||
} |
||||
} |
||||
|
||||
@get:Input |
||||
val xcodeTargetPlatform: Provider<String> = |
||||
providers.gradleProperty("compose.ios.resources.platform") |
||||
.orElse(providers.environmentVariable("PLATFORM_NAME")) |
||||
.orElseThrowMissingAttributeError("platform") |
||||
|
||||
|
||||
@get:Input |
||||
val xcodeTargetArchs: Provider<List<String>> = |
||||
providers.gradleProperty("compose.ios.resources.archs") |
||||
.orElse(providers.environmentVariable("ARCHS")) |
||||
.orElseThrowMissingAttributeError("architectures") |
||||
.map { |
||||
it.split(",", " ").filter { it.isNotBlank() } |
||||
} |
||||
|
||||
@get:Input |
||||
internal val iosTargets: SetProperty<IosTargetResources> = objects.setProperty(IosTargetResources::class.java) |
||||
|
||||
@get:PathSensitive(PathSensitivity.ABSOLUTE) |
||||
@get:InputFiles |
||||
val resourceFiles: Provider<FileCollection> = xcodeTargetPlatform.zip(xcodeTargetArchs, ::Pair) |
||||
.map { (xcodeTargetPlatform, xcodeTargetArchs) -> |
||||
val allResources = objects.fileCollection() |
||||
val activeKonanTargets = determineIosKonanTargetsFromEnv(xcodeTargetPlatform, xcodeTargetArchs) |
||||
.mapTo(HashSet()) { it.name } |
||||
val dirsToInclude = iosTargets.get() |
||||
.filter { it.konanTarget.get() in activeKonanTargets } |
||||
.flatMapTo(HashSet()) { it.dirs.get() } |
||||
for (dirPath in dirsToInclude) { |
||||
val fileTree = objects.fileTree().apply { |
||||
setDir(layout.projectDirectory.dir(dirPath)) |
||||
include("**/*") |
||||
} |
||||
allResources.from(fileTree) |
||||
} |
||||
allResources |
||||
} |
||||
|
||||
@get:OutputDirectory |
||||
val outputDir: DirectoryProperty = objects.directoryProperty() |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
val outputDir = outputDir.get().asFile |
||||
fileOperations.clearDirs(outputDir) |
||||
val allResourceDirs = iosTargets.get().flatMapTo(HashSet()) { it.dirs.get().map { Path(it).toAbsolutePath() } } |
||||
|
||||
fun copyFileToOutputDir(file: File) { |
||||
for (dir in allResourceDirs) { |
||||
val path = file.toPath().toAbsolutePath() |
||||
if (path.startsWith(dir)) { |
||||
val targetFile = outputDir.resolve(path.relativeTo(dir).pathString) |
||||
file.copyTo(targetFile, overwrite = true) |
||||
return |
||||
} |
||||
} |
||||
|
||||
error( |
||||
buildString { |
||||
appendLine("Resource file '$file' does not belong to a known resource directory:") |
||||
allResourceDirs.forEach { |
||||
appendLine("* $it") |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
val resourceFiles = resourceFiles.get().files |
||||
for (file in resourceFiles) { |
||||
copyFileToOutputDir(file) |
||||
} |
||||
logger.info("Synced Compose resource files. Copied ${resourceFiles.size} files to $outputDir") |
||||
} |
||||
} |
@ -1,265 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources.ios |
||||
|
||||
import org.gradle.api.Project |
||||
import org.gradle.api.file.Directory |
||||
import org.gradle.api.provider.Provider |
||||
import org.gradle.api.tasks.Copy |
||||
import org.gradle.api.tasks.Input |
||||
import org.gradle.api.tasks.TaskContainer |
||||
import org.gradle.api.tasks.TaskAction |
||||
import org.jetbrains.compose.desktop.application.internal.ComposeProperties |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.asIosNativeTargetOrNull |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.cocoapodsExt |
||||
import org.jetbrains.compose.experimental.uikit.internal.utils.withCocoapodsPlugin |
||||
import org.jetbrains.compose.experimental.uikit.tasks.AbstractComposeIosTask |
||||
import org.jetbrains.compose.internal.utils.joinLowerCamelCase |
||||
import org.jetbrains.compose.internal.utils.new |
||||
import org.jetbrains.compose.internal.utils.registerOrConfigure |
||||
import org.jetbrains.compose.internal.utils.uppercaseFirstChar |
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension |
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.* |
||||
import java.io.File |
||||
|
||||
private val incompatiblePlugins = listOf( |
||||
"dev.icerock.mobile.multiplatform-resources", |
||||
"io.github.skeptick.libres", |
||||
) |
||||
|
||||
private const val IOS_COMPOSE_RESOURCES_ROOT_DIR = "compose-resources" |
||||
|
||||
internal fun Project.configureSyncTask(mppExt: KotlinMultiplatformExtension) { |
||||
fun reportSyncIsDisabled(reason: String) { |
||||
logger.info("Compose Multiplatform resource management for iOS is disabled: $reason") |
||||
} |
||||
|
||||
if (!ComposeProperties.syncResources(providers).get()) { |
||||
reportSyncIsDisabled("'${ComposeProperties.SYNC_RESOURCES_PROPERTY}' value is 'false'") |
||||
return |
||||
} |
||||
|
||||
for (incompatiblePluginId in incompatiblePlugins) { |
||||
if (project.plugins.hasPlugin(incompatiblePluginId)) { |
||||
reportSyncIsDisabled("resource management is not compatible with '$incompatiblePluginId'") |
||||
return |
||||
} |
||||
} |
||||
|
||||
with(SyncIosResourcesContext(project, mppExt)) { |
||||
configureSyncResourcesTasks() |
||||
configureCocoapodsResourcesAttribute() |
||||
} |
||||
} |
||||
|
||||
private class SyncIosResourcesContext( |
||||
val project: Project, |
||||
val mppExt: KotlinMultiplatformExtension |
||||
) { |
||||
fun syncDirFor(framework: Framework): Provider<Directory> { |
||||
val providers = framework.project.providers |
||||
return if (framework.isCocoapodsFramework) { |
||||
project.layout.buildDirectory.dir("compose/ios/${framework.baseName}/$IOS_COMPOSE_RESOURCES_ROOT_DIR/") |
||||
} else { |
||||
providers.environmentVariable("BUILT_PRODUCTS_DIR") |
||||
.zip(providers.environmentVariable("CONTENTS_FOLDER_PATH")) { builtProductsDir, contentsFolderPath -> |
||||
File(builtProductsDir) |
||||
.resolve(contentsFolderPath) |
||||
.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR) |
||||
.canonicalPath |
||||
}.flatMap { |
||||
framework.project.objects.directoryProperty().apply { set(File(it)) } |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun configureEachIosTestExecutable(fn: (TestExecutable) -> Unit) { |
||||
mppExt.targets.all { target -> |
||||
target.asIosNativeTargetOrNull()?.let { iosTarget -> |
||||
iosTarget.binaries.withType(TestExecutable::class.java).configureEach { bin -> |
||||
fn(bin) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun configureEachIosFramework(fn: (Framework) -> Unit) { |
||||
mppExt.targets.all { target -> |
||||
target.asIosNativeTargetOrNull()?.let { iosTarget -> |
||||
iosTarget.binaries.withType(Framework::class.java).configureEach { framework -> |
||||
fn(framework) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private const val RESOURCES_SPEC_ATTR = "resources" |
||||
private fun SyncIosResourcesContext.configureCocoapodsResourcesAttribute() { |
||||
project.withCocoapodsPlugin { |
||||
project.gradle.taskGraph.whenReady { |
||||
val cocoapodsExt = mppExt.cocoapodsExt |
||||
val specAttributes = cocoapodsExt.extraSpecAttributes |
||||
val resourcesSpec = specAttributes[RESOURCES_SPEC_ATTR] |
||||
if (!resourcesSpec.isNullOrBlank()) { |
||||
error(""" |
||||
|Kotlin.cocoapods.extraSpecAttributes["resources"] is not compatible with Compose Multiplatform's resources management for iOS. |
||||
| * Recommended action: remove extraSpecAttributes["resources"] from '${project.buildFile}' and run '${project.path}:podInstall' once; |
||||
| * Alternative action: turn off Compose Multiplatform's resources management for iOS by adding '${ComposeProperties.SYNC_RESOURCES_PROPERTY}=false' to your gradle.properties; |
||||
""".trimMargin()) |
||||
} |
||||
cocoapodsExt.framework { |
||||
val syncDir = syncDirFor(this).get().asFile |
||||
specAttributes[RESOURCES_SPEC_ATTR] = "['${syncDir.relativeTo(project.projectDir).path}']" |
||||
project.tasks.named("podInstall").configure { |
||||
it.doFirst { |
||||
syncDir.mkdirs() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Since Xcode 15, there is a new default setting: `ENABLE_USER_SCRIPT_SANDBOXING = YES` |
||||
* It's set in project.pbxproj |
||||
* |
||||
* SyncComposeResourcesForIosTask fails to work with it right now. |
||||
* |
||||
* Gradle attempts to create an output folder for SyncComposeResourcesForIosTask on our behalf, |
||||
* so we can't handle an exception when it occurs. Therefore, we make SyncComposeResourcesForIosTask |
||||
* depend on CheckCanAccessComposeResourcesDirectory, where we check ENABLE_USER_SCRIPT_SANDBOXING. |
||||
*/ |
||||
internal abstract class CheckCanAccessComposeResourcesDirectory : AbstractComposeIosTask() { |
||||
|
||||
@get:Input |
||||
val enabled = providers.environmentVariable("ENABLE_USER_SCRIPT_SANDBOXING") |
||||
.orElse("NOT_DEFINED") |
||||
.map { it == "YES" } |
||||
|
||||
private val errorMessage = """ |
||||
|Failed to sync compose resources! |
||||
|Please make sure ENABLE_USER_SCRIPT_SANDBOXING is set to 'NO' in 'project.pbxproj' |
||||
|""".trimMargin() |
||||
|
||||
@TaskAction |
||||
fun run() { |
||||
if (enabled.get()) { |
||||
logger.error(errorMessage) |
||||
throw IllegalStateException( |
||||
"Sandbox environment detected (ENABLE_USER_SCRIPT_SANDBOXING = YES). It's not supported so far." |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
private fun SyncIosResourcesContext.configureSyncResourcesTasks() { |
||||
val lazyTasksDependencies = LazyTasksDependencyConfigurator(project.tasks) |
||||
configureEachIosFramework { framework -> |
||||
val frameworkClassifier = framework.namePrefix.uppercaseFirstChar() |
||||
val syncResourcesTaskName = framework.getSyncResourcesTaskName() |
||||
val checkSyncResourcesTaskName = "checkCanSync${frameworkClassifier}ComposeResourcesForIos" |
||||
val checkNoSandboxTask = framework.project.tasks.registerOrConfigure<CheckCanAccessComposeResourcesDirectory>(checkSyncResourcesTaskName) {} |
||||
val syncTask = framework.project.tasks.registerOrConfigure<SyncComposeResourcesForIosTask>(syncResourcesTaskName) { |
||||
dependsOn(checkNoSandboxTask) |
||||
outputDir.set(syncDirFor(framework)) |
||||
iosTargets.add(iosTargetResourcesProvider(framework)) |
||||
} |
||||
with (lazyTasksDependencies) { |
||||
if (framework.isCocoapodsFramework) { |
||||
"syncFramework".lazyDependsOn(syncTask.name) |
||||
} else { |
||||
"embedAndSign${frameworkClassifier}AppleFrameworkForXcode".lazyDependsOn(syncTask.name) |
||||
} |
||||
} |
||||
} |
||||
configureEachIosTestExecutable { bin -> |
||||
val copyTestResourcesTask = "copyTestComposeResourcesFor${bin.target.targetName.uppercaseFirstChar()}" |
||||
val task = project.tasks.registerOrConfigure<Copy>(copyTestResourcesTask) { |
||||
from({ |
||||
(bin.compilation.associateWith + bin.compilation).flatMap { compilation -> |
||||
compilation.allKotlinSourceSets.map { it.resources } |
||||
} |
||||
}) |
||||
into(bin.outputDirectory.resolve(IOS_COMPOSE_RESOURCES_ROOT_DIR)) |
||||
} |
||||
bin.linkTask.dependsOn(task) |
||||
} |
||||
} |
||||
|
||||
internal fun Framework.getSyncResourcesTaskName() = |
||||
"sync${namePrefix.uppercaseFirstChar()}ComposeResourcesForIos" |
||||
|
||||
private val Framework.isCocoapodsFramework: Boolean |
||||
get() = name.startsWith("pod") |
||||
|
||||
private val Framework.namePrefix: String |
||||
get() = extractPrefixFromBinaryName( |
||||
name, |
||||
buildType, |
||||
outputKind.taskNameClassifier |
||||
) |
||||
|
||||
private fun extractPrefixFromBinaryName(name: String, buildType: NativeBuildType, outputKindClassifier: String): String { |
||||
val suffix = joinLowerCamelCase(buildType.getName(), outputKindClassifier) |
||||
return if (name == suffix) |
||||
"" |
||||
else |
||||
name.substringBeforeLast(suffix.uppercaseFirstChar()) |
||||
} |
||||
|
||||
private fun iosTargetResourcesProvider(bin: NativeBinary): Provider<IosTargetResources> { |
||||
val kotlinTarget = bin.target |
||||
val project = bin.project |
||||
return project.provider { |
||||
val resourceDirs = bin.compilation.allKotlinSourceSets |
||||
.flatMap { sourceSet -> |
||||
sourceSet.resources.srcDirs.map { it.canonicalPath } |
||||
} |
||||
project.objects.new<IosTargetResources>().apply { |
||||
name.set(kotlinTarget.name) |
||||
konanTarget.set(kotlinTarget.konanTarget.name) |
||||
dirs.set(resourceDirs) |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Ensures, that a dependency between tasks is set up, |
||||
* when a dependent task (fromTask) is created, while avoiding eager configuration. |
||||
*/ |
||||
private class LazyTasksDependencyConfigurator(private val tasks: TaskContainer) { |
||||
private val existingDependencies = HashSet<Pair<String, String>>() |
||||
private val requestedDependencies = HashMap<String, MutableSet<String>>() |
||||
|
||||
init { |
||||
tasks.configureEach { fromTask -> |
||||
val onTasks = requestedDependencies.remove(fromTask.name) ?: return@configureEach |
||||
for (onTaskName in onTasks) { |
||||
val dependency = fromTask.name to onTaskName |
||||
if (existingDependencies.add(dependency)) { |
||||
fromTask.dependsOn(onTaskName) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun String.lazyDependsOn(dependencyTask: String) { |
||||
val dependingTask = this |
||||
val dependency = dependingTask to dependencyTask |
||||
if (dependency in existingDependencies) return |
||||
|
||||
if (dependingTask in tasks.names) { |
||||
tasks.named(dependingTask).configure { it.dependsOn(dependencyTask) } |
||||
existingDependencies.add(dependency) |
||||
} else { |
||||
requestedDependencies |
||||
.getOrPut(dependingTask) { HashSet() } |
||||
.add(dependencyTask) |
||||
} |
||||
} |
||||
} |
@ -1,38 +0,0 @@
|
||||
/* |
||||
* Copyright 2020-2023 JetBrains s.r.o. and respective authors and developers. |
||||
* Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. |
||||
*/ |
||||
|
||||
package org.jetbrains.compose.resources.ios |
||||
|
||||
import org.jetbrains.kotlin.konan.target.KonanTarget |
||||
|
||||
// based on AppleSdk.kt from Kotlin Gradle Plugin |
||||
// See https://github.com/JetBrains/kotlin/blob/142421da5b966049b4eab44ce6856eb172cf122a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/apple/AppleSdk.kt |
||||
internal fun determineIosKonanTargetsFromEnv(platform: String, archs: List<String>): List<KonanTarget> { |
||||
val targets: MutableSet<KonanTarget> = mutableSetOf() |
||||
|
||||
when { |
||||
platform.startsWith("iphoneos") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_ARM64 |
||||
"armv7", "armv7s" -> KonanTarget.IOS_ARM32 |
||||
else -> error("Unknown iOS device arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
platform.startsWith("iphonesimulator") -> { |
||||
targets.addAll(archs.map { arch -> |
||||
when (arch) { |
||||
"arm64", "arm64e" -> KonanTarget.IOS_SIMULATOR_ARM64 |
||||
"x86_64" -> KonanTarget.IOS_X64 |
||||
else -> error("Unknown iOS simulator arch: '$arch'") |
||||
} |
||||
}) |
||||
} |
||||
else -> error("Unknown iOS platform: '$platform'") |
||||
} |
||||
|
||||
return targets.toList() |
||||
} |
@ -0,0 +1,16 @@
|
||||
package org.jetbrains.compose.test.tests.unit |
||||
|
||||
import org.jetbrains.compose.resources.handleSpecialCharacters |
||||
import kotlin.test.Test |
||||
import kotlin.test.assertEquals |
||||
|
||||
class TestEscapedResourceSymbols { |
||||
|
||||
@Test |
||||
fun testEscapedSymbols() { |
||||
assertEquals( |
||||
"abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \\u355g", |
||||
handleSpecialCharacters("""abc \n \\n \t \\t \u1234 \ua45f \\u1234 \\ \u355g""") |
||||
) |
||||
} |
||||
} |
@ -1,53 +0,0 @@
|
||||
plugins { |
||||
id "org.jetbrains.kotlin.multiplatform" |
||||
id "org.jetbrains.compose" |
||||
id "dev.icerock.mobile.multiplatform-resources" |
||||
} |
||||
|
||||
kotlin { |
||||
iosX64 { |
||||
binaries.framework { |
||||
baseName = "shared" |
||||
isStatic = true |
||||
} |
||||
} |
||||
iosArm64 { |
||||
binaries.framework { |
||||
baseName = "shared" |
||||
isStatic = true |
||||
} |
||||
} |
||||
iosSimulatorArm64 { |
||||
binaries.framework { |
||||
baseName = "shared" |
||||
isStatic = true |
||||
} |
||||
} |
||||
|
||||
sourceSets { |
||||
def commonMain = named("commonMain") { |
||||
dependencies { |
||||
implementation(compose.runtime) |
||||
implementation(compose.foundation) |
||||
implementation(compose.material) |
||||
implementation("dev.icerock.moko:resources-compose:MOKO_RESOURCES_PLUGIN_VERSION_PLACEHOLDER") // for compose multiplatform |
||||
} |
||||
} |
||||
def iosMain = create("iosMain") { |
||||
dependsOn(commonMain.get()) |
||||
} |
||||
named("iosX64Main") { |
||||
dependsOn(iosMain) |
||||
} |
||||
named("iosArm64Main") { |
||||
dependsOn(iosMain) |
||||
} |
||||
named("iosSimulatorArm64Main") { |
||||
dependsOn(iosMain) |
||||
} |
||||
} |
||||
} |
||||
|
||||
multiplatformResources { |
||||
multiplatformResourcesPackage = "org.example" |
||||
} |
@ -1 +0,0 @@
|
||||
org.jetbrains.compose.experimental.uikit.enabled=true |
@ -1,27 +0,0 @@
|
||||
pluginManagement { |
||||
plugins { |
||||
id 'org.jetbrains.kotlin.multiplatform' version 'KOTLIN_VERSION_PLACEHOLDER' |
||||
id 'org.jetbrains.compose' version 'COMPOSE_GRADLE_PLUGIN_VERSION_PLACEHOLDER' |
||||
id 'dev.icerock.mobile.multiplatform-resources' version 'MOKO_RESOURCES_PLUGIN_VERSION_PLACEHOLDER' |
||||
} |
||||
repositories { |
||||
mavenLocal() |
||||
gradlePluginPortal() |
||||
mavenCentral() |
||||
google() |
||||
maven { |
||||
url 'https://maven.pkg.jetbrains.space/public/p/compose/dev' |
||||
} |
||||
} |
||||
} |
||||
dependencyResolutionManagement { |
||||
repositories { |
||||
mavenLocal() |
||||
mavenCentral() |
||||
google() |
||||
maven { |
||||
url 'https://maven.pkg.jetbrains.space/public/p/compose/dev' |
||||
} |
||||
} |
||||
} |
||||
rootProject.name = "iosResources" |
@ -1,10 +0,0 @@
|
||||
import androidx.compose.material.MaterialTheme |
||||
import androidx.compose.material.Text |
||||
import androidx.compose.runtime.Composable |
||||
|
||||
@Composable |
||||
fun App() { |
||||
MaterialTheme { |
||||
Text("Hello, World!") |
||||
} |
||||
} |
Loading…
Reference in new issue