Browse Source

Pack all resources to assets on the android target. (#4965)

The PR changes the android resources packaging. Now all resources are
packed to the android assets (not only fonts). It unblocks usage android
URIs to the resources in a WebView or other external resource consumers.
Additionally the PR fixes Android Studio Compose Previews work with
multiplatform resources:


![](https://private-user-images.githubusercontent.com/3532155/341182790-ef26b667-ad0d-4efd-b7f9-23cff92ab49d.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTg4Nzg0MTgsIm5iZiI6MTcxODg3ODExOCwicGF0aCI6Ii8zNTMyMTU1LzM0MTE4Mjc5MC1lZjI2YjY2Ny1hZDBkLTRlZmQtYjdmOS0yM2NmZjkyYWI0OWQucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI0MDYyMCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNDA2MjBUMTAwODM4WiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9OTY1MzdhMTAxMjNmZDRhMDA4ZjdjODBjYzg3M2MyNDg0ZTA5OWFkZGZkZjk1ZDUwOWFkZDk3MmQ2YjIzNzJiYiZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QmYWN0b3JfaWQ9MCZrZXlfaWQ9MCZyZXBvX2lkPTAifQ.xgUAr_2--ZHo6txhdAANRbe8ju2SQ5EACvK96gaGJnY)

For a backward compatibility the resources library tries to read
resources in java resources if assets were not found.

Fixes https://github.com/JetBrains/compose-multiplatform/issues/4877
Fixes https://github.com/JetBrains/compose-multiplatform/issues/4503
Fixes https://github.com/JetBrains/compose-multiplatform/issues/4932
Fixes https://github.com/JetBrains/compose-multiplatform/issues/4476

## Release Notes

### Features - Resources
- Android Studio Preview works with Compose Multiplatform resources now
- Compose Multiplatform resources are stored in the android assets now.
This fixes such cases as a rendering resource files in WebViews or Media
Players
pull/5006/head
Konstantin 5 months ago committed by GitHub
parent
commit
8fc3dd2f75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      components/build.gradle.kts
  2. 3
      components/gradle.properties
  3. 2
      components/gradle/libs.versions.toml
  4. 10
      components/resources/demo/shared/build.gradle.kts
  5. 18
      components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/main.android.kt
  6. 4
      components/resources/library/api/android/library.api
  7. 8
      components/resources/library/build.gradle.kts
  8. 13
      components/resources/library/src/androidMain/AndroidManifest.xml
  9. 81
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/AndroidContextProvider.kt
  10. 3
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/FontResources.android.kt
  11. 69
      components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt
  12. 6
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt
  13. 4
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt
  14. 6
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt
  15. 2
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringArrayResources.kt
  16. 4
      components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt
  17. 2
      components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt
  18. 2
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
  19. 7
      components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt
  20. 4
      components/settings.gradle.kts
  21. 2
      components/ui-tooling-preview/library/build.gradle.kts
  22. 96
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidResources.kt
  23. 34
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt
  24. 32
      gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/KmpResources.kt
  25. 74
      gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt

2
components/build.gradle.kts

@ -5,7 +5,7 @@ plugins {
}
subprojects {
version = findProperty("deploy.version") ?: property("compose.version")!!
version = findProperty("deploy.version")!!
plugins.withId("java") {
configureIfExists<JavaPluginExtension> {

3
components/gradle.properties

@ -8,12 +8,11 @@ android.useAndroidX=true
#Versions
kotlin.version=1.9.23
compose.version=1.6.10-beta02
agp.version=8.2.2
deploy.version=0.1.0-SNAPSHOT
#Compose
org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.wasm.enabled=true
org.jetbrains.compose.experimental.macos.enabled=true
compose.desktop.verbose=true
compose.useMavenLocal=false

2
components/gradle/libs.versions.toml

@ -14,3 +14,5 @@ androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-te
androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose" }
androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose" }

10
components/resources/demo/shared/build.gradle.kts

@ -52,6 +52,10 @@ kotlin {
desktopMain.dependencies {
implementation(compose.desktop.common)
}
androidMain.dependencies {
implementation(libs.androidx.ui.tooling)
implementation(libs.androidx.ui.tooling.preview)
}
val nonAndroidMain by creating {
dependsOn(commonMain.get())
@ -73,6 +77,12 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.11"
}
}
compose.experimental {

18
components/resources/demo/shared/src/androidMain/kotlin/org/jetbrains/compose/resources/demo/shared/main.android.kt

@ -5,9 +5,27 @@
package org.jetbrains.compose.resources.demo.shared
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.PreviewContextConfigurationEffect
@Composable
fun MainView() {
UseResources()
}
@Preview(showBackground = true)
@Composable
fun ImagesResPreview() {
ImagesRes(PaddingValues())
}
@OptIn(ExperimentalResourceApi::class)
@Preview(showBackground = true)
@Composable
fun FileResPreview() {
PreviewContextConfigurationEffect()
FileRes(PaddingValues())
}

4
components/resources/library/api/android/library.api

@ -1,3 +1,7 @@
public final class org/jetbrains/compose/resources/AndroidContextProviderKt {
public static final fun PreviewContextConfigurationEffect (Landroidx/compose/runtime/Composer;I)V
}
public final class org/jetbrains/compose/resources/DensityQualifier$Companion {
public final fun selectByDensity (F)Lorg/jetbrains/compose/resources/DensityQualifier;
public final fun selectByValue (I)Lorg/jetbrains/compose/resources/DensityQualifier;

8
components/resources/library/build.gradle.kts

@ -10,8 +10,6 @@ plugins {
id("org.jetbrains.kotlinx.binary-compatibility-validator")
}
val composeVersion = extra["compose.version"] as String
kotlin {
jvm("desktop")
androidTarget {
@ -187,6 +185,7 @@ android {
assets.srcDir("src/androidInstrumentedTest/assets")
}
named("test") { resources.srcDir(commonTestResources) }
named("main") { manifest.srcFile("src/androidMain/AndroidManifest.xml") }
}
}
@ -202,11 +201,6 @@ apiValidation {
nonPublicMarkers.add("org.jetbrains.compose.resources.InternalResourceApi")
}
// adding it here to make sure skiko is unpacked and available in web tests
compose.experimental {
web.application {}
}
//utility task to generate CLDRPluralRuleLists.kt file by 'CLDRPluralRules/plurals.xml'
tasks.register<GeneratePluralRuleListsTask>("generatePluralRuleLists") {
val projectDir = project.layout.projectDirectory

13
components/resources/library/src/androidMain/AndroidManifest.xml

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:authorities="${applicationId}.resources.AndroidContextProvider"
android:name="org.jetbrains.compose.resources.AndroidContextProvider"
android:exported="false"
android:enabled="true">
</provider>
</application>
</manifest>

81
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/AndroidContextProvider.kt

@ -0,0 +1,81 @@
package org.jetbrains.compose.resources
import android.annotation.SuppressLint
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
internal val androidContext get() = AndroidContextProvider.ANDROID_CONTEXT
/**
* The function configures the android context
* to be used for non-composable resource read functions
*
* e.g. `Res.readBytes(...)`
*
* Example usage:
* ```
* @Preview
* @Composable
* fun MyPreviewComponent() {
* PreviewContextConfigurationEffect()
* //...
* }
* ```
*/
@ExperimentalResourceApi
@Composable
fun PreviewContextConfigurationEffect() {
if (LocalInspectionMode.current) {
AndroidContextProvider.ANDROID_CONTEXT = LocalContext.current
}
}
//https://andretietz.com/2017/09/06/autoinitialise-android-library/
internal class AndroidContextProvider : ContentProvider() {
companion object {
@SuppressLint("StaticFieldLeak")
var ANDROID_CONTEXT: Context? = null
}
override fun onCreate(): Boolean {
ANDROID_CONTEXT = context
return true
}
override fun attachInfo(context: Context, info: ProviderInfo?) {
if (info == null) {
throw NullPointerException("AndroidContextProvider ProviderInfo cannot be null.")
}
// So if the authorities equal the library internal ones, the developer forgot to set his applicationId
if ("org.jetbrains.compose.components.resources.resources.AndroidContextProvider" == info.authority) {
throw IllegalStateException("Incorrect provider authority in manifest. Most likely due to a "
+ "missing applicationId variable your application\'s build.gradle.")
}
super.attachInfo(context, info)
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
}

3
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/FontResources.android.kt

@ -9,5 +9,6 @@ import androidx.compose.ui.text.font.*
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val environment = LocalComposeEnvironment.current.rememberEnvironment()
val path = remember(environment, resource) { resource.getResourceItemByEnvironment(environment).path }
return Font(path, LocalContext.current.assets, weight, style)
val assets = LocalContext.current.assets
return Font(path, assets, weight, style)
}

69
components/resources/library/src/androidMain/kotlin/org/jetbrains/compose/resources/ResourceReader.android.kt

@ -1,9 +1,21 @@
package org.jetbrains.compose.resources
import java.io.File
import android.content.res.AssetManager
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import java.io.FileNotFoundException
import java.io.InputStream
internal actual fun getPlatformResourceReader(): ResourceReader = object : ResourceReader {
private val assets: AssetManager by lazy {
val context = androidContext ?: error(
"Android context is not initialized. " +
"If it happens in the Preview mode then call PreviewContextConfigurationEffect() function."
)
context.assets
}
override suspend fun read(path: String): ByteArray {
val resource = getResourceAsStream(path)
return resource.readBytes()
@ -33,39 +45,52 @@ internal actual fun getPlatformResourceReader(): ResourceReader = object : Resou
private fun InputStream.readBytes(byteArray: ByteArray, offset: Int, size: Int) {
var readBytes = 0
while (readBytes < size) {
val count = read(byteArray, offset + readBytes, size - readBytes)
val count = read(byteArray, offset + readBytes, size - readBytes)
if (count <= 0) break
readBytes += count
}
}
override fun getUri(path: String): String {
val classLoader = getClassLoader()
val resource = classLoader.getResource(path) ?: run {
//try to find a font in the android assets
if (File(path).isFontResource()) {
classLoader.getResource("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource.toURI().toString()
val uri = if (assets.hasFile(path)) {
Uri.parse("file:///android_asset/$path")
} else {
val classLoader = getClassLoader()
val resource = classLoader.getResource(path) ?: throw MissingResourceException(path)
resource.toURI()
}
return uri.toString()
}
private fun getResourceAsStream(path: String): InputStream {
val classLoader = getClassLoader()
val resource = classLoader.getResourceAsStream(path) ?: run {
//try to find a font in the android assets
if (File(path).isFontResource()) {
classLoader.getResourceAsStream("assets/$path")
} else null
} ?: throw MissingResourceException(path)
return resource
}
private fun File.isFontResource(): Boolean {
return this.parentFile?.name.orEmpty().startsWith("font")
return try {
assets.open(path)
} catch (e: FileNotFoundException) {
val classLoader = getClassLoader()
classLoader.getResourceAsStream(path) ?: throw MissingResourceException(path)
}
}
private fun getClassLoader(): ClassLoader {
return this.javaClass.classLoader ?: error("Cannot find class loader")
}
private fun AssetManager.hasFile(path: String): Boolean {
var inputStream: InputStream? = null
val result = try {
inputStream = open(path)
true
} catch (e: FileNotFoundException) {
false
} finally {
inputStream?.close()
}
return result
}
}
internal actual val ProvidableCompositionLocal<ResourceReader>.currentOrPreview: ResourceReader
@Composable get() {
PreviewContextConfigurationEffect()
return current
}

6
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ImageResources.kt

@ -55,7 +55,7 @@ private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) }
*/
@Composable
fun imageResource(resource: DrawableResource): ImageBitmap {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val imageBitmap by rememberResourceState(resource, resourceReader, { emptyImageBitmap }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
val cached = loadImage(path, resourceReader) {
@ -78,7 +78,7 @@ private val emptyImageVector: ImageVector by lazy {
*/
@Composable
fun vectorResource(resource: DrawableResource): ImageVector {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val density = LocalDensity.current
val imageVector by rememberResourceState(resource, resourceReader, density, { emptyImageVector }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
@ -98,7 +98,7 @@ private val emptySvgPainter: Painter by lazy { BitmapPainter(emptyImageBitmap) }
@Composable
private fun svgPainter(resource: DrawableResource): Painter {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val density = LocalDensity.current
val svgPainter by rememberResourceState(resource, resourceReader, density, { emptySvgPainter }) { env ->
val path = resource.getResourceItemByEnvironment(env).path

4
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/PluralStringResources.kt

@ -26,7 +26,7 @@ class PluralStringResource
*/
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int): String {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val pluralStr by rememberResourceState(resource, quantity, { "" }) { env ->
loadPluralString(resource, quantity, resourceReader, env)
}
@ -93,7 +93,7 @@ private suspend fun loadPluralString(
*/
@Composable
fun pluralStringResource(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val args = formatArgs.map { it.toString() }
val pluralStr by rememberResourceState(resource, quantity, args, { "" }) { env ->
loadPluralString(resource, quantity, args, resourceReader, env)

6
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ResourceReader.kt

@ -1,5 +1,7 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.staticCompositionLocalOf
class MissingResourceException(path: String) : Exception("Missing resource with path: $path")
@ -34,3 +36,7 @@ internal val DefaultResourceReader = getPlatformResourceReader()
//ResourceReader provider will be overridden for tests
internal val LocalResourceReader = staticCompositionLocalOf { DefaultResourceReader }
//For an android preview we need to initialize the resource reader with the local context
internal expect val ProvidableCompositionLocal<ResourceReader>.currentOrPreview: ResourceReader
@Composable get

2
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringArrayResources.kt

@ -30,7 +30,7 @@ class StringArrayResource
*/
@Composable
fun stringArrayResource(resource: StringArrayResource): List<String> {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val array by rememberResourceState(resource, { emptyList() }) { env ->
loadStringArray(resource, resourceReader, env)
}

4
components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/StringResources.kt

@ -23,7 +23,7 @@ class StringResource
*/
@Composable
fun stringResource(resource: StringResource): String {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val str by rememberResourceState(resource, { "" }) { env ->
loadString(resource, resourceReader, env)
}
@ -75,7 +75,7 @@ private suspend fun loadString(
*/
@Composable
fun stringResource(resource: StringResource, vararg formatArgs: Any): String {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val args = formatArgs.map { it.toString() }
val str by rememberResourceState(resource, args, { "" }) { env ->
loadString(resource, args, resourceReader, env)

2
components/resources/library/src/commonTest/kotlin/org/jetbrains/compose/resources/ComposeResourceTest.kt

@ -331,7 +331,7 @@ class ComposeResourceTest {
var uri2 = ""
setContent {
CompositionLocalProvider(LocalComposeEnvironment provides TestComposeEnvironment) {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
uri1 = resourceReader.getUri("1.png")
uri2 = resourceReader.getUri("2.png")
}

2
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt

@ -31,7 +31,7 @@ private val defaultEmptyFont by lazy { Font("org.jetbrains.compose.emptyFont", B
@Composable
actual fun Font(resource: FontResource, weight: FontWeight, style: FontStyle): Font {
val resourceReader = LocalResourceReader.current
val resourceReader = LocalResourceReader.currentOrPreview
val fontFile by rememberResourceState(resource, weight, style, { defaultEmptyFont }) { env ->
val path = resource.getResourceItemByEnvironment(env).path
val fontBytes = resourceReader.read(path)

7
components/resources/library/src/skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt

@ -0,0 +1,7 @@
package org.jetbrains.compose.resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
internal actual val ProvidableCompositionLocal<ResourceReader>.currentOrPreview: ResourceReader
@Composable get() = current

4
components/settings.gradle.kts

@ -12,10 +12,12 @@ pluginManagement {
plugins {
kotlin("jvm").version(extra["kotlin.version"] as String)
kotlin("multiplatform").version(extra["kotlin.version"] as String)
id("org.jetbrains.compose").version(extra["compose.version"] as String)
id("org.jetbrains.compose") //version is not required because the plugin is included to the build
id("com.android.library").version(extra["agp.version"] as String)
id("org.jetbrains.kotlinx.binary-compatibility-validator").version("0.15.0-Beta.2")
}
includeBuild("../gradle-plugins")
}
dependencyResolutionManagement {

2
components/ui-tooling-preview/library/build.gradle.kts

@ -7,8 +7,6 @@ plugins {
id("com.android.library")
}
val composeVersion = extra["compose.version"] as String
kotlin {
jvm("desktop")
androidTarget {

96
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/AndroidResources.kt

@ -1,7 +1,6 @@
package org.jetbrains.compose.resources
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask
import com.android.build.gradle.internal.lint.LintModelWriterTask
import org.gradle.api.DefaultTask
@ -10,49 +9,23 @@ import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileCollection
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.gradle.api.tasks.IgnoreEmptyDirectories
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
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 javax.inject.Inject
@OptIn(ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureAndroidComposeResources(
kotlinExtension: KotlinMultiplatformExtension,
androidExtension: BaseExtension
kotlinExtension: KotlinMultiplatformExtension
) {
// 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 ->
compilation.defaultSourceSet.androidSourceSetInfoOrNull?.let { kotlinAndroidSourceSet ->
androidExtension.sourceSets
.matching { it.name == kotlinAndroidSourceSet.androidSourceSetName }
.all { androidSourceSet ->
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet ->
val preparedComposeResources = getPreparedComposeResourcesDir(kotlinSourceSet)
androidSourceSet.resources.srcDirs(preparedComposeResources)
//fix for AGP < 8.0
//usually 'androidSourceSet.resources.srcDir(preparedCommonResources)' should be enough
compilation.androidVariant.processJavaResourcesProvider.configure {
it.dependsOn(preparedComposeResources)
}
}
}
}
}
}
//copy fonts from the compose resources dir to android assets
//copy all compose resources to android assets
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
val variantResources = project.files()
@ -60,7 +33,7 @@ internal fun Project.configureAndroidComposeResources(
kotlinExtension.targets.withType(KotlinAndroidTarget::class.java).all { androidTarget ->
androidTarget.compilations.all { compilation: KotlinJvmAndroidCompilation ->
if (compilation.androidVariant.name == variant.name) {
project.logger.info("Configure fonts for variant ${variant.name}")
project.logger.info("Configure resources for variant ${variant.name}")
(compilation.allKotlinSourceSets as? ObservableSet<KotlinSourceSet>)?.forAll { kotlinSourceSet ->
val preparedComposeResources = getPreparedComposeResourcesDir(kotlinSourceSet)
variantResources.from(preparedComposeResources)
@ -69,22 +42,32 @@ internal fun Project.configureAndroidComposeResources(
}
}
val copyFonts = registerTask<CopyAndroidFontsToAssetsTask>(
"copy${variant.name.uppercaseFirstChar()}FontsToAndroidAssets"
val copyResources = registerTask<CopyResourcesToAndroidAssetsTask>(
"copy${variant.name.uppercaseFirstChar()}ResourcesToAndroidAssets"
) {
from.set(variantResources)
}
variant.sources?.assets?.addGeneratedSourceDirectory(
taskProvider = copyFonts,
wiredWith = CopyAndroidFontsToAssetsTask::outputDirectory
)
//exclude a duplication of fonts in apks
variant.packaging.resources.excludes.add("**/font*/*")
variant.sources.assets?.apply {
addGeneratedSourceDirectory(
taskProvider = copyResources,
wiredWith = CopyResourcesToAndroidAssetsTask::outputDirectory
)
// addGeneratedSourceDirectory doesn't mark the output directory as assets hence AS Compose Preview doesn't work
addStaticSourceDirectory(copyResources.flatMap { it.outputDirectory.asFile }.get().path)
// addGeneratedSourceDirectory doesn't run the copyResources task during AS Compose Preview build
tasks.configureEach { task ->
if (task.name == "compile${variant.name.uppercaseFirstChar()}Sources") {
task.dependsOn(copyResources)
}
}
}
}
}
//Copy task doesn't work with 'variant.sources?.assets?.addGeneratedSourceDirectory' API
internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() {
internal abstract class CopyResourcesToAndroidAssetsTask : DefaultTask() {
@get:Inject
abstract val fileSystem: FileSystemOperations
@ -100,7 +83,6 @@ internal abstract class CopyAndroidFontsToAssetsTask : DefaultTask() {
fileSystem.copy {
it.includeEmptyDirs = false
it.from(from)
it.include("**/font*/*")
it.into(outputDirectory)
}
}
@ -121,5 +103,31 @@ internal fun Project.fixAndroidLintTaskDependencies() {
it is AndroidLintAnalysisTask || it is LintModelWriterTask
}.configureEach {
it.mustRunAfter(tasks.withType(GenerateResourceAccessorsTask::class.java))
it.mustRunAfter(tasks.withType(CopyResourcesToAndroidAssetsTask::class.java))
}
}
internal fun Project.configureAndroidAssetsForPreview() {
val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return
androidComponents.onVariants { variant ->
variant.sources.assets?.apply {
val kgpCopyAssetsTaskName = "${variant.name}AssetsCopyForAGP"
// addGeneratedSourceDirectory doesn't mark the output directory as assets hence AS Compose Preview doesn't work
tasks.all { task ->
if (task.name == kgpCopyAssetsTaskName) {
task.outputs.files.forEach { file ->
addStaticSourceDirectory(file.path)
}
}
}
// addGeneratedSourceDirectory doesn't run the copyResources task during AS Compose Preview build
tasks.configureEach { task ->
if (task.name == "compile${variant.name.uppercaseFirstChar()}Sources") {
task.dependsOn(kgpCopyAssetsTaskName)
}
}
}
}
}

34
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/ComposeResources.kt

@ -1,24 +1,17 @@
package org.jetbrains.compose.resources
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask
import com.android.build.gradle.internal.lint.LintModelWriterTask
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.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.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinBasePlugin
import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper
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"
@ -46,7 +39,10 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
if (kmpResourcesAreAvailable) {
configureKmpResources(kotlinExtension, extraProperties.get(KMP_RES_EXT)!!, config)
onAgpApplied { fixAndroidLintTaskDependencies() }
onAgpApplied {
configureAndroidAssetsForPreview()
fixAndroidLintTaskDependencies()
}
} else {
if (!disableMultimoduleResources) {
if (!hasKmpResources) logger.info(
@ -66,8 +62,8 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
val commonMain = KotlinSourceSet.COMMON_MAIN_SOURCE_SET_NAME
configureComposeResources(kotlinExtension, commonMain, config)
onAgpApplied { androidExtension ->
configureAndroidComposeResources(kotlinExtension, androidExtension)
onAgpApplied {
configureAndroidComposeResources(kotlinExtension)
fixAndroidLintTaskDependencies()
}
}
@ -75,11 +71,10 @@ private fun Project.onKgpApplied(config: Provider<ResourcesExtension>, kgp: Kotl
configureSyncIosComposeResources(kotlinExtension)
}
private fun Project.onAgpApplied(block: (androidExtension: BaseExtension) -> Unit) {
private fun Project.onAgpApplied(block: () -> Unit) {
androidPluginIds.forEach { pluginId ->
plugins.withId(pluginId) {
val androidExtension = project.extensions.getByType(BaseExtension::class.java)
block(androidExtension)
block()
}
}
}
@ -90,8 +85,6 @@ private fun Project.onKotlinJvmApplied(config: Provider<ResourcesExtension>) {
configureComposeResources(kotlinExtension, main, config)
}
// sourceSet.resources.srcDirs doesn't work for Android targets.
// Android resources should be configured separately
private fun Project.configureComposeResources(
kotlinExtension: KotlinProjectExtension,
resClassSourceSetName: String,
@ -100,7 +93,16 @@ private fun Project.configureComposeResources(
logger.info("Configure compose resources")
configureComposeResourcesGeneration(kotlinExtension, resClassSourceSetName, config, false)
// mark prepared resources as sourceSet.resources
// 1) it automatically packs the resources to JVM jars
// 2) it configures the webpack to use the resources
// 3) for native targets we will use source set resources to pack them into the final app. see IosResources.kt
// 4) for the android it DOESN'T pack resources! we copy resources to assets in AndroidResources.kt
kotlinExtension.sourceSets.all { sourceSet ->
sourceSet.resources.srcDirs(getPreparedComposeResourcesDir(sourceSet))
// the HACK is here because KGP copy androidMain java resources to Android target
// if the resources were registered in the androidMain source set before the target declaration
afterEvaluate {
sourceSet.resources.srcDirs(getPreparedComposeResourcesDir(sourceSet))
}
}
}

32
gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/resources/KmpResources.kt

@ -32,28 +32,26 @@ internal fun Project.configureKmpResources(
logger.info("Configure resources publication for '${target.targetName}' target")
val packedResourceDir = config.getModuleResourcesDir(project)
kmpResources.publishResourcesAsKotlinComponent(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
getPreparedComposeResourcesDir(sourceSet),
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")
if (target !is KotlinAndroidTarget) {
kmpResources.publishResourcesAsKotlinComponent(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
getPreparedComposeResourcesDir(sourceSet),
emptyList(),
emptyList()
)
},
packedResourceDir
)
} else {
//for android target publish resources in assets
kmpResources.publishInAndroidAssets(
target,
{ sourceSet ->
KotlinTargetResourcesPublication.ResourceRoot(
getPreparedComposeResourcesDir(sourceSet),
listOf("**/font*/*"),
emptyList(),
emptyList()
)
},

74
gradle-plugins/compose/src/test/kotlin/org/jetbrains/compose/test/tests/integration/ResourcesTest.kt

@ -251,32 +251,13 @@ class ResourcesTest : GradlePluginTestBase() {
"my-mvn/me/sample/library/cmplib-$target/1.0/cmplib-$target-1.0$ext"
val aar = file(libpath("android", ".aar"))
val innerClassesJar = aar.parentFile.resolve("aar-inner-classes.jar")
assertTrue(aar.exists(), "File not found: " + aar.path)
ZipFile(aar).use { zip ->
resourcesFiles
.filter { it.startsWith("font") }
.forEach { fontRes ->
ZipFile(aar).use { zip -> resourcesFiles.forEach { fontRes ->
assertNotNull(
zip.getEntry("assets/composeResources/$subdir/$fontRes"),
"Resource not found: '$fontRes' in aar '${aar.path}'"
)
}
innerClassesJar.writeBytes(
zip.getInputStream(zip.getEntry("classes.jar")).readBytes()
)
}
ZipFile(innerClassesJar).use { zip ->
resourcesFiles
.filterNot { it.startsWith("font") }
.forEach { res ->
assertNotNull(
zip.getEntry("composeResources/$subdir/$res"),
"Resource not found: '$res' in aar/classes.jar '${aar.path}'"
)
}
}
} }
val jar = file(libpath("jvm", ".jar"))
checkResourcesZip(jar, resourcesFiles, subdir)
@ -393,37 +374,37 @@ class ResourcesTest : GradlePluginTestBase() {
.getConvertedResources(commonResourcesDir)
gradle("build").checks {
check.taskSuccessful(":copyDemoDebugFontsToAndroidAssets")
check.taskSuccessful(":copyDemoReleaseFontsToAndroidAssets")
check.taskSuccessful(":copyFullDebugFontsToAndroidAssets")
check.taskSuccessful(":copyFullReleaseFontsToAndroidAssets")
check.taskSuccessful(":copyDemoDebugResourcesToAndroidAssets")
check.taskSuccessful(":copyDemoReleaseResourcesToAndroidAssets")
check.taskSuccessful(":copyFullDebugResourcesToAndroidAssets")
check.taskSuccessful(":copyFullReleaseResourcesToAndroidAssets")
getAndroidApk("demo", "debug", "Resources-Test").let { apk ->
checkResourcesInZip(apk, commonResourcesFiles, true)
assertEquals(
"android demo-debug",
readFileInZip(apk, "files/platform.txt").decodeToString()
readFileInZip(apk, "assets/files/platform.txt").decodeToString()
)
}
getAndroidApk("demo", "release", "Resources-Test").let { apk ->
checkResourcesInZip(apk, commonResourcesFiles, true)
assertEquals(
"android demo-release",
readFileInZip(apk, "files/platform.txt").decodeToString()
readFileInZip(apk, "assets/files/platform.txt").decodeToString()
)
}
getAndroidApk("full", "debug", "Resources-Test").let { apk ->
checkResourcesInZip(apk, commonResourcesFiles, true)
assertEquals(
"android full-debug",
readFileInZip(apk, "files/platform.txt").decodeToString()
readFileInZip(apk, "assets/files/platform.txt").decodeToString()
)
}
getAndroidApk("full", "release", "Resources-Test").let { apk ->
checkResourcesInZip(apk, commonResourcesFiles, true)
assertEquals(
"android full-release",
readFileInZip(apk, "files/platform.txt").decodeToString()
readFileInZip(apk, "assets/files/platform.txt").decodeToString()
)
}
@ -443,36 +424,6 @@ class ResourcesTest : GradlePluginTestBase() {
}
}
@Test
fun testAndroidFonts(): Unit = with(testProject("misc/commonResources")) {
val commonResourcesDir = file("src/commonMain/composeResources")
val commonResourcesFiles = commonResourcesDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.getConvertedResources(commonResourcesDir)
gradle("assembleDebug").checks {
check.taskSuccessful(":copyDebugFontsToAndroidAssets")
getAndroidApk("", "debug", "Resources-Test").let { apk ->
checkResourcesInZip(apk, commonResourcesFiles, true)
}
}
file("src/commonMain/composeResources/font-en").renameTo(
file("src/commonMain/composeResources/font-mdpi")
)
val newCommonResourcesFiles = commonResourcesDir.walkTopDown()
.filter { !it.isDirectory && !it.isHidden }
.getConvertedResources(commonResourcesDir)
gradle("assembleDebug").checks {
check.taskSuccessful(":copyDebugFontsToAndroidAssets")
getAndroidApk("", "debug", "Resources-Test").let { apk ->
checkResourcesInZip(apk, newCommonResourcesFiles, true)
}
}
}
private fun Sequence<File>.getConvertedResources(baseDir: File) = map { file ->
val newFile = if (
file.parentFile.name.startsWith("value") &&
@ -486,7 +437,6 @@ class ResourcesTest : GradlePluginTestBase() {
newFile.relativeTo(baseDir).invariantSeparatorsPath
}
private fun File.writeNewFile(text: String) {
parentFile.mkdirs()
createNewFile()
@ -507,8 +457,8 @@ class ResourcesTest : GradlePluginTestBase() {
ZipFile(file).use { zip ->
commonResourcesFiles.forEach { res ->
println("check '$res' file")
if (isAndroid && res.startsWith("font")) {
//android fonts should be only in assets
if (isAndroid) {
//android resources should be only in assets
assertNull(zip.getEntry(res), "file = '$res'")
assertNotNull(zip.getEntry("assets/$res"), "file = 'assets/$res'")
} else {

Loading…
Cancel
Save