Skip to content
Merged
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
43 changes: 43 additions & 0 deletions plugin/src/main/kotlin/xyz/block/microfilm/Manifest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
*/
package xyz.block.microfilm

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path
import xyz.block.microfilm.Manifest.Compressor

@Serializable
Expand All @@ -38,6 +42,27 @@ internal data class Manifest(val entries: List<Entry> = emptyList()) {
val compressionMethod: Int?,
val metadata: String?,
)

internal fun toJson(): String =
JSON_SERIALIZER.encodeToString(serializer = serializer(), value = this) + "\n"

companion object {
internal fun fromJson(json: String): Manifest =
JSON_DESERIALIZER.decodeFromString(string = json)

@OptIn(ExperimentalSerializationApi::class)
private val JSON_SERIALIZER = Json {
encodeDefaults = true
explicitNulls = false
prettyPrint = true
prettyPrintIndent = " "
}

private val JSON_DESERIALIZER = Json {
explicitNulls = false
ignoreUnknownKeys = true
}
}
}

internal fun ImageSettings.Compress.toCompressor(cwebpVersion: String) =
Expand All @@ -49,3 +74,21 @@ internal fun ImageSettings.Compress.toCompressor(cwebpVersion: String) =
compressionMethod = compressionMethod,
metadata = metadata?.toString(),
)

/** Reads a manifest from disk, or returns an empty manifest if there isn't an existing file. */
internal fun FileSystem.readManifest(path: Path): Manifest =
if (exists(path = path)) {
read(file = path) { readUtf8() }.let { string -> Manifest.fromJson(json = string) }
} else {
Manifest()
}

/** Writes the manifest to disk, or deletes the existing file if the manifest is empty. */
internal fun FileSystem.writeManifest(path: Path, manifest: Manifest) {
if (manifest.entries.isEmpty()) {
delete(path = path)
} else {
path.parent?.let { parent -> createDirectories(dir = parent) }
write(file = path) { writeUtf8(manifest.toJson()) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@
package xyz.block.microfilm.compression

import javax.inject.Inject
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.gradle.api.DefaultTask
Expand All @@ -39,6 +37,7 @@ import xyz.block.microfilm.compression.Compressor.Result.Failure
import xyz.block.microfilm.compression.Compressor.Result.Success
import xyz.block.microfilm.cwebp.RealCwebp
import xyz.block.microfilm.scanning.RealScanner
import xyz.block.microfilm.writeManifest

@DisableCachingByDefault(because = "This task modifies the source tree in place")
internal abstract class CompressTask
Expand Down Expand Up @@ -123,14 +122,7 @@ constructor(private val execOperations: ExecOperations) : DefaultTask() {
)

// Write the manifest to disk
if (manifest.entries.isEmpty()) {
fileSystem.delete(path = microfilmManifestPath)
} else {
fileSystem.createDirectories(dir = microfilmDirectoryPath)
fileSystem.write(file = microfilmManifestPath) {
writeUtf8(JSON.encodeToString(serializer = Manifest.serializer(), value = manifest) + "\n")
}
}
fileSystem.writeManifest(path = microfilmManifestPath, manifest = manifest)

// Fail if any images could not be compressed
val failures = results.filterIsInstance<Failure>()
Expand All @@ -143,14 +135,4 @@ constructor(private val execOperations: ExecOperations) : DefaultTask() {
)
}
}

companion object {
@OptIn(ExperimentalSerializationApi::class)
private val JSON = Json {
encodeDefaults = true
explicitNulls = false
prettyPrint = true
prettyPrintIndent = " "
}
}
}
18 changes: 3 additions & 15 deletions plugin/src/main/kotlin/xyz/block/microfilm/scanning/RealScanner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@
*/
package xyz.block.microfilm.scanning

import kotlinx.serialization.json.Json
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import xyz.block.microfilm.Manifest
import xyz.block.microfilm.isPngDrawable
import xyz.block.microfilm.isWebpDrawable
import xyz.block.microfilm.listRecursivelyOrEmpty
import xyz.block.microfilm.readManifest

