Skip to content

Commit

Permalink
Merge pull request #4067 from square/jw.apt.2024-01-29
Browse files Browse the repository at this point in the history
Import response type keeper
  • Loading branch information
JakeWharton authored Feb 2, 2024
2 parents 4793b75 + e13f655 commit e34aee6
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 0 deletions.
9 changes: 9 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ okhttp = "3.14.9"
protobuf = "3.25.2"
robovm = "2.3.14"
kotlinx-serialization = "1.6.2"
autoService = "1.1.1"
incap = "1.0.0"

[libraries]
androidPlugin = { module = "com.android.tools.build:gradle", version = "8.2.2" }
Expand All @@ -41,6 +43,12 @@ protobufPlugin = "com.google.protobuf:protobuf-gradle-plugin:0.9.4"
protobuf = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }

incap-runtime = { module = "net.ltgt.gradle.incap:incap", version.ref = "incap" }
incap-processor = { module = "net.ltgt.gradle.incap:incap-processor", version.ref = "incap" }

autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" }
autoService-compiler = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }

kotlinCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.7.3" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
Expand Down Expand Up @@ -71,3 +79,4 @@ jsoup = { module = "org.jsoup:jsoup", version = "1.17.2" }
robovm = { module = "com.mobidevelop.robovm:robovm-rt", version.ref = "robovm" }
googleJavaFormat = "com.google.googlejavaformat:google-java-format:1.19.2"
ktlint = "com.pinterest.ktlint:ktlint-cli:1.1.1"
compileTesting = "com.google.testing.compile:compile-testing:0.21.0"
48 changes: 48 additions & 0 deletions retrofit-response-type-keeper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Response Type Keeper

Generates keep rules for types mentioned in generic parameter positions of Retrofit service methods.

## Problem

Given a service method like
```java
@GET("users/{id}")
Call<User> getUser(
@Path("id") String id);
```

If you execute this request and do not actually use the returned `User` instance, R8 will remove it
and replace the return type as `Call<?>`. This fails Retrofit's runtime validation since a wildcard
is not a valid type to pass to a converter. Note: this removal only occurs if the Retrofit's service
method definition is the only reference to `User`.

## Solution

This module contains an annotation processor which looks at each Retrofit method and generates
explicit `-keep` rules for the types mentioned.

