diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 225b78c5e8..d3ecb2b3e0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Linter (gradle) - run: ./gradlew spotlessCheck :quartz:verifyKmpPurity + run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity build-desktop: needs: lint diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 2dc92ff7a3..684213eea6 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -75,6 +75,10 @@ kotlin { // Immutable collections api(libs.kotlinx.collections.immutable) + // JSON for custom-feed definitions (KMP — replaces Jackson + // for the one commonMain serializer that was blocking iOS). + implementation(libs.kotlinx.serialization.json) + // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) @@ -150,3 +154,51 @@ compose.resources { packageOfResClass = "com.vitorpamplona.amethyst.commons.resources" generateResClass = always } + +// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale +// there. Commons gains this gate once FeedDefinitionSerializer.kt has been +// migrated off Jackson; future commonMain code must not reintroduce JVM-only +// JSON / HTTP deps. +val verifyKmpPurity by tasks.registering { + group = "verification" + description = "Fails if iOS-targeted source sets import JVM-only deps." + val checkedDirs = + listOf( + "src/commonMain", "src/commonTest", + "src/appleMain", "src/appleTest", + "src/nativeMain", "src/nativeTest", + "src/iosMain", "src/iosTest", + "src/iosArm64Main", "src/iosArm64Test", + "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", + "src/linuxMain", "src/linuxTest", + "src/linuxX64Main", "src/linuxX64Test", + "src/macosMain", "src/macosTest", + "src/macosArm64Main", "src/macosArm64Test", + ).map { layout.projectDirectory.dir(it).asFile } + .filter { it.exists() } + inputs.files(checkedDirs) + doLast { + val forbidden = listOf("com.fasterxml.jackson", "okhttp3") + val offenders = + checkedDirs.flatMap { dir -> + dir.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .flatMap { file -> + file.readLines().withIndex().mapNotNull { (idx, line) -> + forbidden.firstOrNull { line.contains(it) }?.let { hit -> + "${file.relativeTo(rootDir)}:${idx + 1}: '$hit'" + } + } + } + } + if (offenders.isNotEmpty()) { + throw GradleException( + "iOS-targeted source sets must not reference JVM-only deps " + + "(Jackson, OkHttp). Move the offending code to jvmAndroid/ " + + "or behind an expect/actual:\n " + offenders.joinToString("\n "), + ) + } + } +} + +tasks.named("check").configure { dependsOn(verifyKmpPurity) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt index 397491563b..918cab2458 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializer.kt @@ -20,29 +20,37 @@ */ package com.vitorpamplona.amethyst.commons.feeds.custom -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object FeedDefinitionSerializer { - private val mapper = ObjectMapper() + private val json = Json { ignoreUnknownKeys = true } fun serializeList(feeds: List): String { - val array = mapper.createArrayNode() - feeds.forEach { feed -> array.add(serializeFeed(feed)) } - return mapper.writeValueAsString(array) + val array = + buildJsonArray { + feeds.forEach { add(serializeFeed(it)) } + } + return json.encodeToString(JsonArray.serializer(), array) } - fun deserializeList(json: String): List { - if (json.isBlank()) return emptyList() - val array = mapper.readTree(json) as? ArrayNode ?: return emptyList() - return array.mapNotNull { node -> deserializeFeed(node) } + fun deserializeList(jsonString: String): List { + if (jsonString.isBlank()) return emptyList() + val array = json.parseToJsonElement(jsonString) as? JsonArray ?: return emptyList() + return array.mapNotNull { node -> (node as? JsonObject)?.let { deserializeFeed(it) } } } - private fun serializeFeed(feed: FeedDefinition): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeFeed(feed: FeedDefinition): JsonObject = + buildJsonObject { put("id", feed.id) put("name", feed.name) put("emoji", feed.emoji) @@ -50,25 +58,25 @@ object FeedDefinitionSerializer { put("pinOrder", feed.pinOrder) put("refreshMode", feed.refreshMode.name) put("createdAt", feed.createdAt) - set("source", serializeSource(feed.source)) + put("source", serializeSource(feed.source)) } - private fun deserializeFeed(node: JsonNode): FeedDefinition? { - val id = node.get("id")?.asText() ?: return null - val name = node.get("name")?.asText() ?: return null - val emoji = node.get("emoji")?.asText() ?: "" - val pinned = node.get("pinned")?.asBoolean() ?: false - val pinOrder = node.get("pinOrder")?.asInt() ?: Int.MAX_VALUE + private fun deserializeFeed(node: JsonObject): FeedDefinition? { + val id = node.string("id") ?: return null + val name = node.string("name") ?: return null + val emoji = node.string("emoji") ?: "" + val pinned = node.bool("pinned") ?: false + val pinOrder = node.int("pinOrder") ?: Int.MAX_VALUE val refreshMode = - node.get("refreshMode")?.asText()?.let { + node.string("refreshMode")?.let { try { RefreshMode.valueOf(it) } catch (_: Exception) { RefreshMode.LIVE_STREAM } } ?: RefreshMode.LIVE_STREAM - val createdAt = node.get("createdAt")?.asLong() ?: 0L - val source = node.get("source")?.let { deserializeSource(it) } ?: return null + val createdAt = node.long("createdAt") ?: 0L + val source = (node["source"] as? JsonObject)?.let { deserializeSource(it) } ?: return null return FeedDefinition( id = id, @@ -82,17 +90,20 @@ object FeedDefinitionSerializer { ) } - private fun serializeSource(source: FeedSource): ObjectNode = - mapper.createObjectNode().apply { + private fun serializeSource(source: FeedSource): JsonObject = + buildJsonObject { when (source) { is FeedSource.Filter -> { put("type", "filter") - set("hashtags", mapper.valueToTree(source.hashtags.toList())) - set("authors", mapper.valueToTree(source.authors.toList())) - set("relays", mapper.valueToTree(source.relays.toList())) - set("excludeAuthors", mapper.valueToTree(source.excludeAuthors.toList())) - set("excludeKeywords", mapper.valueToTree(source.excludeKeywords.toList())) - set("kinds", mapper.valueToTree(source.kinds.toList())) + put("hashtags", stringArray(source.hashtags)) + put("authors", stringArray(source.authors)) + put("relays", stringArray(source.relays)) + put("excludeAuthors", stringArray(source.excludeAuthors)) + put("excludeKeywords", stringArray(source.excludeKeywords)) + put( + "kinds", + buildJsonArray { source.kinds.forEach { add(JsonPrimitive(it)) } }, + ) } is FeedSource.PeopleList -> { @@ -131,61 +142,73 @@ object FeedDefinitionSerializer { } } - private fun deserializeSource(node: JsonNode): FeedSource? { - val type = node.get("type")?.asText() ?: return null + private fun deserializeSource(node: JsonObject): FeedSource? { + val type = node.string("type") ?: return null return when (type) { "filter" -> { FeedSource.Filter( - hashtags = node.get("hashtags")?.map { it.asText() }?.toImmutableList() ?: return null, - authors = node.get("authors")?.map { it.asText() }?.toImmutableList() ?: return null, - relays = node.get("relays")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeAuthors = node.get("excludeAuthors")?.map { it.asText() }?.toImmutableList() ?: return null, - excludeKeywords = node.get("excludeKeywords")?.map { it.asText() }?.toImmutableList() ?: return null, - kinds = node.get("kinds")?.map { it.asInt() }?.toImmutableList() ?: return null, + hashtags = node.stringList("hashtags") ?: return null, + authors = node.stringList("authors") ?: return null, + relays = node.stringList("relays") ?: return null, + excludeAuthors = node.stringList("excludeAuthors") ?: return null, + excludeKeywords = node.stringList("excludeKeywords") ?: return null, + kinds = node.intList("kinds") ?: return null, ) } "people_list" -> { FeedSource.PeopleList( - kind = node.get("kind")?.asInt() ?: 30000, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30000, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "interest_set" -> { FeedSource.InterestSet( - kind = node.get("kind")?.asInt() ?: 30015, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 30015, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "dvm" -> { FeedSource.DVM( - kind = node.get("kind")?.asInt() ?: 31990, - pubkey = node.get("pubkey")?.asText() ?: return null, - dTag = node.get("dTag")?.asText() ?: return null, + kind = node.int("kind") ?: 31990, + pubkey = node.string("pubkey") ?: return null, + dTag = node.string("dTag") ?: return null, ) } "single_relay" -> { FeedSource.SingleRelay( - url = node.get("url")?.asText() ?: return null, + url = node.string("url") ?: return null, ) } - "global" -> { - FeedSource.Global - } - - "following" -> { - FeedSource.Following - } - - else -> { - null - } + "global" -> FeedSource.Global + "following" -> FeedSource.Following + else -> null } } + + private fun stringArray(values: Iterable): JsonArray = buildJsonArray { values.forEach { add(JsonPrimitive(it)) } } + + private fun JsonObject.string(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun JsonObject.bool(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull + + private fun JsonObject.int(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.long(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.stringList(key: String) = + (this[key] as? JsonArray) + ?.map { (it as? JsonPrimitive)?.content.orEmpty() } + ?.toImmutableList() + + private fun JsonObject.intList(key: String) = + (this[key] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.intOrNull } + ?.toImmutableList() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt index 0badc90af2..b82cce5228 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -212,6 +212,33 @@ class FeedDefinitionSerializerTest { assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) } + @Test + fun parsesLegacyJacksonOutput() { + // Wire format previously emitted by ObjectMapper. Users have feed + // definitions stored on disk in exactly this shape — the new + // kotlinx.serialization-backed parser must keep accepting it. + val legacy = + """[{"id":"x","name":"Bitcoin","emoji":"₿","pinned":true,"pinOrder":0,""" + + """"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],"authors":["abc"],""" + + """"relays":[],"excludeAuthors":[],"excludeKeywords":[],"kinds":[1,6]}}]""" + + val feeds = FeedDefinitionSerializer.deserializeList(legacy) + assertEquals(1, feeds.size) + val feed = feeds[0] + assertEquals("x", feed.id) + assertEquals("Bitcoin", feed.name) + assertEquals("₿", feed.emoji) + assertTrue(feed.pinned) + assertEquals(0, feed.pinOrder) + assertEquals(RefreshMode.LIVE_STREAM, feed.refreshMode) + assertEquals(1000L, feed.createdAt) + val source = feed.source as FeedSource.Filter + assertEquals(persistentListOf("bitcoin", "btc"), source.hashtags) + assertEquals(persistentListOf("abc"), source.authors) + assertEquals(persistentListOf(1, 6), source.kinds) + } + @Test fun defaultFeedsAreValid() { val defaults = defaultFeeds()