/** A [Scanner] backed by an Okio [FileSystem]. */
internal class RealScanner(
Expand Down Expand Up @@ -54,14 +53,8 @@ internal class RealScanner(
// Read the manifest entries
val microfilmManifestPath = microfilmDirectory.resolve("manifest.json")
val microfilmManifestEntries =
if (fileSystem.exists(path = microfilmManifestPath)) {
fileSystem
.read(file = microfilmManifestPath) { readUtf8() }
.let<String, Manifest> { string -> JSON.decodeFromString(string = string) }
.entries
.associateBy { entry -> entry.sourcePath.toPath().key }
} else {
emptyMap()
fileSystem.readManifest(path = microfilmManifestPath).entries.associateBy { entry ->
entry.sourcePath.toPath().key
}

// Group the images for each unique key
Expand All @@ -85,11 +78,6 @@ internal class RealScanner(
}
}

private val JSON = Json {
explicitNulls = false
ignoreUnknownKeys = true
}

private val Path.key
get() = buildList {
addAll(segments.dropLast(n = 1))
Expand Down
119 changes: 119 additions & 0 deletions plugin/src/test/kotlin/xyz/block/microfilm/ManifestTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2026 Block, 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 xyz.block.microfilm

import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import okio.fakefilesystem.FakeFileSystem
import org.junit.jupiter.api.Test
import xyz.block.microfilm.scanning.ImageGroupFixtures.LOSSLESS_MANIFEST_ENTRY
import xyz.block.microfilm.scanning.ImageGroupFixtures.LOSSY_MANIFEST_ENTRY
import xyz.block.microfilm.scanning.ImageGroupFixtures.MICROFILM_DIRECTORY

class ManifestTest {
private val fileSystem = FakeFileSystem()

@Test
fun `toJson serializes Manifest`() {
assertThat(LOSSLESS_MANIFEST.toJson()).isEqualTo(LOSSLESS_JSON)
}

@Test
fun `fromJson deserializes Manifest`() {
assertThat(Manifest.fromJson(json = LOSSLESS_JSON)).isEqualTo(LOSSLESS_MANIFEST)
}

@Test
fun `readManifest without existing file returns empty manifest`() {
val manifest = fileSystem.readManifest(path = MANIFEST_PATH)

assertThat(manifest).isEqualTo(EMPTY_MANIFEST)
}

@Test
fun `readManifest with existing file returns valid manifest`() {
fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST)

val manifest = fileSystem.readManifest(path = MANIFEST_PATH)

assertThat(manifest).isEqualTo(LOSSLESS_MANIFEST)
}

@Test
fun `writeManifest empty manifest without existing file does nothing`() {
fileSystem.writeManifest(path = MANIFEST_PATH, manifest = EMPTY_MANIFEST)

assertThat(fileSystem.exists(path = MANIFEST_PATH)).isFalse()
}

@Test
fun `writeManifest empty manifest with existing file deletes existing file`() {
fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST)

fileSystem.writeManifest(path = MANIFEST_PATH, manifest = EMPTY_MANIFEST)

assertThat(fileSystem.exists(path = MANIFEST_PATH)).isFalse()
}

@Test
fun `writeManifest valid manifest without existing file writes new file`() {
fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST)

assertThat(fileSystem.exists(path = MANIFEST_PATH)).isTrue()
assertThat(fileSystem.readManifest(path = MANIFEST_PATH)).isEqualTo(LOSSLESS_MANIFEST)
}

@Test
fun `writeManifest valid manifest with existing file overwrites existing file`() {
fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSY_MANIFEST)

fileSystem.writeManifest(path = MANIFEST_PATH, manifest = LOSSLESS_MANIFEST)

assertThat(fileSystem.exists(path = MANIFEST_PATH)).isTrue()
assertThat(fileSystem.readManifest(path = MANIFEST_PATH)).isEqualTo(LOSSLESS_MANIFEST)
}

companion object {
val MANIFEST_PATH = MICROFILM_DIRECTORY.resolve(child = "manifest.json")

private val EMPTY_MANIFEST = Manifest()
private val LOSSLESS_MANIFEST = Manifest(entries = listOf(LOSSLESS_MANIFEST_ENTRY))
private val LOSSY_MANIFEST = Manifest(entries = listOf(LOSSY_MANIFEST_ENTRY))

private val LOSSLESS_JSON =
"""
{
"entries": [
{
"sourcePath": "drawable/photo.png",
"sourceSha256": "8f8cbb7dcf46e0bc7d53265749a6c17d116093a6ba95e442764060c76fd4a86c",
"compressedPath": "drawable/photo.webp",
"compressedSha256": "a57bb082e728a0cdce930ecfcccf4510a3a247be5f322b09b3a971a3f5ed34f8",
"compressor": {
"name": "cwebp",
"version": "1.2.3",
"lossless": true
}
}
]
}

"""
.trimIndent()
}
}