Add it to Gradle Java projects with
```groovy
annotationProcessor 'com.squareup.retrofit2:response-type-keeper:<version>'
```
Or Gradle Kotlin projects with
```groovy
kapt 'com.squareup.retrofit2:response-type-keeper:<version>'
```

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
```

This works for nested generics, such as `Call<ApiResponse<User>>`, which would produce:
```
-keep com.example.ApiResponse
-keep com.example.User
```

It also works on Kotlin `suspend` functions which turn into a type like
`Continuation<? extends User>` in the Java bytecode.
15 changes: 15 additions & 0 deletions retrofit-response-type-keeper/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.jetbrains.kotlin.kapt'
apply plugin: 'com.vanniktech.maven.publish'

dependencies {
compileOnly libs.autoService.annotations
compileOnly libs.incap.runtime
kapt libs.autoService.compiler
kapt libs.incap.processor

testImplementation libs.junit
testImplementation libs.compileTesting
testImplementation libs.truth
testImplementation projects.retrofit
}
3 changes: 3 additions & 0 deletions retrofit-response-type-keeper/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=response-type-keeper
POM_NAME=Response Type Keeper
POM_DESCRIPTION=Annotation processor to generate R8 keep rules for types mentioned in generics.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2024 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.auto.service.AutoService
import javax.annotation.processing.AbstractProcessor
import javax.annotation.processing.Processor
import javax.annotation.processing.RoundEnvironment
import javax.lang.model.SourceVersion
import javax.lang.model.element.ExecutableElement
import javax.lang.model.element.TypeElement
import javax.lang.model.type.DeclaredType
import javax.lang.model.type.TypeMirror
import javax.lang.model.type.WildcardType
import javax.tools.StandardLocation.CLASS_OUTPUT
import net.ltgt.gradle.incap.IncrementalAnnotationProcessor
import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType.ISOLATING

@AutoService(Processor::class)
@IncrementalAnnotationProcessor(ISOLATING)
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 process(
annotations: Set<TypeElement>,
roundEnv: RoundEnvironment,
): Boolean {
val elements = processingEnv.elementUtils
val types = processingEnv.typeUtils

val methods = supportedAnnotationTypes
.mapNotNull(elements::getTypeElement)
.flatMap(roundEnv::getElementsAnnotatedWith)

val elementToReferencedTypes = mutableMapOf<TypeElement, MutableSet<String>>()
for (method in methods) {
val executableElement = method as ExecutableElement

val serviceType = method.enclosingElement as TypeElement
val referenced = elementToReferencedTypes.getOrPut(serviceType, ::LinkedHashSet)

val returnType = executableElement.returnType as DeclaredType
returnType.recursiveParameterizedTypesTo(referenced)

// Retrofit has special support for 'suspend fun' in Kotlin which manifests as a
// final Continuation parameter whose generic type is the declared return type.
executableElement.parameters
.lastOrNull()
?.asType()
?.takeIf { types.erasure(it).toString() == "kotlin.coroutines.Continuation" }
?.let { (it as DeclaredType).typeArguments.single() }
?.recursiveParameterizedTypesTo(referenced)
}

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)
rules.openWriter().buffered().use { w ->
w.write("# $typeName\n")
for (referencedType in referencedTypes.sorted()) {
w.write("-keep,allowobfuscation,allowoptimization class $referencedType\n")
}
}
}
return false
}

private fun TypeMirror.recursiveParameterizedTypesTo(types: MutableSet<String>) {
when (this) {
is WildcardType -> {
extendsBound?.recursiveParameterizedTypesTo(types)
superBound?.recursiveParameterizedTypesTo(types)
}
is DeclaredType -> {
for (typeArgument in typeArguments) {
typeArgument.recursiveParameterizedTypesTo(types)
}
types += (asElement() as TypeElement).qualifiedName.toString()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright (C) 2024 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.common.truth.Truth.assertAbout
import com.google.testing.compile.JavaFileObjects
import com.google.testing.compile.JavaSourceSubjectFactory.javaSource
import java.nio.charset.StandardCharsets.UTF_8
import javax.tools.StandardLocation.CLASS_OUTPUT
import org.junit.Test

class RetrofitResponseTypeKeepProcessorTest {
@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<DeleteUser> delete();
@GET("/") Call<GetUser> get();
@HEAD("/") Call<HeadUser> head();
@HTTP(method = "CUSTOM", path = "/") Call<HttpUser> http();
@OPTIONS("/") Call<OptionsUser> options();
@PATCH("/") Call<PatchUser> patch();
@POST("/") Call<PostUser> post();
@PUT("/") Call<PutUser> put();
}
""".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,allowobfuscation,allowoptimization class retrofit2.Call
|-keep,allowobfuscation,allowoptimization class test.DeleteUser
|-keep,allowobfuscation,allowoptimization class test.GetUser
|-keep,allowobfuscation,allowoptimization class test.HeadUser
|-keep,allowobfuscation,allowoptimization class test.HttpUser
|-keep,allowobfuscation,allowoptimization class test.OptionsUser
|-keep,allowobfuscation,allowoptimization class test.PatchUser
|-keep,allowobfuscation,allowoptimization class test.PostUser
|-keep,allowobfuscation,allowoptimization class test.PutUser
|
""".trimMargin(),
)
}

@Test
fun nesting() {
val service = JavaFileObjects.forSourceString(
"test.Service",
"""
package test;
import retrofit2.*;
import retrofit2.http.*;
class One<T> {}
class Two<T> {}
class Three {}
interface Service {
@GET("/") Call<One<Two<Three>>> get();
}
""".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,allowobfuscation,allowoptimization class retrofit2.Call
|-keep,allowobfuscation,allowoptimization class test.One
|-keep,allowobfuscation,allowoptimization class test.Three
|-keep,allowobfuscation,allowoptimization 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<? extends Body> c);
}
""".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,allowobfuscation,allowoptimization class java.lang.Object
|-keep,allowobfuscation,allowoptimization class test.Body
|
""".trimMargin(),
)
}
}
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ include ':retrofit:test-helpers'

include ':retrofit-mock'

include ':retrofit-response-type-keeper'

include ':retrofit-adapters:guava'
include ':retrofit-adapters:java8'
include ':retrofit-adapters:rxjava'
Expand Down

0 comments on commit e34aee6

Please sign in to comment.