diff --git a/build-support/build.gradle b/build-support/build.gradle index 2b898124b9..9db80c5d0e 100644 --- a/build-support/build.gradle +++ b/build-support/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation libs.spotlessPlugin implementation libs.androidGradlePlugin implementation libs.jetbrains.compose.gradlePlugin + implementation libs.zipline // Expose the generated version catalog API to the plugin. implementation(files(libs.class.superclass.protectionDomain.codeSource.location)) diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildExtension.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildExtension.kt index c47f279f86..97717ca18c 100644 --- a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildExtension.kt +++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildExtension.kt @@ -26,6 +26,16 @@ interface RedwoodBuildExtension { */ fun publishing() - /** Bundle a zip with dependencies and startup scripts. */ - fun application(name: String, mainClass: String) + /** Bundle a zip with dependencies and startup scripts for a CLI. */ + fun cliApplication(name: String, mainClass: String) + + /** Bundle a zip of a Zipline application's compiled `.zipline` files. */ + fun ziplineApplication() + + /** + * Consume a Zipline application in an Android application and embed it within assets. + * + * @name Name of the Treehouse application. Will be used to prefix the Zipline manifest file. + */ + fun embedZiplineApplication(dependencyNotation: Any, name: String) } diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt index cc5dd72cd9..edaabae527 100644 --- a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt +++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/RedwoodBuildPlugin.kt @@ -24,9 +24,11 @@ import org.gradle.accessors.dm.LibrariesForLibs import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.attributes.Attribute import org.gradle.api.plugins.JavaApplication import org.gradle.api.publish.PublishingExtension import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.testing.AbstractTestTask import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL @@ -344,7 +346,7 @@ private class RedwoodBuildExtensionImpl(private val project: Project) : RedwoodB } } - override fun application(name: String, mainClass: String) { + override fun cliApplication(name: String, mainClass: String) { project.plugins.apply("application") val application = project.extensions.getByName("application") as JavaApplication @@ -374,4 +376,73 @@ private class RedwoodBuildExtensionImpl(private val project: Project) : RedwoodB } } } + + override fun ziplineApplication() { + var hasZipline = false + project.afterEvaluate { + check(hasZipline) { + "Project ${project.path} must have Zipline plugin to create Zipline application" + } + } + project.plugins.withId("app.cash.zipline") { + hasZipline = true + + val ziplineConfiguration = project.configurations.create("zipline") { + it.isVisible = false + it.isCanBeResolved = false + it.isCanBeConsumed = true + it.attributes { + it.attribute(ziplineAttribute, ziplineAttributeValue) + } + } + + val zipTask = project.tasks.register("zipProductionZiplineFiles", Zip::class.java) { + // Note: This makes assumptions about our setup having a JS target with the default name. + it.from(project.tasks.named("compileProductionExecutableKotlinJsZipline")) + it.destinationDirectory.set(project.layout.buildDirectory.dir("libs")) + it.archiveClassifier.set("zipline") + } + + project.artifacts.add(ziplineConfiguration.name, zipTask) + } + } + + override fun embedZiplineApplication(dependencyNotation: Any, name: String) { + var hasApplication = false + project.afterEvaluate { + check(hasApplication) { + "Project ${project.path} must have Android Application plugin to embed Zipline application" + } + } + project.plugins.withId("com.android.application") { + hasApplication = true + + // Note: This will crash if you call it twice. We don't need this today, so it's not supported. + val ziplineConfiguration = project.configurations.create("zipline") { + it.isVisible = false + it.isCanBeResolved = true + it.isCanBeConsumed = false + it.attributes { + it.attribute(ziplineAttribute, ziplineAttributeValue) + } + } + project.dependencies.add(ziplineConfiguration.name, dependencyNotation) + + val task = project.tasks.register("copyEmbeddedZiplineApplication", ZiplineAppAssetCopyTask::class.java) { + it.appName.set(name) + it.files.setFrom(ziplineConfiguration) + } + + val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) + androidComponents.onVariants { + val assets = checkNotNull(it.sources.assets) { + "Project ${project.path} assets must be enabled to embed Zipline application" + } + assets.addGeneratedSourceDirectory(task, ZiplineAppAssetCopyTask::outputDirectory) + } + } + } } + +private val ziplineAttribute = Attribute.of("zipline", String::class.java) +private const val ziplineAttributeValue = "yep" diff --git a/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppAssetCopyTask.kt b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppAssetCopyTask.kt new file mode 100644 index 0000000000..8d2587b006 --- /dev/null +++ b/build-support/src/main/kotlin/app/cash/redwood/buildsupport/ZiplineAppAssetCopyTask.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2023 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.buildsupport + +import app.cash.zipline.ZiplineManifest +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +private const val ZIPLINE_MANIFEST_JSON = "manifest.zipline.json" + +internal abstract class ZiplineAppAssetCopyTask + @Inject constructor(private val archiveOperations: ArchiveOperations, +): DefaultTask() { + @get:InputFiles + abstract val files: ConfigurableFileCollection + + @get:Input + abstract val appName: Property + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun execute() { + val zipFile = files.singleFile + val fileMap = archiveOperations.zipTree(zipFile) + .files + .associateBy { it.name } + .toMutableMap() + val inputManifestFile = checkNotNull(fileMap.remove(ZIPLINE_MANIFEST_JSON)) { + "No zipline.manifest.json file found in input files ${fileMap.keys}" + } + val inputManifest = ZiplineManifest.decodeJson(inputManifestFile.readText()) + val outputManifest = inputManifest.copy( + unsigned = inputManifest.unsigned.copy( + freshAtEpochMs = System.currentTimeMillis(), + ) + ) + + val outputDirectory = outputDirectory.get() + outputDirectory.asFile.apply { + deleteRecursively() + mkdirs() + } + + val outputManifestFile = outputDirectory.file("${appName.get()}.$ZIPLINE_MANIFEST_JSON").asFile + outputManifestFile.writeText(outputManifest.encodeJson()) + + for (module in outputManifest.modules.values) { + val inputFile = checkNotNull(fileMap.remove(module.url)) { + "No ${module.url} file found in input files as specified by the manifest" + } + val outputFile = outputDirectory.file(module.sha256.hex()).asFile + inputFile.copyTo(outputFile) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8022546994..51cda601a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ jbCompose = "1.5.2" lint = "31.1.1" zipline = "1.3.0" coil = "2.4.0" +okio = "3.6.0" [libraries] kotlin-compiler = { module = "org.jetbrains.kotlin:kotlin-compiler", version.ref = "kotlin" } @@ -62,7 +63,8 @@ junit = { module = "junit:junit", version = "4.13.2" } testParameterInjector = { module = "com.google.testparameterinjector:test-parameter-injector", version = "1.12" } assertk = "com.willowtreeapps.assertk:assertk:0.27.0" robolectric = { module = "org.robolectric:robolectric", version = "4.10.3" } -okio = { module = "com.squareup.okio:okio", version = "3.6.0" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +okio-assetfilesystem = { module = "com.squareup.okio:okio-assetfilesystem", version.ref = "okio" } okHttp = { module = "com.squareup.okhttp3:okhttp", version = "4.11.0" } paging-compose-common = { module = "app.cash.paging:paging-compose-common", version = "3.2.0-alpha05-0.2.3" } zipline = { module = "app.cash.zipline:zipline", version.ref = "zipline" } diff --git a/redwood-tooling-codegen/build.gradle b/redwood-tooling-codegen/build.gradle index 8b66210ebc..f1ec039a38 100644 --- a/redwood-tooling-codegen/build.gradle +++ b/redwood-tooling-codegen/build.gradle @@ -2,7 +2,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm' redwoodBuild { publishing() - application('redwood-codegen', 'app.cash.redwood.tooling.codegen.Main') + cliApplication('redwood-codegen', 'app.cash.redwood.tooling.codegen.Main') } dependencies { diff --git a/redwood-tooling-lint/build.gradle b/redwood-tooling-lint/build.gradle index e297dbb9f1..c7848ea654 100644 --- a/redwood-tooling-lint/build.gradle +++ b/redwood-tooling-lint/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'org.jetbrains.kotlin.plugin.serialization' redwoodBuild { publishing() - application('redwood-lint', 'app.cash.redwood.tooling.lint.Main') + cliApplication('redwood-lint', 'app.cash.redwood.tooling.lint.Main') } dependencies { diff --git a/redwood-tooling-schema/build.gradle b/redwood-tooling-schema/build.gradle index 9776e8cc86..9a195eb2a6 100644 --- a/redwood-tooling-schema/build.gradle +++ b/redwood-tooling-schema/build.gradle @@ -6,7 +6,7 @@ apply plugin: 'org.jetbrains.kotlin.plugin.serialization' redwoodBuild { publishing() - application('redwood-schema', 'app.cash.redwood.tooling.schema.Main') + cliApplication('redwood-schema', 'app.cash.redwood.tooling.schema.Main') } dependencies { diff --git a/samples/emoji-search/android-composeui/build.gradle b/samples/emoji-search/android-composeui/build.gradle index 17aedcd769..a380980f36 100644 --- a/samples/emoji-search/android-composeui/build.gradle +++ b/samples/emoji-search/android-composeui/build.gradle @@ -1,6 +1,10 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' +redwoodBuild { + embedZiplineApplication(projects.samples.emojiSearch.presenterTreehouse, 'emoji-search') +} + android { namespace 'com.example.redwood.emojisearch.android.composeui' @@ -28,4 +32,5 @@ dependencies { implementation projects.redwoodTreehouseHost implementation projects.redwoodTreehouseHostComposeui implementation projects.redwoodWidgetCompose + implementation libs.okio.assetfilesystem } diff --git a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt index 68f32d033b..5623cab78d 100644 --- a/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-composeui/src/main/kotlin/com/example/redwood/emojisearch/android/composeui/EmojiSearchActivity.kt @@ -52,6 +52,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import okhttp3.OkHttpClient +import okio.Path.Companion.toPath +import okio.assetfilesystem.asFileSystem @NoLiveLiterals class EmojiSearchActivity : ComponentActivity() { @@ -128,6 +130,8 @@ class EmojiSearchActivity : ComponentActivity() { httpClient = httpClient, manifestVerifier = ManifestVerifier.Companion.NO_SIGNATURE_CHECKS, eventListener = appEventListener, + embeddedDir = "/".toPath(), + embeddedFileSystem = applicationContext.assets.asFileSystem(), ) val manifestUrlFlow = flowOf("http://10.0.2.2:8080/manifest.zipline.json") diff --git a/samples/emoji-search/android-views/build.gradle b/samples/emoji-search/android-views/build.gradle index c677bb9dd5..53b969ba78 100644 --- a/samples/emoji-search/android-views/build.gradle +++ b/samples/emoji-search/android-views/build.gradle @@ -1,6 +1,10 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' +redwoodBuild { + embedZiplineApplication(projects.samples.emojiSearch.presenterTreehouse, 'emoji-search') +} + android { namespace "com.example.redwood.emojisearch.android.views" } @@ -18,4 +22,5 @@ dependencies { implementation projects.redwoodLazylayoutView implementation projects.redwoodTreehouse implementation projects.redwoodTreehouseHost + implementation libs.okio.assetfilesystem } diff --git a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt index 1ee6180be2..db3c77bffc 100644 --- a/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt +++ b/samples/emoji-search/android-views/src/main/kotlin/com/example/redwood/emojisearch/android/views/EmojiSearchActivity.kt @@ -48,6 +48,8 @@ import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okio.FileSystem import okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath +import okio.assetfilesystem.asFileSystem class EmojiSearchActivity : ComponentActivity() { private val scope: CoroutineScope = CoroutineScope(Main) @@ -119,6 +121,8 @@ class EmojiSearchActivity : ComponentActivity() { httpClient = httpClient, manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, eventListener = appEventListener, + embeddedDir = "/".toPath(), + embeddedFileSystem = applicationContext.assets.asFileSystem(), stateStore = FileStateStore( json = Json, fileSystem = FileSystem.SYSTEM, diff --git a/samples/emoji-search/presenter-treehouse/build.gradle b/samples/emoji-search/presenter-treehouse/build.gradle index 682920feae..cad75d8233 100644 --- a/samples/emoji-search/presenter-treehouse/build.gradle +++ b/samples/emoji-search/presenter-treehouse/build.gradle @@ -1,6 +1,10 @@ apply plugin: 'org.jetbrains.kotlin.multiplatform' apply plugin: 'app.cash.zipline' +redwoodBuild { + ziplineApplication() +} + kotlin { iosArm64() iosX64()