diff --git a/CHANGELOG.md b/CHANGELOG.md index 582b98174c..b6b237af4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add explicit keep rules for RxJava `Result` types to prevent their generic information from being removed. - Add `allowoptimization` flags for most kept types. - Add `Invocation.annotationUrl` which returns the original URL from the method annotation. + - Support using response type keeper with KSP. **Changed** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bce13c85ed..fecc63878b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ okhttp = "5.3.0" protobuf = "3.25.8" robovm = "2.3.14" kotlinx-serialization = "1.9.0" +kct = "0.10.0" autoService = "1.1.1" incap = "1.0.0" jackson = "2.20.1" @@ -33,6 +34,8 @@ kotlin-stdLib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-serializationPlugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } +ksp-api = "com.google.devtools.ksp:symbol-processing-api:2.3.0" + errorpronePlugin = "net.ltgt.gradle:gradle-errorprone-plugin:4.3.0" errorproneCore = { module = "com.google.errorprone:error_prone_core", version = "2.10.0" } errorproneJavac = { module = "com.google.errorprone:javac", version = "9+181-r4173-1" } @@ -78,3 +81,5 @@ googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.31.0" ktlint = "com.pinterest.ktlint:ktlint-cli:1.7.1" compileTesting = "com.google.testing.compile:compile-testing:0.23.0" testParameterInjector = "com.google.testparameterinjector:test-parameter-injector:1.19" +kct-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kct" } +kct-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kct" } diff --git a/retrofit-response-type-keeper/README.md b/retrofit-response-type-keeper/README.md index 099d5ee1e8..7d786dca2b 100644 --- a/retrofit-response-type-keeper/README.md +++ b/retrofit-response-type-keeper/README.md @@ -27,21 +27,21 @@ annotationProcessor 'com.squareup.retrofit2:response-type-keeper:' ``` Or Gradle Kotlin projects with ```groovy -kapt 'com.squareup.retrofit2:response-type-keeper:' +ksp 'com.squareup.retrofit2:response-type-keeper:' ``` For other build systems, the `com.squareup.retrofit2:response-type-keeper` needs added to the Java compiler `-processor` classpath. For the example above, the annotation processor's generated file would contain -``` --keep com.example.User +```proguard +-keep,allowoptimization,allowshrinking,allowobfuscation class com.example.User ``` This works for nested generics, such as `Call>`, which would produce: -``` --keep com.example.ApiResponse --keep com.example.User +```proguard +-keep,allowoptimization,allowshrinking,allowobfuscation class com.example.ApiResponse +-keep,allowoptimization,allowshrinking,allowobfuscation class com.example.User ``` It also works on Kotlin `suspend` functions which turn into a type like diff --git a/retrofit-response-type-keeper/build.gradle b/retrofit-response-type-keeper/build.gradle index f4709ddfbc..6cb3db3616 100644 --- a/retrofit-response-type-keeper/build.gradle +++ b/retrofit-response-type-keeper/build.gradle @@ -2,8 +2,13 @@ apply plugin: 'org.jetbrains.kotlin.jvm' apply plugin: 'com.vanniktech.maven.publish' dependencies { + compileOnly libs.ksp.api + testImplementation libs.junit testImplementation libs.compileTesting + testImplementation libs.kct.core + testImplementation libs.kct.ksp testImplementation libs.truth + testImplementation libs.testParameterInjector testImplementation projects.retrofit } diff --git a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt index 72a66568b0..b0c73b78da 100644 --- a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt +++ b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt @@ -27,16 +27,7 @@ import javax.tools.StandardLocation.CLASS_OUTPUT class RetrofitResponseTypeKeepProcessor : AbstractProcessor() { override fun getSupportedSourceVersion() = SourceVersion.latestSupported() - override fun getSupportedAnnotationTypes() = setOf( - "retrofit2.http.DELETE", - "retrofit2.http.GET", - "retrofit2.http.HEAD", - "retrofit2.http.HTTP", - "retrofit2.http.OPTIONS", - "retrofit2.http.PATCH", - "retrofit2.http.POST", - "retrofit2.http.PUT", - ) + override fun getSupportedAnnotationTypes() = annotationNames override fun process( annotations: Set, @@ -71,12 +62,11 @@ class RetrofitResponseTypeKeepProcessor : AbstractProcessor() { for ((element, referencedTypes) in elementToReferencedTypes) { val typeName = element.qualifiedName.toString() - val outputFile = "META-INF/proguard/retrofit-response-type-keeper-$typeName.pro" - val rules = processingEnv.filer.createResource(CLASS_OUTPUT, "", outputFile, element) + val rules = processingEnv.filer.createResource(CLASS_OUTPUT, "", proguardFilePath(typeName), element) rules.openWriter().buffered().use { w -> w.write("# $typeName\n") for (referencedType in referencedTypes.sorted()) { - w.write("-keep,allowoptimization,allowshrinking,allowobfuscation class $referencedType\n") + w.write(keepRuleForType(referencedType)) } } } diff --git a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepSymbolProcessor.kt b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepSymbolProcessor.kt new file mode 100644 index 0000000000..d33123ecad --- /dev/null +++ b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepSymbolProcessor.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2025 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 retrofit2.keeper + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +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.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier + +class RetrofitResponseTypeKeepSymbolProcessor( + environment: SymbolProcessorEnvironment, +) : SymbolProcessor { + private val codeGenerator: CodeGenerator = environment.codeGenerator + + override fun process(resolver: Resolver): List { + val elementToReferencedTypes = mutableMapOf>() + + annotationNames.flatMap { resolver.getSymbolsWithAnnotation(it) } + .filterIsInstance() + .forEach { function -> + val serviceType = function.parentDeclaration as? KSClassDeclaration ?: return@forEach + val referenced = elementToReferencedTypes.getOrPut(serviceType, ::LinkedHashSet) + + // Retrofit has special support for 'suspend fun' in Kotlin which manifests as a + // final Continuation parameter whose generic type is the declared return type. + if (function.modifiers.contains(Modifier.SUSPEND)) { + function.parameters.forEach { + it.type.resolve().recursiveParameterizedTypesTo(referenced) + } + } + + val returnType = function.returnType?.resolve() ?: return@forEach + returnType.recursiveParameterizedTypesTo(referenced) + } + + elementToReferencedTypes.forEach { (element, referencedTypes) -> + val containingFile = element.containingFile ?: return@forEach + val typeName = element.qualifiedName?.asString() ?: return@forEach + + val dependencies = Dependencies(aggregating = false, containingFile) + codeGenerator.createNewFile(dependencies, "", proguardFilePath(typeName), "") + .bufferedWriter().use { w -> + w.write("# $typeName\n") + for (referencedType in referencedTypes.sorted()) { + w.write(keepRuleForType(referencedType)) + } + } + } + + return emptyList() + } + + private fun KSType.recursiveParameterizedTypesTo(types: MutableSet) { + val declaration = this.declaration + if (declaration is KSClassDeclaration) { + var qualifiedName = declaration.qualifiedName?.asString() + if (qualifiedName == "kotlin.Any") { + qualifiedName = "java.lang.Object" + } + qualifiedName?.let { types.add(it) } + } + + for (typeArgument in arguments) { + typeArgument.type?.resolve()?.recursiveParameterizedTypesTo(types) + } + } + + class Provider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + RetrofitResponseTypeKeepSymbolProcessor(environment) + } +} diff --git a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/Utils.kt b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/Utils.kt new file mode 100644 index 0000000000..4ae458c612 --- /dev/null +++ b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/Utils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 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 retrofit2.keeper + +internal val annotationNames = setOf( + "retrofit2.http.DELETE", + "retrofit2.http.GET", + "retrofit2.http.HEAD", + "retrofit2.http.HTTP", + "retrofit2.http.OPTIONS", + "retrofit2.http.PATCH", + "retrofit2.http.POST", + "retrofit2.http.PUT", +) + +internal fun keepRuleForType(referencedType: String): String = + "-keep,allowoptimization,allowshrinking,allowobfuscation class $referencedType\n" + +internal fun proguardFilePath(typeName: String) = + "META-INF/proguard/retrofit-response-type-keeper-$typeName.pro" diff --git a/retrofit-response-type-keeper/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/retrofit-response-type-keeper/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000000..4d7bb0d66a --- /dev/null +++ b/retrofit-response-type-keeper/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +retrofit2.keeper.RetrofitResponseTypeKeepSymbolProcessor$Provider diff --git a/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt index 23cc38bb9c..7e2a1ab5b8 100644 --- a/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt +++ b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt @@ -16,147 +16,258 @@ package retrofit2.keeper import com.google.common.truth.Truth.assertAbout +import com.google.common.truth.Truth.assertThat import com.google.testing.compile.JavaFileObjects import com.google.testing.compile.JavaSourceSubjectFactory.javaSource +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.configureKsp +import com.tschuchort.compiletesting.kspSourcesDir import java.nio.charset.StandardCharsets.UTF_8 import javax.tools.StandardLocation.CLASS_OUTPUT +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.junit.Test +import org.junit.runner.RunWith -class RetrofitResponseTypeKeepProcessorTest { +@RunWith(TestParameterInjector::class) +class RetrofitResponseTypeKeepProcessorTest( + @param:TestParameter private val processor: Processor, +) { @Test fun allHttpMethods() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import retrofit2.*; - import retrofit2.http.*; - - class DeleteUser {} - class GetUser {} - class HeadUser {} - class HttpUser {} - class OptionsUser {} - class PatchUser {} - class PostUser {} - class PutUser {} - - interface Service { - @DELETE("/") Call delete(); - @GET("/") Call get(); - @HEAD("/") Call head(); - @HTTP(method = "CUSTOM", path = "/") Call http(); - @OPTIONS("/") Call options(); - @PATCH("/") Call patch(); - @POST("/") Call post(); - @PUT("/") Call put(); + val rules = """ + |# test.Service + |-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Call + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.DeleteUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.GetUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.HeadUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.HttpUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.OptionsUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PatchUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PostUser + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PutUser + | + """.trimMargin() + + when (processor) { + Processor.Apt -> { + val source = """ + package test; + + import retrofit2.*; + import retrofit2.http.*; + + class DeleteUser {} + class GetUser {} + class HeadUser {} + class HttpUser {} + class OptionsUser {} + class PatchUser {} + class PostUser {} + class PutUser {} + + interface Service { + @DELETE("/") Call delete(); + @GET("/") Call get(); + @HEAD("/") Call head(); + @HTTP(method = "CUSTOM", path = "/") Call http(); + @OPTIONS("/") Call options(); + @PATCH("/") Call patch(); + @POST("/") Call post(); + @PUT("/") Call put(); + } + """.trimIndent() + processor.validate(source, rules) + } + + Processor.Ksp -> { + val source = """ + package test + + import retrofit2.* + import retrofit2.http.* + + class DeleteUser + class GetUser + class HeadUser + class HttpUser + class OptionsUser + class PatchUser + class PostUser + class PutUser + + interface Service { + @DELETE("/") + fun delete(): Call + + @GET("/") + fun get(): Call + + @HEAD("/") + fun head(): Call + + @retrofit2.http.HTTP(method = "CUSTOM", path = "/") + fun http(): Call + + @OPTIONS("/") + fun options(): Call + + @PATCH("/") + fun patch(): Call + + @POST("/") + fun post(): Call + + @PUT("/") + fun put(): Call + } + """.trimIndent() + processor.validate(source, rules) } - """.trimIndent(), - ) - - assertAbout(javaSource()) - .that(service) - .processedWith(RetrofitResponseTypeKeepProcessor()) - .compilesWithoutError() - .and() - .generatesFileNamed( - CLASS_OUTPUT, - "", - "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( - UTF_8, - """ - |# test.Service - |-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Call - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.DeleteUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.GetUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.HeadUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.HttpUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.OptionsUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PatchUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PostUser - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PutUser - | - """.trimMargin(), - ) + } } @Test fun nesting() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import retrofit2.*; - import retrofit2.http.*; - - class One {} - class Two {} - class Three {} - - interface Service { - @GET("/") Call>> get(); + val rules = """ + |# test.Service + |-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Call + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.One + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Three + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Two + | + """.trimMargin() + when (processor) { + Processor.Apt -> { + val source = """ + package test; + + import retrofit2.*; + import retrofit2.http.*; + + class One {} + class Two {} + class Three {} + + interface Service { + @GET("/") Call>> get(); + } + """.trimIndent() + processor.validate(source, rules) + } + + Processor.Ksp -> { + val source = """ + package test + + import retrofit2.* + import retrofit2.http.* + + internal class One + internal class Two + internal class Three + + internal interface Service { + @GET("/") + fun get(): Call>> + } + """.trimIndent() + processor.validate(source, rules) } - """.trimIndent(), - ) - - assertAbout(javaSource()) - .that(service) - .processedWith(RetrofitResponseTypeKeepProcessor()) - .compilesWithoutError() - .and() - .generatesFileNamed( - CLASS_OUTPUT, - "", - "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( - UTF_8, - """ - |# test.Service - |-keep,allowoptimization,allowshrinking,allowobfuscation class retrofit2.Call - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.One - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Three - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Two - | - """.trimMargin(), - ) + } } @Test fun kotlinSuspend() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import kotlin.coroutines.Continuation; - import retrofit2.*; - import retrofit2.http.*; - - class Body {} - - interface Service { - @GET("/") Object get(Continuation c); + val rules = """ + |# test.Service + |-keep,allowoptimization,allowshrinking,allowobfuscation class java.lang.Object + |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Body + | + """.trimMargin() + when (processor) { + Processor.Apt -> { + val source = """ + package test; + + import kotlin.coroutines.Continuation; + import retrofit2.*; + import retrofit2.http.*; + + class Body {} + + interface Service { + @GET("/") Object get(Continuation c); + } + """.trimIndent() + processor.validate(source, rules) + } + + Processor.Ksp -> { + val source = """ + package test + + import retrofit2.* + import retrofit2.http.* + + internal class Body + + internal interface Service { + @GET("/") + suspend fun get(c: Body): Any + } + """.trimIndent() + processor.validate(source, rules) + } + } + } + + enum class Processor { + Apt { + override fun validate(source: String, rules: String) { + val service = JavaFileObjects.forSourceString("test.Service", source) + assertAbout(javaSource()) + .that(service) + .processedWith(RetrofitResponseTypeKeepProcessor()) + .compilesWithoutError() + .and() + .generatesFileNamed( + CLASS_OUTPUT, + "", + GENERATED_PATH, + ).withStringContents( + UTF_8, + rules, + ) } - """.trimIndent(), - ) - - assertAbout(javaSource()) - .that(service) - .processedWith(RetrofitResponseTypeKeepProcessor()) - .compilesWithoutError() - .and() - .generatesFileNamed( - CLASS_OUTPUT, - "", - "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( - UTF_8, - """ - |# test.Service - |-keep,allowoptimization,allowshrinking,allowobfuscation class java.lang.Object - |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Body - | - """.trimMargin(), - ) + }, + + Ksp { + @OptIn(ExperimentalCompilerApi::class) + override fun validate(source: String, rules: String) { + val compilation = KotlinCompilation().apply { + configureKsp { + inheritClassPath = true + symbolProcessorProviders += RetrofitResponseTypeKeepSymbolProcessor.Provider() + sources = listOf(SourceFile.new("Service.kt", source)) + } + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(ExitCode.OK) + assertThat(compilation.kspSourcesDir.resolve("resources/$GENERATED_PATH").readText()) + .isEqualTo(rules) + } + }, + ; + + abstract fun validate(source: String, rules: String) + + private companion object { + const val GENERATED_PATH = "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro" + } } }