From 81d9b31f6c0c4441f9e73e1ea9acc6e78bee3df7 Mon Sep 17 00:00:00 2001 From: fankesyooni Date: Wed, 1 Oct 2025 02:16:56 +0800 Subject: [PATCH] Add AdapterRegistryGenerator and related functionality for default adapter registration --- .../codegen/api/AdapterRegistryGenerator.kt | 117 ++++++++++++++++++ .../moshi/kotlin/codegen/api/Options.kt | 21 ++++ .../moshi/kotlin/codegen/api/ProguardRules.kt | 14 ++- .../ksp/JsonClassSymbolProcessorProvider.kt | 45 ++++++- .../src/main/java/com/squareup/moshi/Moshi.kt | 35 ++++++ .../java/com/squareup/moshi/internal/Util.kt | 16 ++- 6 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/AdapterRegistryGenerator.kt diff --git a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/AdapterRegistryGenerator.kt b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/AdapterRegistryGenerator.kt new file mode 100644 index 0000000..c500d16 --- /dev/null +++ b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/AdapterRegistryGenerator.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2018 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 + * + * https://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 com.squareup.moshi.kotlin.codegen.api + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.WildcardTypeName +import com.squareup.kotlinpoet.asClassName + +private const val DEFAULT_ADAPTER_REGISTRY_PACKAGE_NAME_PREFIX = "com.squareup.moshi.generated" +private const val DEFAULT_ADAPTER_REGISTRY_CLASS_NAME = "DefaultMoshiAdapterRegistry" + +public data class AdapterRegistration( + val targetClassName: ClassName, + val adapterClassName: ClassName, + val hasTypeParameters: Boolean +) + +/** Generates a registry of adapters. */ +@InternalMoshiCodegenApi +public class AdapterRegistryGenerator( + private val adapterRegistrations: List +) { + + public fun generate(): FileSpec { + // Get the top-level package name of the project (inferred from the shortest package name) + val packageName = run { + val firstPackage = adapterRegistrations + .filter { it.targetClassName.packageName.isNotEmpty() } + .minByOrNull { it.targetClassName.packageName.length } + ?.targetClassName?.packageName + + firstPackage + } ?: "default" // fallback package name + + // Generate a package hash of the package name to avoid potential collisions. + val packageHash = packageName.hashCode().toUInt().toString(16).take(12) + val registryPackageName = "$DEFAULT_ADAPTER_REGISTRY_PACKAGE_NAME_PREFIX.r$packageHash" + + val moshiClass = ClassName("com.squareup.moshi", "Moshi") + val adapterRegistryClass = moshiClass.nestedClass("AdapterRegistry") + val jsonAdapterClass = ClassName("com.squareup.moshi", "JsonAdapter") + val typeClass = ClassName("java.lang.reflect", "Type") + + val adaptersMapBuilder = CodeBlock.builder() + adaptersMapBuilder.add("mapOf(") + + val nonGenericAdapters = adapterRegistrations.filter { !it.hasTypeParameters } + nonGenericAdapters.forEachIndexed { index, registration -> + if (index > 0) adaptersMapBuilder.add(",") + adaptersMapBuilder.add( + "\n %T::class.java to %T::class.java", + registration.targetClassName, + registration.adapterClassName + ) + } + + if (nonGenericAdapters.isNotEmpty()) { + adaptersMapBuilder.add("\n") + } + adaptersMapBuilder.add(")") + + val adaptersProperty = PropertySpec.builder( + "adapters", + Map::class.asClassName().parameterizedBy( + typeClass, + Class::class.asClassName().parameterizedBy( + WildcardTypeName.producerOf(jsonAdapterClass.parameterizedBy(STAR)) + ) + ) + ) + .addModifiers(KModifier.OVERRIDE) + .getter( + FunSpec.getterBuilder() + .addCode("return %L", adaptersMapBuilder.build()) + .build() + ) + .build() + + val registryClass = TypeSpec.classBuilder(DEFAULT_ADAPTER_REGISTRY_CLASS_NAME) + .addSuperinterface(adapterRegistryClass) + .addProperty(adaptersProperty) + .build() + + val file = FileSpec.builder(registryPackageName, DEFAULT_ADAPTER_REGISTRY_CLASS_NAME) + .addFileComment( + """ + This file is auto generated by Moshi Kotlin CodeGen. + **DO NOT EDIT THIS FILE MANUALLY** + """.trimIndent() + ) + .addType(registryClass) + .build() + + return file + } +} diff --git a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt index d725595..dcee5f4 100644 --- a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt +++ b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt @@ -37,6 +37,27 @@ public object Options { */ public const val OPTION_GENERATE_PROGUARD_RULES: String = "moshi.generateProguardRules" + /** + * This boolean processing option can enable omission of `-keepnames` rules for target classes. + * Moshi's reflective lookup of adapters by class name will fail if obfuscation is applied + * to the target class. Only enable this if you are sure you won't be using Moshi's + * reflective adapter lookup. + * If you set [OPTION_GENERATE_DEFAULT_ADAPTER_REGISTRY] to `true` and decide to + * manually set the generated registry, you can also enable this option to + * avoid keeping target class names. (LET THEM BE OBFUSCATION, I DON'T WANT THE CLASS NAME TO BE RETAINED!) + * This option is automatically invalid if [OPTION_GENERATE_PROGUARD_RULES] is set to `false`. + * This is disabled by default for backward compatibility. + */ + public const val OPTION_PROGUARD_RULES_DONT_KEEP_CLASS_NAMES: String = "moshi.proguardRulesDontKeepClassNames" + + /** + * This boolean processing option can enable generation of a default adapter registry that + * a compile-time adapter registry that doesn't depend on class names for lookup. + * The generated registry content is a class annotated with `@JsonClass(generateAdapter = true)`. + * This is disabled by default for backward compatibility. + */ + public const val OPTION_GENERATE_DEFAULT_ADAPTER_REGISTRY: String = "moshi.generateDefaultAdapterRegistry" + /** * This boolean processing option controls whether or not Moshi will directly instantiate * JsonQualifier annotations in Kotlin 1.6+. Note that this is enabled by default in Kotlin 1.6 diff --git a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt index c7d682c..4331c1d 100644 --- a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt +++ b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt @@ -45,7 +45,7 @@ public data class ProguardConfig( return "META-INF/proguard/moshi-$canonicalName" } - public fun writeTo(out: Appendable): Unit = out.run { + public fun writeTo(out: Appendable, dontKeepClassNames: Boolean): Unit = out.run { // // -keepnames class {the target class} // -if class {the target class} @@ -56,11 +56,17 @@ public data class ProguardConfig( // val targetName = targetClass.reflectionName() val adapterCanonicalName = ClassName(targetClass.packageName, adapterName).canonicalName - // Keep the class name for Moshi's reflective lookup based on it - appendLine("-keepnames class $targetName") + + // Keep the class name for Moshi's reflective lookup based on it, + // but only if the user hasn't opted out of this. + if (!dontKeepClassNames) + appendLine("-keepnames class $targetName") + + // Keep the `JsonClass` annotation on the target class, R8 will shrink it away otherwise. + appendLine("-keep${if (dontKeepClassNames) ",allowobfuscation" else ""} @com.squareup.moshi.JsonClass class *") appendLine("-if class $targetName") - appendLine("-keep class $adapterCanonicalName {") + appendLine("-${if (dontKeepClassNames) "keepclassmembers" else "keep"} class $adapterCanonicalName {") // Keep the constructor for Moshi's reflective lookup val constructorArgs = adapterConstructorParams.joinToString(",") appendLine(" public ($constructorArgs);") diff --git a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt index 3df24d6..0855d75 100644 --- a/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt +++ b/moshi-kotlin-codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt @@ -24,16 +24,22 @@ import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.processing.SymbolProcessorProvider import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSFile import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.ksp.addOriginatingKSFile +import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import com.squareup.moshi.JsonClass import com.squareup.moshi.kotlin.codegen.api.AdapterGenerator +import com.squareup.moshi.kotlin.codegen.api.AdapterRegistration +import com.squareup.moshi.kotlin.codegen.api.AdapterRegistryGenerator import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATED +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_DEFAULT_ADAPTER_REGISTRY import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_PROGUARD_RULES +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_PROGUARD_RULES_DONT_KEEP_CLASS_NAMES import com.squareup.moshi.kotlin.codegen.api.Options.POSSIBLE_GENERATED_NAMES import com.squareup.moshi.kotlin.codegen.api.ProguardConfig import com.squareup.moshi.kotlin.codegen.api.PropertyGenerator @@ -63,6 +69,10 @@ private class JsonClassSymbolProcessor( } } private val generateProguardRules = environment.options[OPTION_GENERATE_PROGUARD_RULES]?.toBooleanStrictOrNull() ?: true + private val proguardRulesDontKeepClassNames = environment.options[OPTION_PROGUARD_RULES_DONT_KEEP_CLASS_NAMES]?.toBooleanStrictOrNull() ?: false + private val generateDefaultAdapterRegistry = environment.options[OPTION_GENERATE_DEFAULT_ADAPTER_REGISTRY]?.toBooleanStrictOrNull() ?: false + + private val adapterRegistrations = mutableListOf() override fun process(resolver: Resolver): List { val generatedAnnotation = generatedOption?.let { @@ -100,13 +110,40 @@ private class JsonClassSymbolProcessor( .build() } preparedAdapter.spec.writeTo(codeGenerator, aggregating = false) - preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile) + preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile, proguardRulesDontKeepClassNames) + + // Collect adapter registration information. + if (generateDefaultAdapterRegistry && type is KSClassDeclaration) { + val targetClassName = type.toClassName() + val adapterClassName = ClassName( + targetClassName.packageName, + "${targetClassName.simpleNames.joinToString("_")}JsonAdapter" + ) + val hasTypeParameters = type.typeParameters.isNotEmpty() + + adapterRegistrations.add( + AdapterRegistration( + targetClassName = targetClassName, + adapterClassName = adapterClassName, + hasTypeParameters = hasTypeParameters + ) + ) + } } catch (e: Exception) { logger.error( "Error preparing ${type.simpleName.asString()}: ${e.stackTrace.joinToString("\n")}", ) } } + + if (adapterRegistrations.isNotEmpty()) { + // FileAlreadyExistsException can happen if multiple compilations. + runCatching { + val adapterRegistryGenerator = adapterRegistryGenerator() + adapterRegistryGenerator.generate().writeTo(codeGenerator, aggregating = false) + } + } + return emptyList() } @@ -144,10 +181,12 @@ private class JsonClassSymbolProcessor( return AdapterGenerator(type, sortedProperties) } + + private fun adapterRegistryGenerator() = AdapterRegistryGenerator(adapterRegistrations) } /** Writes this config to a [codeGenerator]. */ -private fun ProguardConfig.writeTo(codeGenerator: CodeGenerator, originatingKSFile: KSFile) { +private fun ProguardConfig.writeTo(codeGenerator: CodeGenerator, originatingKSFile: KSFile, dontKeepClassNames: Boolean) { val file = codeGenerator.createNewFile( dependencies = Dependencies(aggregating = false, originatingKSFile), packageName = "", @@ -156,5 +195,5 @@ private fun ProguardConfig.writeTo(codeGenerator: CodeGenerator, originatingKSFi ) // Don't use writeTo(file) because that tries to handle directories under the hood OutputStreamWriter(file, StandardCharsets.UTF_8) - .use(::writeTo) + .use { writeTo(it, dontKeepClassNames) } } diff --git a/moshi/src/main/java/com/squareup/moshi/Moshi.kt b/moshi/src/main/java/com/squareup/moshi/Moshi.kt index d36856b..17b9eed 100644 --- a/moshi/src/main/java/com/squareup/moshi/Moshi.kt +++ b/moshi/src/main/java/com/squareup/moshi/Moshi.kt @@ -45,6 +45,8 @@ public class Moshi internal constructor(builder: Builder) { private val lookupChainThreadLocal = ThreadLocal() private val adapterCache = LinkedHashMap?>() + internal val registry = builder.registry + /** Returns a JSON adapter for `type`, creating it if necessary. */ @CheckReturnValue public fun adapter(type: Type): JsonAdapter = adapter(type, NO_ANNOTATIONS) @@ -191,6 +193,7 @@ public class Moshi internal constructor(builder: Builder) { public class Builder { internal val factories = mutableListOf() internal var lastOffset = 0 + internal var registry: AdapterRegistry? = null @CheckReturnValue @ExperimentalStdlibApi @@ -239,10 +242,42 @@ public class Moshi internal constructor(builder: Builder) { addLast(AdapterMethodsFactory(adapter)) } + public fun setRegistry(adapterRegistry: AdapterRegistry): Builder = apply { + this.registry = adapterRegistry + } + @CheckReturnValue public fun build(): Moshi = Moshi(this) } + /** + * A registry of [JsonAdapter]s to be registered with a [Moshi] instance. + * + * To use, create a subclass and implement [adapters]. Then set an instance of your subclass on + * [Moshi.Builder.setRegistry]. + * + * Example: + * ``` + * class MyAdapterRegistry : Moshi.AdapterRegistry { + * override val adapters = mapOf( + * MyType::class.java to MyTypeJsonAdapter::class.java, + * Types.newParameterizedType(List::class.java, String::class.java) to MyStringListJsonAdapter::class.java + * ) + * } + * + * val myRegistry = MyAdapterRegistry() + * + * val builder = Moshi.Builder().setRegistry(myRegistry) + * val moshi = builder.build() + * ``` + */ + public interface AdapterRegistry { + /** + * A map of [Type] to [JsonAdapter] to be registered. + */ + public val adapters: Map>> + } + /** * A possibly-reentrant chain of lookups for JSON adapters. * diff --git a/moshi/src/main/java/com/squareup/moshi/internal/Util.kt b/moshi/src/main/java/com/squareup/moshi/internal/Util.kt index fdd1959..12e14b9 100644 --- a/moshi/src/main/java/com/squareup/moshi/internal/Util.kt +++ b/moshi/src/main/java/com/squareup/moshi/internal/Util.kt @@ -378,12 +378,24 @@ public fun Moshi.generatedAdapter( if (jsonClass == null || !jsonClass.generateAdapter) { return null } - val adapterClassName = Types.generatedJsonAdapterName(rawType.name) + + val adapters = registry?.adapters + + val rawAdapterClassName = Types.generatedJsonAdapterName(rawType.name) var possiblyFoundAdapter: Class>? = null + return try { @Suppress("UNCHECKED_CAST") - val adapterClass = Class.forName(adapterClassName, true, rawType.classLoader) as Class> + val adapterClass = adapters?.firstNotNullOfOrNull { (regType, regAdapter) -> + if (typesMatch(rawType, regType)) { + regAdapter + } else { + null + } + } ?: Class.forName(rawAdapterClassName, true, rawType.classLoader) as? Class>? + ?: throw ClassNotFoundException() possiblyFoundAdapter = adapterClass + var constructor: Constructor> var args: Array if (type is ParameterizedType) {