Skip to content

Commit

Permalink
Embed the Zipline app in Android app assets
Browse files Browse the repository at this point in the history
This allows the app to function without the server running, or without ever having to had connect to the server in the past. It will also allow automated testing of these applications in the future.
  • Loading branch information
JakeWharton committed Oct 5, 2023
1 parent 4cc6e10 commit cb055d8
Show file tree
Hide file tree
Showing 13 changed files with 191 additions and 7 deletions.
1 change: 1 addition & 0 deletions build-support/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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<String>

@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)
}
}
}
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion redwood-tooling-codegen/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion redwood-tooling-lint/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion redwood-tooling-schema/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions samples/emoji-search/android-composeui/build.gradle
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -28,4 +32,5 @@ dependencies {
implementation projects.redwoodTreehouseHost
implementation projects.redwoodTreehouseHostComposeui
implementation projects.redwoodWidgetCompose
implementation libs.okio.assetfilesystem
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions samples/emoji-search/android-views/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
}
Expand All @@ -18,4 +22,5 @@ dependencies {
implementation projects.redwoodLazylayoutView
implementation projects.redwoodTreehouse
implementation projects.redwoodTreehouseHost
implementation libs.okio.assetfilesystem
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions samples/emoji-search/presenter-treehouse/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
apply plugin: 'org.jetbrains.kotlin.multiplatform'
apply plugin: 'app.cash.zipline'

redwoodBuild {
ziplineApplication()
}

kotlin {
iosArm64()
iosX64()
Expand Down

0 comments on commit cb055d8

Please sign in to comment.