Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
compose-gradle-plugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-plugin" }
compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" }
kotlin-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" }
kotlin-poet = { module = "com.squareup:kotlinpoet", version.ref = "kotlin-poet" }
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
Expand Down
1 change: 1 addition & 0 deletions modules/gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {
implementation(libs.compose.gradle.plugin)
implementation(libs.kotlin.poet)
testImplementation(libs.junit)
testImplementation(libs.compose.compiler.gradle.plugin)
}

group = "org.jetbrains.compose.storytale"
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import java.nio.file.Files
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction

@CacheableTask
Expand All @@ -19,12 +21,16 @@ open class AndroidSourceGeneratorTask : DefaultTask() {
@Input
lateinit var appPackageName: String

@OutputDirectory
@get:OutputDirectory
lateinit var outputResourcesDir: File

@OutputDirectory
@get:OutputDirectory
lateinit var outputSourcesDir: File

@get:OutputFile
@get:Optional
var deviceTestManifestFile: File? = null

@TaskAction
fun generate() {
cleanup(outputSourcesDir)
Expand Down Expand Up @@ -72,32 +78,40 @@ open class AndroidSourceGeneratorTask : DefaultTask() {
private fun generateAndroidManifest() {
val androidManifestFile = File(outputResourcesDir, "AndroidManifest.xml")
Files.createDirectories(androidManifestFile.parentFile.toPath())
androidManifestFile.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>
<application
tools:replace="label, theme"
android:label="${StorytaleGradlePlugin.STORYTALE_NATIVE_APP_NAME}"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".stories.StorytaleAppActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustPan"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
val manifestXml = """
|<?xml version="1.0" encoding="utf-8"?>
|<!-- Generated by Storytale. Safe to delete or gitignore. -->
|<manifest
| xmlns:android="http://schemas.android.com/apk/res/android"
| xmlns:tools="http://schemas.android.com/tools"
| >
| <application
| tools:replace="label, theme"
| android:label="${StorytaleGradlePlugin.STORYTALE_NATIVE_APP_NAME}"
| android:theme="@android:style/Theme.Material.NoActionBar">
| <activity
| android:name="$appPackageName.StorytaleAppActivity"
| android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
| android:launchMode="singleInstance"
| android:windowSoftInputMode="adjustPan"
| android:exported="true">
| <intent-filter>
| <action android:name="android.intent.action.MAIN" />
| <category android:name="android.intent.category.LAUNCHER" />
| </intent-filter>
| </activity>
| </application>
|
|</manifest>
""".trimMargin()

androidManifestFile.writeText(manifestXml)

</manifest>
""".trimIndent(),
)
deviceTestManifestFile?.let { targetFile ->
if (!targetFile.exists() || targetFile.readText().contains("<!-- Generated by Storytale")) {
Files.createDirectories(targetFile.parentFile.toPath())
targetFile.writeText(manifestXml)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package org.jetbrains.compose.storytale.plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact
import org.jetbrains.kotlin.gradle.plugin.SubpluginOption
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget
Expand Down Expand Up @@ -33,16 +35,21 @@ class StorytaleGradlePlugin : KotlinCompilerPluginSupportPlugin {

private fun Project.processConfigurations(extension: StorytaleExtension) {
extension.targets.all {
when (this) {
val target = this
when (target) {
is KotlinJsIrTarget ->
when (wasmTargetType) {
KotlinWasmTargetType.JS -> processWasmCompilation(extension, this)
null -> processJsCompilation(extension, this)
when (target.wasmTargetType) {
KotlinWasmTargetType.JS -> processWasmCompilation(extension, target)
null -> processJsCompilation(extension, target)
else -> {}
}
is KotlinAndroidTarget -> processAndroidCompilation(extension, this)
is KotlinJvmTarget -> processJvmCompilation(extension, this)
is KotlinNativeTarget -> processNativeCompilation(extension, this)
is KotlinJvmTarget -> processJvmCompilation(extension, target)
is KotlinNativeTarget -> processNativeCompilation(extension, target)
else -> {
if (target.isAndroidTarget) {
processAndroidCompilation(extension, target)
}
}
}
}
}
Expand All @@ -66,3 +73,9 @@ class StorytaleGradlePlugin : KotlinCompilerPluginSupportPlugin {
val VERSION = BuildTimeConfig.projectVersion
}
}

private val KotlinTarget.isAndroidTarget: Boolean
get() = platformType == KotlinPlatformType.androidJvm ||
this is KotlinAndroidTarget ||
javaClass.name.contains("Android")

Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.internal
Expand Down Expand Up @@ -131,12 +130,31 @@ fun getArchivesFromResources(compilation: KotlinCompilation<*>): FileCollection
}.files
}

fun Project.execute(vararg args: String): String = ByteArrayOutputStream().apply {
exec {
commandLine(*args)
standardOutput = this@apply
fun String.capitalized(): String = replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }

fun runProcess(vararg args: String, workingDir: File? = null): String {
val process = ProcessBuilder(*args).apply {
if (workingDir != null) directory(workingDir)
}.start()
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0) {
val error = process.errorStream.bufferedReader().readText()
throw org.gradle.api.GradleException("Command failed with exit code $exitCode: ${args.joinToString(" ")}\n$error")
}
}.toString()
return output
}

fun runProcessIgnoreExit(vararg args: String, workingDir: File? = null): String {
val process = ProcessBuilder(*args).apply {
if (workingDir != null) directory(workingDir)
}.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
}

fun Project.execute(vararg args: String): String = runProcessIgnoreExit(*args)

@Suppress("SpellCheckingInspection")
fun File.createDirectory() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package org.jetbrains.compose.storytale.plugin

import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.testfixtures.ProjectBuilder
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.kotlinExtension
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test

class AndroidTargetSupportTest {

@Test
fun testAndroidApplicationConfiguration() {
val project = ProjectBuilder.builder().withName("appTest").build()
project.plugins.apply("com.android.application")
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
project.plugins.apply("org.jetbrains.kotlin.plugin.compose")
project.plugins.apply("org.jetbrains.compose")
project.plugins.apply("org.jetbrains.compose.storytale")

val android = project.extensions.getByName("android")
android.javaClass.getMethod("setNamespace", String::class.java).invoke(android, "org.storytale.test.app")
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)

val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
kotlin.androidTarget()

(project as ProjectInternal).evaluate()

// 1. Android DSL buildType verification
val buildTypes = android.javaClass.getMethod("getBuildTypes").invoke(android)
val storiesBuildType = buildTypes.javaClass.getMethod("findByName", String::class.java).invoke(buildTypes, "Stories")
assertNotNull("Stories buildType should be registered in android DSL", storiesBuildType)
val isDebuggable = storiesBuildType!!.javaClass.getMethod("isDebuggable").invoke(storiesBuildType) as Boolean
assertTrue("Stories buildType should be configured as debuggable", isDebuggable)

val sourceSet = project.kotlinExtension.sourceSets.findByName("androidStories")
assertNotNull("androidStories source set should be created for android application", sourceSet)
assertTrue(
"androidStories source set should include generated sources directory",
sourceSet!!.kotlin.srcDirs.any { it.path.contains("sources") },
)
val storyExtension = project.extensions.getByType(StorytaleExtension::class.java)
assertTrue(
"androidStories source set should include mainStoriesSourceSet srcDirs",
sourceSet.kotlin.srcDirs.containsAll(storyExtension.mainStoriesSourceSet.kotlin.srcDirs),
)

// 3. Task graph registration and metadata
val generateTask = project.tasks.findByName("androidStorytaleGenerate") as? AndroidSourceGeneratorTask
assertNotNull("androidStorytaleGenerate task should be registered", generateTask)
assertEquals("android", generateTask!!.title)
assertTrue("outputSourcesDir must be configured", generateTask.outputSourcesDir.path.contains("sources"))
assertTrue("outputResourcesDir must be configured", generateTask.outputResourcesDir.path.contains("resources"))

val runTask = project.tasks.findByName("androidStoriesRun")
assertNotNull("androidStoriesRun task should be registered for application target", runTask)
assertEquals(StorytaleGradlePlugin.STORYTALE_TASK_GROUP, runTask!!.group)
}

@Test
fun testAndroidApplicationMinSdkFloorConfigured() {
val project = ProjectBuilder.builder().withName("minSdkTest").build()
project.plugins.apply("com.android.application")
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
project.plugins.apply("org.jetbrains.kotlin.plugin.compose")
project.plugins.apply("org.jetbrains.compose")
project.plugins.apply("org.jetbrains.compose.storytale")

val android = project.extensions.getByName("android")
android.javaClass.getMethod("setNamespace", String::class.java).invoke(android, "org.storytale.test.minsdk")
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)

val defaultConfig = android.javaClass.getMethod("getDefaultConfig").invoke(android)
defaultConfig.javaClass.getMethod("setMinSdkVersion", Int::class.javaPrimitiveType).invoke(defaultConfig, 24)

val androidComponents = project.extensions.getByType(com.android.build.api.variant.ApplicationAndroidComponentsExtension::class.java)
var storiesMinSdk: Int? = null
androidComponents.onVariants(androidComponents.selector().withBuildType("Stories")) { variant ->
storiesMinSdk = variant.minSdk.apiLevel
}

val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
kotlin.androidTarget()

(project as ProjectInternal).evaluate()

assertNotNull("Stories variant should be evaluated", storiesMinSdk)
assertEquals("Stories variant minSdk must be raised to at least 30", 30, storiesMinSdk)
}

@Test
fun testAndroidLibraryConfiguration() {
val project = ProjectBuilder.builder().withName("libTest").build()
project.plugins.apply("com.android.library")
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
project.plugins.apply("org.jetbrains.kotlin.plugin.compose")
project.plugins.apply("org.jetbrains.compose")
project.plugins.apply("org.jetbrains.compose.storytale")

val android = project.extensions.getByName("android")
android.javaClass.getMethod("setNamespace", String::class.java).invoke(android, "org.storytale.test.lib")
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)

val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
kotlin.androidTarget()
val androidTest = kotlin.sourceSets.create("androidTest")

(project as ProjectInternal).evaluate()

val runTask = project.tasks.findByName("androidStoriesRun")
assertNotNull("androidStoriesRun task should be registered for android library target", runTask)
assertEquals(StorytaleGradlePlugin.STORYTALE_TASK_GROUP, runTask!!.group)

val generateTask = project.tasks.findByName("androidStorytaleGenerate") as? AndroidSourceGeneratorTask
assertNotNull("androidStorytaleGenerate task should be registered for library", generateTask)
assertNotNull("deviceTestManifestFile should be configured for library module", generateTask!!.deviceTestManifestFile)

assertNotNull("androidTest source set should exist for library target", androidTest)
assertTrue(
"androidTest source set should include generated storytale sources",
androidTest.kotlin.srcDirs.any { it.path.contains("sources") },
)
val storyExtension = project.extensions.getByType(StorytaleExtension::class.java)
assertTrue(
"androidTest source set should include mainStoriesSourceSet srcDirs",
androidTest.kotlin.srcDirs.containsAll(storyExtension.mainStoriesSourceSet.kotlin.srcDirs),
)
}

@Test
fun testAndroidKotlinMultiplatformLibraryConfiguration() {
val project = ProjectBuilder.builder().withName("kmpLibTest").build()
project.plugins.apply("com.android.kotlin.multiplatform.library")
project.plugins.apply("org.jetbrains.kotlin.multiplatform")
project.plugins.apply("org.jetbrains.kotlin.plugin.compose")
project.plugins.apply("org.jetbrains.compose")
project.plugins.apply("org.jetbrains.compose.storytale")

val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java)
val androidLib = kotlin.extensions.findByName("androidLibrary")
assertNotNull("androidLibrary extension should exist on kotlin", androidLib)
val setNamespaceMethod = androidLib!!.javaClass.methods.firstOrNull { it.name == "setNamespace" && it.parameterCount == 1 }
setNamespaceMethod?.invoke(androidLib, "org.storytale.test.kmplib")
val setCompileSdkMethod = androidLib.javaClass.methods.firstOrNull {
(it.name == "setCompileSdk" || it.name == "setCompileSdkVersion") && it.parameterCount == 1
}
setCompileSdkMethod?.invoke(androidLib, 34)

(project as ProjectInternal).evaluate()

val runTask = project.tasks.findByName("androidStoriesRun")
?: project.tasks.firstOrNull { it.name.endsWith("StoriesRun") }
assertNotNull("StoriesRun task should be registered for KMP library target", runTask)
assertEquals(StorytaleGradlePlugin.STORYTALE_TASK_GROUP, runTask!!.group)

val generateTask = project.tasks.firstOrNull { it.name.endsWith("StorytaleGenerate") }
assertNotNull("StorytaleGenerate task should be registered for KMP library target", generateTask)
}

@Test
fun testAndroidComponentsHelperResolution() {
val project = ProjectBuilder.builder().withName("helperTest").build()
project.plugins.apply("com.android.application")
val android = project.extensions.getByName("android")
android.javaClass.getMethod("setNamespace", String::class.java).invoke(android, "org.storytale.helper.app")

val adbPath = AndroidComponentsHelper.resolveAdbPath(project)
assertTrue("Adb path should be resolved", adbPath.isNotBlank())

val fallbackAppId = AndroidComponentsHelper.resolveFallbackApplicationId(project, "Stories")
assertEquals("Fallback application ID should use namespace and variant", "org.storytale.helper.app.Stories", fallbackAppId)
}
}
Loading